Remotes view: unified table display for connected and local remotes

- Remotes and Local Remotes merged into a single grid/table layout; upstream connected remotes listed first, local remotes after
- Expanding a remote row shows its functions as indented rows; any number of remotes can be expanded simultaneously
- Each function row shows name and description, and expands into a detail section (10em min height) with JSON arguments on the left and call output on the right
- Connected and local remotes are visually identical; local remotes additionally offer connect/disconnect and delete options plus a connected status
- Disconnected local remotes show an editable function interface (args editor + JS body); when connected, functions become callable instead (only one mode shown at a time)
- Local remote<->upstream connection matching persisted across view reloads via sessionStorage (browser-session scoped, matching connection lifetime), not across tab reloads
- Local remote store and remote-call listener moved to module scope so they survive view reloads; stale matching flags are dropped when the socket connection is gone
This commit is contained in:
root authored and ShiveringOcelot committed 2026-09-21 11:17:14 -05:00
1 parent e1063fc733
commit 4dc776dda4
1 file changed
+467 -333
+467 -333
View File
@@ -1,57 +1,82 @@
<script setup>
import TitleBar from '../components/TitleBar.vue';
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
<script>
// Module-scope store for local remotes. Lives outside the component so it
// survives view reloads (navigation), while connections themselves die with
// the websocket (browser session), matching the upstream remote lifecycle.
import { ref } from 'vue';
import * as backend from '../backend';
import * as ui from '../ui';
/** Connected external tool remotes (initialized with mock data) */
const remotes = ref([]);
/** 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';
let connectListenerCancel = null;
let disconnectListenerCancel = null;
/** sessionStorage flag marking a local remote as matched with its upstream
* connection. sessionStorage is browser-session scoped, which is exactly the
* lifetime of a remote connection, so matching survives view reloads but not
* tab reloads. */
const connectedFlagKey = (name) => `local_remote_connected:${name}`;
// Listen for remote-call requests destined for local remotes.
// Persists for the session, not just while the tab is visible.
// TODO: restore after disconnections
let callListenerCancel = null;
let idCounter = 0;
function nextId() { return ++idCounter; }
onMounted(async () => {
await fetchRemotes();
let connectListenerCancel = backend.onAction('remote-connected', ev => {
remotes.value.push(ev.data.remote);
});
let disconnectListenerCancel = backend.onAction('remote-disconnected', ev => {
remotes.value = remotes.value.filter((r) => r.name != ev.data.remotename);
});
/** Local remote definitions persisted in localStorage (connection state excluded) */
const localRemotes = ref([]);
if (callListenerCancel == null) {
callListenerCancel = backend.onAction('remote-call', async ({ requestId, remotename, function: fnName, args }) => {
function loadLocalRemotes() {
let data = [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) data = parsed;
}
} catch (e) {}
localRemotes.value = data.map(remote => ({
id: nextId(),
name: remote.name || '',
connected: false,
functions: (remote.functions || []).map(fn => ({
id: nextId(),
name: fn.name || '',
description: fn.description || '',
arguments: Array.isArray(fn.arguments) ? fn.arguments : [],
body: fn.body || '// Return your result object\nreturn {};\n',
})),
}));
}
function saveLocalRemotes() {
// 'connected' is connection state, never persisted to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(localRemotes.value.map(remote => ({
name: remote.name,
functions: remote.functions.map(fn => ({
name: fn.name,
description: fn.description,
arguments: fn.arguments,
body: fn.body,
})),
}))));
}
/** Whether a local remote name is remembered as connected this browser session */
function isMatchedConnected(name) {
try { return sessionStorage.getItem(connectedFlagKey(name)) === '1'; }
catch (e) { return false; }
}
/** Set the local<->upstream connection match for this browser session */
function setMatchedConnected(remote, connected) {
remote.connected = connected;
try {
if (connected) sessionStorage.setItem(connectedFlagKey(remote.name), '1');
else sessionStorage.removeItem(connectedFlagKey(remote.name));
} catch (e) {}
}
loadLocalRemotes();
// Answer calls routed to connected local remotes. The module runs once per
// page, so the listener survives view reloads, exactly like the connection.
backend.onAction('remote-call', async ({ requestId, remotename, function: fnName, args }) => {
console.log(`Received remote call for: ${remotename}, ${fnName}`);
const local = localRemoteList.value.find(r => r.name == remotename && r.connected);
const local = localRemotes.value.find(r => r.name == remotename && r.connected);
if (!local) return;
const funcDef = local.functions.find(f => f.name == fnName);
if (!funcDef) {
@@ -69,110 +94,218 @@ onMounted(async () => {
} catch (e) {
await backend.sendAction('remote-call-result', { requestId, error: String(e) });
}
});
});
</script>
<script setup>
import TitleBar from '../components/TitleBar.vue';
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import * as backend from '../backend';
import * as ui from '../ui';
/** Connected upstream remotes (source of truth from the backend) */
const upstreamRemotes = ref([]);
/** Expanded remote rows, keyed by row key; several can be open at once */
const expandedRemotes = ref(new Set());
/** Expanded function rows, keyed by `<rowKey>|<fnKey>` */
const expandedFns = ref(new Set());
/** Per-function call state, keyed like expandedFns */
const callStates = reactive({});
let connectListenerCancel = null;
let disconnectListenerCancel = null;
onMounted(async () => {
try {
upstreamRemotes.value = (await backend.sendAction('remote-list')).remotes || [];
} catch (e) {
upstreamRemotes.value = [];
}
// Restore local<->upstream matching from this browser session. A flag
// without a matching upstream connection is stale (socket died), drop it.
for (const remote of localRemotes.value) {
if (remote.connected) continue;
if (isMatchedConnected(remote.name) && findUpstream(remote.name)) {
remote.connected = true;
} else {
setMatchedConnected(remote, false);
}
}
connectListenerCancel = backend.onAction('remote-connected', ev => {
const remote = ev.data.remote;
if (!upstreamRemotes.value.some(r => r.name === remote.name)) {
upstreamRemotes.value.push(remote);
}
// If this is the upstream connection of a local remote we initiated
// connecting this session, complete the match.
const local = localRemotes.value.find(r => r.name === remote.name);
if (local && !local.connected && isMatchedConnected(remote.name)) {
local.connected = true;
}
});
disconnectListenerCancel = backend.onAction('remote-disconnected', ev => {
upstreamRemotes.value = upstreamRemotes.value.filter(r => r.name != ev.data.remotename);
// The upstream connection of a matched local remote is gone
const local = localRemotes.value.find(r => r.name === ev.data.remotename);
if (local && local.connected) setMatchedConnected(local, false);
});
});
onUnmounted(async () => {
onUnmounted(() => {
if (connectListenerCancel) connectListenerCancel();
if (disconnectListenerCancel) disconnectListenerCancel();
})
});
async function fetchRemotes() {
remotes.value = (await backend.sendAction('remote-list')).remotes;
// ---- Unified row model ----
/** Upstream connection serving a name, if any */
function findUpstream(name) {
return upstreamRemotes.value.find(r => r.name === name);
}
/** 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);
/**
* All rows of the unified table: connected upstream remotes first, local
* remotes after. The upstream connection of a connected local remote is
* matched into the local row instead of being listed separately.
*/
const remoteRows = computed(() => {
const connectedLocalNames = new Set(
localRemotes.value.filter(r => r.connected).map(r => r.name)
);
const rows = upstreamRemotes.value
.filter(r => !connectedLocalNames.has(r.name))
.map(r => ({ kind: 'upstream', key: `up:${r.name}`, remote: r }));
for (const remote of localRemotes.value) {
rows.push({ kind: 'local', key: `local:${remote.id}`, remote });
}
expandedRemoteIds.value = newSet;
return rows;
});
function rowKey(row) { return row.key; }
/** Row is in the connected state (upstream remotes always are) */
function isRowConnected(row) {
return row.kind === 'upstream' || row.remote.connected;
}
/** 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();
/** Local remotes disconnected from their upstream show the edit interface */
function isEditable(row) {
return row.kind === 'local' && !row.remote.connected;
}
/**
* Functions listed under a row. Connected remotes (upstream, or local remotes
* matched with their upstream) show the upstream function specs; only
* disconnected local remotes show the editable local definitions.
*/
function functionsOf(row) {
if (isEditable(row)) return row.remote.functions;
if (row.kind === 'upstream') return row.remote.functions || [];
const up = findUpstream(row.remote.name);
if (up) return up.functions || [];
return row.remote.functions.map(f => ({
name: f.name,
description: f.description,
parameters: {
type: 'object',
properties: f.arguments.reduce((acc, arg) => {
acc[arg.name] = { type: arg.type, description: '' };
return acc;
}, {}),
required: f.arguments.map(a => a.name),
},
}));
}
// ---- Expansion ----
function toggleRemote(key) {
const set = new Set(expandedRemotes.value);
if (set.has(key)) set.delete(key); else set.add(key);
expandedRemotes.value = set;
}
function fnKey(row, fn) {
return fn.id != null ? `l${fn.id}` : `u${fn.name}`;
}
function fnStateKey(row, fn) {
return `${row.key}|${fnKey(row, fn)}`;
}
function toggleFn(row, fn) {
const key = fnStateKey(row, fn);
const set = new Set(expandedFns.value);
if (set.has(key)) set.delete(key); else set.add(key);
expandedFns.value = set;
}
// ---- Calling ----
/** Type-appropriate default argument JSON for a function spec */
function defaultArgsJson(fn) {
const props = fn.parameters?.properties || {};
const out = {};
for (const [name, p] of Object.entries(props)) {
const type = p.type;
out[name] =
type === 'string' ? '' :
type === 'number' ? 0 :
type === 'boolean' ? false :
type === 'array' ? [] :
type === 'object' ? {} : null;
}
return JSON.stringify(out, null, 2);
}
/** 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;
function callStateFor(row, fn) {
const key = fnStateKey(row, fn);
if (!callStates[key]) {
callStates[key] = { args: defaultArgsJson(fn), result: null, error: '', busy: false };
}
resetCallState();
return callStates[key];
}
/** 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;
}
async function doCall(row, fn) {
const state = callStateFor(row, fn);
state.result = null;
state.error = '';
let argsParsed;
try {
argsParsed = activeArgsJson.value.trim() ? JSON.parse(activeArgsJson.value) : {};
argsParsed = state.args.trim() ? JSON.parse(state.args) : {};
} catch (e) {
callError.value = 'Invalid JSON args: ' + e.message;
state.error = 'Invalid JSON args: ' + e.message;
return;
}
state.busy = true;
try {
callResult.value = (await backend.sendAction('remote-call', {
remotename: activeRemoteName.value,
function: activeFunction.value.name,
const response = await backend.sendAction('remote-call', {
remotename: row.remote.name,
function: fn.name,
args: argsParsed,
})).result;
});
state.result = response.result;
} catch (e) {
callError.value = e.message || String(e);
state.error = e.message || String(e);
} finally {
state.busy = false;
}
}
// ---- Local Remotes ----
function loadLocalRemoteList() {
let data = [];
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed)) data = parsed;
}
} catch (e) {}
data.forEach(r => {
r.connected = false
r.functions = r.functions || [];
});
localRemoteList.value = data;
}
// ---- Local remotes ----
function createLocalRemote() {
localRemoteList.value.push({ name: '', connected: false });
}
function saveLocalRemote() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(localRemoteList.value));
localRemotes.value.push({
id: nextId(),
name: '',
connected: false,
functions: [],
});
saveLocalRemotes();
}
async function connectLocalRemote(remote) {
@@ -180,11 +313,13 @@ async function connectLocalRemote(remote) {
ui.alert("Cannot connect remote with empty name");
return;
}
let result = await backend.sendAction('remote-connect', {
let result;
try {
result = await backend.sendAction('remote-connect', {
remotename: remote.name,
functions: remote.functions?.map(f => ({
functions: remote.functions.map(f => ({
name: f.name,
description: '',
description: f.description || '',
parameters: {
type: 'object',
properties: f.arguments.reduce((acc, arg) => {
@@ -193,73 +328,63 @@ async function connectLocalRemote(remote) {
}, {}),
required: f.arguments.map(a => a.name)
}
})) || [],
})),
});
} catch (e) {
ui.alert(e.message || String(e));
return;
}
if (result.error) {
ui.alert(result.error);
} else {
remote.connected = true;
// persist the chosen name
saveLocalRemote();
return;
}
// Remember the local<->upstream match for this browser session
setMatchedConnected(remote, true);
saveLocalRemotes();
}
async function disconnectLocalRemote(remote) {
let result = await backend.sendAction('remote-disconnect', {
remotename: remote.name
});
if (result.error) {
ui.alert(result.error);
try {
const result = await backend.sendAction('remote-disconnect', { remotename: remote.name });
if (result.error) ui.alert(result.error);
} catch (e) {
// Socket gone: the upstream connection is dead either way
}
// always "shown" disconnected
remote.connected = false;
setMatchedConnected(remote, false);
}
function removeLocalRemote(remote) {
if (remote.connected) disconnectLocalRemote(remote);
const index = localRemoteList.value.indexOf(remote);
if (index > -1) localRemoteList.value.splice(index, 1);
saveLocalRemote();
const index = localRemotes.value.indexOf(remote);
if (index > -1) localRemotes.value.splice(index, 1);
saveLocalRemotes();
}
function addFunctionToRemote(remote) {
if (!remote.functions) remote.functions = [];
remote.functions.push({
id: nextId(),
name: `function_${Date.now()}`,
description: '',
arguments: [{ name: 'arg0', type: 'string' }],
body: '// Return your result object\nreturn {};\n'
});
saveLocalRemote();
saveLocalRemotes();
}
function removeFunctionFromRemote(remote, index) {
remote.functions.splice(index, 1);
saveLocalRemote();
function removeFunctionFromRemote(remote, fn) {
const index = remote.functions.indexOf(fn);
if (index > -1) remote.functions.splice(index, 1);
saveLocalRemotes();
}
function addArgument(fn) {
const idx = fn.arguments.length;
fn.arguments.push({ name: `arg${idx}`, type: 'string' });
saveLocalRemote();
fn.arguments.push({ name: `arg${fn.arguments.length}`, type: 'string' });
saveLocalRemotes();
}
function removeArgument(fn, argIdx) {
fn.arguments.splice(argIdx, 1);
saveLocalRemote();
}
loadLocalRemoteList();
// ---- UI State and Helpers ---- //
const fnExpandedSet = ref(new Set());
function toggleFnExpanded(index, fIdx) {
const key = `${index}-${fIdx}`;
const newSet = new Set(fnExpandedSet.value);
if (newSet.has(key)) newSet.delete(key); else newSet.add(key);
fnExpandedSet.value = newSet;
saveLocalRemotes();
}
</script>
@@ -267,139 +392,140 @@ function toggleFnExpanded(index, fIdx) {
<main>
<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>
<section class="section">
<!-- Unified table: connected remotes first, local remotes after -->
<div class="rt">
<div class="rt-row rt-head">
<div class="col-arrow"></div>
<div class="col-name">Remote</div>
<div class="col-status">Status</div>
<div class="col-options">Options</div>
</div>
<div class="call-layout">
<div class="call-inputs">
<template v-for="row in remoteRows" :key="rowKey(row)">
<!-- Remote row -->
<div class="rt-row rt-remote" :class="{ open: expandedRemotes.has(row.key) }">
<div class="col-arrow toggle" @click="toggleRemote(row.key)">
{{ expandedRemotes.has(row.key) ? '▼' : '▶' }}
</div>
<div class="col-name">
<input
v-if="row.kind === 'local' && !row.remote.connected"
type="text"
v-model="row.remote.name"
placeholder="Remote name..."
class="name-input"
@change="saveLocalRemotes"
/>
<strong v-else>{{ row.remote.name }}</strong>
</div>
<div class="col-status">
<span class="status-dot" :class="{ active: isRowConnected(row) }"></span>
<span class="status-text">{{ isRowConnected(row) ? 'Connected' : 'Disconnected' }}</span>
</div>
<div class="col-options">
<template v-if="row.kind === 'local'">
<button v-if="!row.remote.connected" class="btn btn-primary btn-sm" @click="connectLocalRemote(row.remote)">Connect</button>
<button v-else class="btn btn-secondary btn-sm" @click="disconnectLocalRemote(row.remote)">Disconnect</button>
<button class="btn btn-danger btn-sm" @click="removeLocalRemote(row.remote)">Delete</button>
</template>
<span v-else class="muted">—</span>
</div>
</div>
<!-- Expanded: function rows -->
<template v-if="expandedRemotes.has(row.key)">
<div class="rt-row rt-fnhead">
<div class="col-arrow"></div>
<div class="col-status muted">Functions ({{ functionsOf(row).length }})</div>
<div class="col-options">
<button
v-if="isEditable(row)"
class="btn btn-primary btn-xs"
@click="addFunctionToRemote(row.remote)"
>+ Add Function</button>
</div>
</div>
<div v-if="functionsOf(row).length === 0" class="rt-row rt-fn empty-row">
<div class="col-arrow"></div>
<div class="col-status muted">No functions.</div>
<div class="col-options"></div>
</div>
<template v-for="fn in functionsOf(row)" :key="fnKey(row, fn)">
<!-- Function row -->
<div class="rt-row rt-fn">
<div class="col-arrow toggle" @click="toggleFn(row, fn)">
{{ expandedFns.has(fnStateKey(row, fn)) ? '▼' : '▶' }}
</div>
<div class="col-name">
<input
v-if="isEditable(row)"
type="text"
v-model="fn.name"
class="name-input mono"
@change="saveLocalRemotes"
/>
<span v-else class="mono">{{ fn.name }}</span>
</div>
<div class="col-status">
<input
v-if="isEditable(row)"
type="text"
v-model="fn.description"
placeholder="Description..."
class="name-input"
@change="saveLocalRemotes"
/>
<span v-else class="muted">{{ fn.description }}</span>
</div>
<div class="col-options">
<button
v-if="isEditable(row)"
class="btn btn-danger btn-xs"
@click="removeFunctionFromRemote(row.remote, fn)"
>Remove</button>
<span v-else class="muted">—</span>
</div>
</div>
<!-- Expanded function detail: left = arguments, right = output/body -->
<div v-if="expandedFns.has(fnStateKey(row, fn))" class="rt-row rt-fndetail">
<div class="col-arrow"></div>
<div class="detail">
<!-- Callable mode (connected remotes) -->
<template v-if="!isEditable(row)">
<div class="detail-left">
<label>Arguments (JSON)</label>
<textarea
v-model="activeArgsJson"
rows="6"
v-model="callStateFor(row, fn).args"
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 class="btn btn-primary btn-sm" :disabled="callStateFor(row, fn).busy" @click="doCall(row, fn)">
{{ callStateFor(row, fn).busy ? 'Calling...' : 'Execute Call' }}
</button>
</div>
<div class="detail-right">
<label>Output</label>
<pre v-if="callStateFor(row, fn).result !== null" class="output success">{{ JSON.stringify(callStateFor(row, fn).result, null, 2) }}</pre>
<pre v-else-if="callStateFor(row, fn).error" class="output error">{{ callStateFor(row, fn).error }}</pre>
<div v-else class="output placeholder">Call output will appear here</div>
</div>
</div>
</section>
<!-- ========================== -->
<!-- Local Remotes Section -->
<!-- ========================== -->
<section class="section">
<div class="section-header">
<h2>Local Remotes</h2>
</div>
<div class="remote-list">
<div v-for="(remote, index) in localRemoteList" :key="index" class="card">
<!-- Name / Connect bar -->
<div class="card-header">
<div class="view-mode" style="display:flex;align-items:center;gap:0.75rem;">
<input
v-if="!remote.connected"
type="text"
v-model="remote.name"
placeholder="Enter remote name..."
class="input-field"
/>
<span v-else><strong>{{ remote.name }}</strong></span>
<template v-if="!remote.connected">
<button class="btn btn-primary" @click="connectLocalRemote(remote)">Connect</button>
</template>
<!-- Editable mode (disconnected local remotes) -->
<template v-else>
<span class="status-dot active"></span>
<button class="btn btn-secondary" @click="disconnectLocalRemote(remote)">Disconnect</button>
</template>
<button class="btn btn-danger" @click="removeLocalRemote(remote)">Delete</button>
</div>
</div>
<!-- Expanded: function definitions -->
<div class="card-body">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem;">
<strong>Functions</strong>
<button class="btn btn-primary" @click="addFunctionToRemote(remote)">+ Add Function</button>
</div>
<div v-if="!remote.functions || remote.functions.length === 0" style="color:var(--color-text-mute);font-size:0.85em;">No functions yet.</div>
<div v-for="(fn, fIdx) in remote.functions" :key="fIdx" class="func-def-block">
<div class="func-def-header">
<input type="text" v-model="fn.name" class="input-field"/>
<button class="btn btn-danger btn-sm" @click="removeFunctionFromRemote(remote, fIdx)">Remove</button>
<span style="cursor:pointer;" @click.stop="toggleFnExpanded(index, fIdx)">
{{ fnExpandedSet.has(`${index}-${fIdx}`) ? 'collapse' : 'expand' }}
</span>
</div>
<!-- Arguments table -->
<div class="func-def-row">
<div class="args-table">
<table>
<div class="detail-left">
<label>Arguments</label>
<table class="args-table">
<thead><tr><th>Name</th><th>Type</th><th></th></tr></thead>
<tbody>
<tr v-for="(arg, aIdx) in fn.arguments" :key="aIdx">
<td><input type="text" v-model="arg.name" class="input-small" :disabled="remote.connected" /></td>
<td><input type="text" v-model="arg.name" class="input-small" @change="saveLocalRemotes" /></td>
<td>
<select v-model="arg.type" class="input-small" :disabled="remote.connected">
<select v-model="arg.type" class="input-small" @change="saveLocalRemotes">
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
@@ -411,81 +537,89 @@ function toggleFnExpanded(index, fIdx) {
</tr>
</tbody>
</table>
<button class="btn btn-secondary btn-xs" @click="addArgument(fn)" :disabled="remote.connected">+ Arg</button>
<button class="btn btn-secondary btn-xs" @click="addArgument(fn)">+ Arg</button>
</div>
<div class="detail-right">
<label>Function Body (JavaScript)</label>
<textarea
v-model="fn.body"
placeholder="return {};"
spellcheck="false"
class="js-editor"
@change="saveLocalRemotes"
></textarea>
</div>
</template>
</div>
</div>
</template>
</template>
</template>
</div>
<!-- JS body -->
<div v-if="fnExpandedSet.has(`${index}-${fIdx}`)" class="func-def-row">
<textarea v-model="fn.body" rows="4" placeholder="return {};" spellcheck="false" class="js-editor" :readonly="remote.connected"></textarea>
</div>
</div>
</div>
</div>
</div>
<div v-if="remoteRows.length === 0" class="empty-state">No remotes.</div>
<div class="table-footer">
<button class="btn btn-primary" @click="createLocalRemote">+ Create Local Remote</button>
</div>
</section>
</main>
</template>
<style scoped>
/* ---------- 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; }
/* ---------- Section ---------- */
.section { margin-top: 1.5rem; }
.empty-state { padding: 1em; color: var(--color-text-mute); }
.muted { color: var(--color-text-mute); font-size: 0.85em; }
.mono { font-family: monospace; font-size: 0.9em; }
/* ---------- 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; }
/* ---------- Table-like layout ---------- */
.rt { border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-background-soft); overflow: hidden; }
.rt-row { display: grid; grid-template-columns: 2.5rem minmax(10rem, 1.3fr) minmax(10rem, 1.7fr) minmax(9rem, auto); align-items: center; gap: 0.5rem; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--color-border); }
.rt-row:last-child { border-bottom: none; }
.rt-head { font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-text-mute); background: var(--color-background-muted); }
.col-arrow { text-align: center; color: var(--color-text-mute); }
.col-arrow.toggle { cursor: pointer; user-select: none; }
.col-arrow.toggle:hover { color: var(--color-text-heading); }
.col-status, .col-options { display: flex; align-items: center; gap: 0.4rem; min-width: 0; }
/* Remote rows */
.rt-remote { background: var(--color-background-soft); }
.rt-remote.open { border-bottom-style: dashed; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; flex: none; }
.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; }
.name-input { width: 100%; min-width: 0; padding: 0.35rem 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); background: var(--color-background); }
/* ---------- 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; }
/* Function rows (indented under their remote) */
.rt-fnhead, .rt-fn, .rt-fndetail { padding-left: 1.5rem; background: var(--color-background); }
.rt-fn .col-arrow { color: var(--color-text-mute); }
.rt-fn.empty-row { color: var(--color-text-mute); font-style: italic; }
/* ---------- 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, #3c5c47); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
.output-block.error pre { background: var(--color-error-bg, #5e4242); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
.output-placeholder { padding: 1em; color: var(--color-text-mute); font-style: italic; }
/* Function detail: arguments left, output right, at least 10em tall */
.rt-fndetail { display: block; }
.rt-fndetail .detail { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; min-height: 10em; }
.detail-left, .detail-right { display: flex; flex-direction: column; min-width: 0; }
.detail label { font-weight: bold; font-size: 0.85em; margin-bottom: 0.35rem; }
.detail textarea { width: 100%; flex: 1; min-height: 8em; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); resize: vertical; font-family: monospace; font-size: 0.85em; box-sizing: border-box; }
.detail textarea { background: var(--color-background-soft); }
.detail .btn { margin-top: 0.4rem; align-self: flex-start; }
.output { flex: 1; min-height: 8em; margin: 0; padding: 0.5rem; border-radius: var(--border-radius); overflow: auto; font-family: monospace; font-size: 0.85em; white-space: pre-wrap; }
.output.success { background: var(--color-success-bg, #3c5c47); }
.output.error { background: var(--color-error-bg, #5e4242); }
.output.placeholder { border: 1px dashed var(--color-border); color: var(--color-text-mute); font-style: italic; display: flex; align-items: center; justify-content: center; }
/* ---------- Buttons (shared across sections) ---------- */
/* ---------- Arguments editor ---------- */
.args-table { width: 100%; border-collapse: collapse; font-size: 0.85em; margin-bottom: 0.4rem; }
.args-table th { text-align: left; color: var(--color-text-mute); padding: 0.2em 0.3em; border-bottom: 1px solid var(--color-border); }
.args-table td { padding: 0.2em 0.3em; }
.input-small { width: 100%; padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; box-sizing: border-box; background: var(--color-background); }
/* ---------- Misc ---------- */
.table-footer { margin-top: 0.75rem; }
.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 ---------- */
.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); }
.func-def-block { border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 0.5rem; padding: 0.5rem; background: var(--color-background); }
.func-def-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.35rem; font-family: monospace; }
.func-def-row { margin-bottom: 0.25rem; }
.args-table table { width: 100%; border-collapse: collapse; font-size: 0.85em; }
.args-table th { text-align: left; color: var(--color-text-mute); padding: 0.2em 0.3em; border-bottom: 1px solid var(--color-border); }
.args-table td { padding: 0.2em 0.3em; }
.args-table input[type="text"] { width: 6em; padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; }
.args-table select { padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; }
.js-editor { width: 100%; min-height: 3em; max-height: 30em; max-width: 100%; resize: both; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); font-family: monospace; font-size: 0.85em; box-sizing: border-box; }
.btn-sm { padding: 0.25em 0.6em; font-size: 0.8em; }
.btn-xs { padding: 0.15em 0.4em; font-size: 0.75em; }
.input-small { padding: 0.2em 0.4em; border: 1px solid var(--color-border); border-radius: 4px; font-size: 0.85em; }
</style>