WorkspaceTools and connections
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises.
Book a callTools and connections
The tools the agent can reach, and how much it may do with each. Free and MIT licensed, as HTML or a typed React component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add tool-connectionsReact
npx designme add tool-connections --reactThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro.
About
What it is for and what it does.
A list of the tools and services an agent is connected to, each with a three-way permission switch: Off, Ask or Allow. People can test every connection, reconnect one that failed, and connect another tool from a menu. It answers the question "what can this agent touch, and does it need my OK first?"
- When your agent calls outside tools such as Figma, GitHub or Slack and people should control each one.
- When some tools should need approval while others run freely.
- When connections can expire and people need a way to spot and fix them.
- In an agent's settings or onboarding, where new tools get connected.
- Each row shows a service logo (
brand) or an icon on a tinted tile, a name and a detail line. - An Off, Ask or Allow switch per tool, defaulting to Ask; a tool set to Off is dimmed and not counted as connected.
- "Connect a tool" opens a menu of
availabletools not yet in the list; a picked tool joins with "Just connected" and Ask, andonConnectis called. - Passing
onTestadds a Test all button, andtestingshows a spinner on it while a test runs. You report each tool's progress through itsstatus: 'testing' puts a small spinner on its status dot, without changing its line. - A tool with
status: 'error'shows a Reconnect button that callsonReconnect, with a spinner whilestatusis 'reconnecting'. - The line under the title counts connected tools, or shows your own
statustext.
- Each permission switch is a role="group" labelled with the tool name plus "permission", and its buttons use aria-pressed.
- Each permission option has a tooltip saying what it means, such as "Agent asks first", which also shows on keyboard focus.
- The "Connect a tool" button exposes aria-expanded and aria-haspopup, and its menu has role="menu"; Escape closes it and returns focus to the button.
- From the keyboard, Enter, Space or the down arrow opens a menu with the focus on its picked item, or its first (the up arrow, on its last); the arrow keys move through it and wrap, Home and End jump to the ends, Tab closes it and moves on, and closing it gives the focus back to its button.
Usage
Import it and drive it from your own state.
Import
import { ToolConnections } from '@/components/designme/tool-connections/ToolConnections';Example
The demo above as a React component, 63 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React component: an example of driving it from your own state.
import { useEffect, useRef, useState } from 'react';
import { useStory, useWait } from '../lib/useScript';
import { ToolConnections, type ToolConnectionsTool } from './ToolConnections';
/* ---------- Tools and connections: five tools with their permissions; Test all finds Slack's token expired, Reconnect fixes it ---------- */
const TOOLS: ToolConnectionsTool[] = [
{ key: 'figma', name: 'Figma', detail: 'Read and edit frames', brand: 'figma', permission: 'ask' },
{ key: 'github', name: 'GitHub', detail: 'designme/web, PRs only', brand: 'github', permission: 'off' },
{ key: 'slack', name: 'Slack', detail: '#design, #launch', brand: 'slack', permission: 'ask' },
{ key: 'analytics', name: 'Analytics', detail: 'Read only, last 90 days', icon: 'db', color: 'var(--dm-pt)', permission: 'ask' },
{ key: 'web', name: 'Web search', detail: 'Any public site', icon: 'globe', color: 'var(--dm-pb)', permission: 'allow' }
];
const MORE: ToolConnectionsTool[] = [
{ key: 'sheets', name: 'Google Sheets', icon: 'table', color: 'var(--dm-ok)' },
{ key: 'linear', name: 'Linear', icon: 'bell', color: 'var(--dm-pp)' },
{ key: 'gmail', name: 'Gmail', icon: 'mail', color: 'var(--dm-pr)' }
];
export default function ToolConnectionsDemo() {
const ref = useRef<HTMLDivElement>(null);
const [tools, setTools] = useState(TOOLS);
const [status, setStatus] = useState<string>();
const [testing, setTesting] = useState(false);
const wait = useWait();
const latest = useRef(tools);
useEffect(() => { latest.current = tools; });
const patch = (key: string, p: Partial<ToolConnectionsTool>) => setTools(ts => ts.map(t => t.key === key ? { ...t, ...p } : t));
// every tool the agent may use shows a small spinner at once, and they settle one by one; Slack's token has run out
const fixed = useRef(new Set<string>());
const test = async () => {
const rows = latest.current.filter(t => t.permission !== 'off' && t.status !== 'error');
setTesting(true); setStatus(`Testing ${rows.length} connections`);
rows.forEach(t => patch(t.key, { status: 'testing' }));
for (const t of rows) {
await wait(600);
patch(t.key, t.key === 'slack' && !fixed.current.has(t.key) ? { status: 'error', note: 'Token expired. Reconnect to use it' } : { status: 'ok' });
}
const bad = rows.some(t => t.key === 'slack' && !fixed.current.has(t.key)) ? 1 : 0;
setTesting(false); setStatus(`${rows.length - bad} of ${rows.length} healthy`);
};
const reconnect = async (key: string) => {
patch(key, { status: 'reconnecting' });
await wait(900);
const on = latest.current.filter(t => t.permission !== 'off').length;
fixed.current.add(key); patch(key, { status: 'ok', note: undefined }); setStatus(`${on} of ${on} healthy`);
};
// the preview's story: test everything, find Slack's token expired, and reconnect it
useStory(ref, async ({ wait, press }) => {
await wait(1500); press('.dm-hnx-test');
await wait(4 * 600 + 1500); press('.dm-hnx-t.dm-err .dm-pill');
});
return (
<div ref={ref} style={{ width: '100%' }}>
<ToolConnections tools={tools} onToolsChange={ts => { setTools(ts); setStatus(undefined); }} available={MORE} onTest={test} testing={testing} onReconnect={reconnect} status={status} />
</div>
);
}
Props
Everything ToolConnections takes.
| Prop | Type | What it does |
|---|---|---|
tools | ToolConnectionsTool[] | The connected tools. Leave it out to let the permission switches and Connect a tool change them |
defaultTools | ToolConnectionsTool[] | |
onToolsChange | (tools: ToolConnectionsTool[]) => void | |
available | ToolConnectionsTool[] | Tools that can be connected, listed under Connect a tool. Ones already connected are left out |
onConnect | (tool: ToolConnectionsTool) => void | Called with a tool picked from Connect a tool. It joins the list with "Just connected" and Ask |
onTest | () => void | Adds a Test all button. Report progress through each tool's status and note |
testing | boolean | A test is running: Test all shows a spinner and waits |
onReconnect | (key: string) => void | Called by the Reconnect button of a tool whose status is error |
title | string | |
status | string | The line under the title. Defaults to how many tools are connected |
className | string |
Code
The full source, as the CLI installs it.
ToolConnections.tsxReact · 146 lines
'use client';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { Brand, type BrandName } from '../lib/brands';
import { cx } from '../lib/cx';
import { useControllable, useIsoLayoutEffect } from '../lib/hooks';
import { Icon, type IconName } from '../lib/icons';
import { enter, Spin, Swap, Tip } from '../lib/motion';
import { usePopover } from '../lib/popover';
import '../designme.css';
import './tool-connections.css';
/** off: the agent cannot use it, ask: it asks first, allow: it uses it freely */
export type ToolConnectionsPermission = 'off' | 'ask' | 'allow';
/** ok, testing while a check runs, error when it failed (the row offers Reconnect), reconnecting while that runs */
export type ToolConnectionsStatus = 'ok' | 'testing' | 'error' | 'reconnecting';
/** A tool or connection the agent can reach */
export interface ToolConnectionsTool {
key: string;
/** Like "Figma" */
name: string;
/** The quiet line under it, like "Read and edit frames" */
detail?: string;
/** A service's own logo. Wins over `icon` */
brand?: BrandName;
/** An icon on a tinted tile, for tools without a logo */
icon?: IconName;
/** The icon's colour, which also tints its tile, like 'var(--dm-pt)' */
color?: string;
/** Defaults to ask */
permission?: ToolConnectionsPermission;
status?: ToolConnectionsStatus;
/** Shown instead of the detail while it is set, like "Testing" or "Token expired. Reconnect" */
note?: string;
}
export interface ToolConnectionsProps {
/** The connected tools. Leave it out to let the permission switches and Connect a tool change them */
tools?: ToolConnectionsTool[];
defaultTools?: ToolConnectionsTool[];
onToolsChange?: (tools: ToolConnectionsTool[]) => void;
/** Tools that can be connected, listed under Connect a tool. Ones already connected are left out */
available?: ToolConnectionsTool[];
/** Called with a tool picked from Connect a tool. It joins the list with "Just connected" and Ask */
onConnect?: (tool: ToolConnectionsTool) => void;
/** Adds a Test all button. Report progress through each tool's status and note */
onTest?: () => void;
/** A test is running: Test all shows a spinner and waits */
testing?: boolean;
/** Called by the Reconnect button of a tool whose status is error */
onReconnect?: (key: string) => void;
title?: string;
/** The line under the title. Defaults to how many tools are connected */
status?: string;
className?: string;
}
const PERMS: { value: ToolConnectionsPermission; label: string; tip: string }[] = [
{ value: 'off', label: 'Off', tip: 'Agent cannot use it' },
{ value: 'ask', label: 'Ask', tip: 'Agent asks first' },
{ value: 'allow', label: 'Allow', tip: 'Agent uses it freely' }
];
/** The three-way permission switch, with a knob that slides to the pick */
function Permission({ name, value, onChange }: { name: string; value: ToolConnectionsPermission; onChange: (p: ToolConnectionsPermission) => void }) {
const ref = useRef<HTMLDivElement>(null);
const [knob, setKnob] = useState<CSSProperties>({});
// the knob slides when the choice changes; when the row first appears it is simply in place
const placed = useRef(false);
useIsoLayoutEffect(() => {
const el = ref.current; if (!el) return;
const place = () => { const b = el.querySelector<HTMLElement>('[aria-pressed="true"]'); if (b){ setKnob({ left: b.offsetLeft, width: b.offsetWidth, transition: placed.current ? undefined : 'none' }); placed.current = true; } };
place();
const ro = new ResizeObserver(place); ro.observe(el);
return () => ro.disconnect();
}, [value]);
return (
<div className="dm-hnx-pm" role="group" aria-label={name + ' permission'} ref={ref}>
<span className="dm-kn" style={knob} />
{PERMS.map(p => <Tip key={p.value} label={p.tip}><button type="button" aria-pressed={p.value === value} onClick={() => onChange(p.value)} onMouseDown={e => e.preventDefault()}>{p.label}</button></Tip>)}
</div>
);
}
/** The logo, or the icon on its tinted tile, with a dot for its state */
const Mark = ({ tool }: { tool: ToolConnectionsTool }) => tool.brand
? <span className="dm-ic dm-brandic"><Brand name={tool.brand} /></span>
: <span className="dm-ic" style={tool.color ? { color: tool.color, background: `color-mix(in srgb,${tool.color} 12%,transparent)` } : undefined}>{tool.icon && <Icon name={tool.icon} size="xs" />}</span>;
/** The tools the agent can reach, and how much it may do with each. */
export function ToolConnections({ tools, defaultTools = [], onToolsChange, available = [], onConnect, onTest, testing = false, onReconnect, title = 'Tools and connections', status, className }: ToolConnectionsProps) {
const [list, setList] = useControllable(tools, defaultTools, onToolsChange);
const menu = usePopover({ side: 'up' });
const perm = (t: ToolConnectionsTool) => t.permission ?? 'ask';
const connected = list.filter(t => perm(t) !== 'off').length;
const left = available.filter(a => !list.some(t => t.key === a.key));
// rows there from the start come in one after another; a row connected later, or back from Reconnect, comes in on its own
const [first] = useState(() => list.map(t => t.key));
const [epoch, setEpoch] = useState<Record<string, number>>({});
const was = useRef<Record<string, ToolConnectionsStatus | undefined>>({});
useEffect(() => {
const back = list.filter(t => was.current[t.key] === 'reconnecting' && t.status !== 'reconnecting' && t.status !== 'error').map(t => t.key);
if (back.length) setEpoch(e => ({ ...e, ...Object.fromEntries(back.map(k => [k, (e[k] ?? 0) + 1])) }));
was.current = Object.fromEntries(list.map(t => [t.key, t.status]));
}, [list]);
const change = (key: string, p: ToolConnectionsPermission) => setList(list.map(t => t.key === key ? { ...t, permission: p } : t));
const connect = (tool: ToolConnectionsTool) => {
menu.closeSoon();
setList([...list, { ...tool, detail: 'Just connected', permission: 'ask' }]);
onConnect?.(tool);
};
return (
<div data-dm="" className={cx('dm-hnx dm-panel', className)}>
<div className="dm-hnx-h">
<div><b>{title}</b><Swap className="dm-muted dm-hnx-st">{status ?? connected + ' connected'}</Swap></div>
{onTest && <button type="button" className="dm-pill dm-ghost dm-sm dm-hnx-test" onClick={onTest} disabled={testing}>{testing ? <><Spin />Testing</> : <><Icon name="refresh" size="xs" />Test all</>}</button>}
</div>
<div className="dm-hnx-list">
{list.map(t => {
const at = first.indexOf(t.key), again = epoch[t.key] ?? 0;
const motion = at >= 0 && !again ? enter(at * 50) : enter(0);
return (
<div key={t.key + ':' + again} className={cx('dm-hnx-t', motion.className, perm(t) === 'off' && 'dm-off', t.status === 'testing' && 'dm-chk', (t.status === 'error' || t.status === 'reconnecting') && 'dm-err')} style={motion.style}>
<Mark tool={t} />
<span className="dm-t">{t.name}<Swap>{t.note ?? t.detail ?? ''}</Swap></span>
{t.status === 'error' || t.status === 'reconnecting'
? <button type="button" className="dm-pill dm-accent dm-sm dm-enter" onClick={() => t.status === 'error' && onReconnect?.(t.key)}>{t.status === 'reconnecting' ? <Spin /> : 'Reconnect'}</button>
: <Permission name={t.name} value={perm(t)} onChange={p => change(t.key, p)} />}
</div>
);
})}
</div>
<button type="button" className="dm-hnx-add" {...menu.trigger}><Icon name="plus" size="xs" />Connect a tool</button>
<div {...menu.pop} className={cx(menu.pop.className, 'dm-hnx-pop')} role="menu">
{left.length
? left.map(a => <button type="button" key={a.key} className="dm-mi" onClick={() => connect(a)}>{a.brand ? <Brand name={a.brand} /> : a.icon && <Icon name={a.icon} size="xs" />}<span>{a.name}</span></button>)
: <div className="dm-ph" style={{ padding: 8, fontSize: 12.5, color: 'var(--dm-muted)' }}>All tools connected</div>}
</div>
</div>
);
}
tool-connections.cssReact · 32 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Tools and connections ---------- */
.dm-hnx{padding:var(--dm-s-3);display:flex;flex-direction:column;gap:6px}
.dm-hnx-h{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:2px 10px var(--dm-s-2)}
.dm-hnx-h>div{display:flex;flex-direction:column}
.dm-hnx-h b{font-weight:500;font-size:13.5px}
.dm-hnx-st{font-size:12px}
.dm-hnx-list{display:flex;flex-direction:column}
.dm-hnx-t{display:flex;align-items:center;gap:var(--dm-s-3);padding:var(--dm-s-2) 10px;border-radius:var(--dm-r-in);font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.dm-hnx-t:hover{background:var(--dm-row)}}
.dm-hnx-t .dm-ic{width:28px;height:28px;border-radius:9px;display:grid;place-items:center;flex:none;position:relative}
.dm-hnx-t .dm-ic::after{content:"";position:absolute;right:-2px;bottom:-2px;width:8px;height:8px;border-radius:50%;background:var(--dm-ok);box-shadow:0 0 0 2px var(--dm-surface);transition:background .3s}
.dm-hnx-t.dm-off .dm-ic::after{background:var(--dm-faint)}
.dm-hnx-t .dm-ic.dm-brandic{background:var(--dm-surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--dm-line) 50%,transparent);color:var(--dm-fg)}
.dm-hnx-t .dm-ic .dm-brand{width:15px;height:15px}
.dm-hnx-t.dm-err .dm-ic::after{background:var(--dm-bad)}
.dm-hnx-t.dm-chk .dm-ic::after{width:10px;height:10px;right:-3px;bottom:-3px;box-sizing:border-box;background:var(--dm-surface);border:1.5px solid var(--dm-accent-mark,var(--dm-accent));border-right-color:transparent;box-shadow:0 0 0 2px var(--dm-surface);animation:dm-rot .8s linear infinite}
.dm-hnx-t .dm-t{flex:1;min-width:0;display:flex;flex-direction:column}
.dm-hnx-t .dm-t span{font-size:12px;color:var(--dm-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.dm-hnx-t.dm-err .dm-t span{color:var(--dm-bad)}
/* the same pill switch as every other segmented switch; its track darkens with a hovered row so it never melts into it */
.dm-hnx-pm{display:grid;grid-template-columns:repeat(3,auto);padding:2px;border-radius:var(--dm-r-pill);background:var(--dm-chip);position:relative;transition:background-color .2s}
@media (hover:hover) and (pointer:fine){.dm-hnx-t:hover .dm-hnx-pm{background:color-mix(in srgb,var(--dm-fg) 8%,transparent)}}
.dm-hnx-pm button{position:relative;z-index:1;height:24px;padding:0 9px;border-radius:var(--dm-r-pill);font-size:11.5px;color:var(--dm-muted);transition:color .2s}
@media (hover:hover) and (pointer:fine){.dm-hnx-pm button:hover{color:var(--dm-fg2)}}
.dm-hnx-pm button[aria-pressed="true"]{color:var(--dm-fg)}
.dm-hnx-pm button:focus-visible{outline-offset:-2px}
.dm-hnx-pm .dm-kn{position:absolute;top:2px;bottom:2px;border-radius:var(--dm-r-pill);background:var(--dm-surface);box-shadow:0 1px 2px rgba(0,0,0,.08);transition:left .4s var(--dm-ease),width .4s var(--dm-ease)}
.dm-hnx-add{display:flex;align-items:center;gap:var(--dm-s-2);height:30px;padding:0 var(--dm-s-3) 0 10px;font-size:12.5px;color:var(--dm-fg2);border-radius:var(--dm-r-pill);align-self:flex-start;transition:background-color .2s,color .2s}
@media (hover:hover) and (pointer:fine){.dm-hnx-add:hover{color:var(--dm-fg);background:var(--dm-row)}}
.dm-hnx-pop{min-width:220px}
tool-connections.htmlHTML · 11 lines
<div class="card" data-c="tool-connections">
<div class="stage-in">
<div class="hnx panel" data-reveal="flat">
<div class="hnx-h"><div><b>Tools and connections</b><span class="muted hnx-st">5 connected</span></div><button class="pill ghost sm hnx-test"><svg class="i xs"><use href="#i-refresh"/></svg>Test all</button></div>
<div class="hnx-list"></div>
<button class="hnx-add" data-pop=".hnx-pop" data-pop-pos="up"><svg class="i xs"><use href="#i-plus"/></svg>Connect a tool</button>
<div class="pop hnx-pop" role="menu"></div>
</div>
</div>
</div>
tool-connections.cssHTML · 31 lines
/* ---------- Tools and connections ---------- */
.hnx{padding:var(--s-3);display:flex;flex-direction:column;gap:6px}
.hnx-h{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:2px 10px var(--s-2)}
.hnx-h>div{display:flex;flex-direction:column}
.hnx-h b{font-weight:500;font-size:13.5px}
.hnx-st{font-size:12px}
.hnx-list{display:flex;flex-direction:column}
.hnx-t{display:flex;align-items:center;gap:var(--s-3);padding:var(--s-2) 10px;border-radius:var(--r-in);font-size:13px;transition:background .2s}
@media (hover:hover) and (pointer:fine){.hnx-t:hover{background:var(--row)}}
.hnx-t .ic{width:28px;height:28px;border-radius:9px;display:grid;place-items:center;flex:none;position:relative}
.hnx-t .ic::after{content:"";position:absolute;right:-2px;bottom:-2px;width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 0 2px var(--surface);transition:background .3s}
.hnx-t.off .ic::after{background:var(--faint)}
.hnx-t .ic.brandic{background:var(--surface);box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--line) 50%,transparent);color:var(--fg)}
.hnx-t .ic .brand{width:15px;height:15px}
.hnx-t.err .ic::after{background:var(--bad)}
.hnx-t.chk .ic::after{width:10px;height:10px;right:-3px;bottom:-3px;box-sizing:border-box;background:var(--surface);border:1.5px solid var(--accent-mark,var(--accent));border-right-color:transparent;box-shadow:0 0 0 2px var(--surface);animation:rot .8s linear infinite}
.hnx-t .t{flex:1;min-width:0;display:flex;flex-direction:column}
.hnx-t .t span{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.hnx-t.err .t span{color:var(--bad)}
/* the same pill switch as every other segmented switch; its track darkens with a hovered row so it never melts into it */
.hnx-pm{display:grid;grid-template-columns:repeat(3,auto);padding:2px;border-radius:var(--r-pill);background:var(--chip);position:relative;transition:background-color .2s}
@media (hover:hover) and (pointer:fine){.hnx-t:hover .hnx-pm{background:color-mix(in srgb,var(--fg) 8%,transparent)}}
.hnx-pm button{position:relative;z-index:1;height:24px;padding:0 9px;border-radius:var(--r-pill);font-size:11.5px;color:var(--muted);transition:color .2s}
@media (hover:hover) and (pointer:fine){.hnx-pm button:hover{color:var(--fg2)}}
.hnx-pm button[aria-pressed="true"]{color:var(--fg)}
.hnx-pm button:focus-visible{outline-offset:-2px}
.hnx-pm .kn{position:absolute;top:2px;bottom:2px;border-radius:var(--r-pill);background:var(--surface);box-shadow:0 1px 2px rgba(0,0,0,.08);transition:left .4s var(--ease),width .4s var(--ease)}
.hnx-add{display:flex;align-items:center;gap:var(--s-2);height:30px;padding:0 var(--s-3) 0 10px;font-size:12.5px;color:var(--fg2);border-radius:var(--r-pill);align-self:flex-start;transition:background-color .2s,color .2s}
@media (hover:hover) and (pointer:fine){.hnx-add:hover{color:var(--fg);background:var(--row)}}
.hnx-pop{min-width:220px}
tool-connections.jsHTML · 56 lines
DM.def('tool-connections', (r, c) => {
r.innerHTML = r.innerHTML.replace('{refresh}', I('refresh','xs')).replace('{plus}', I('plus','xs'));
let T = [
['@figma','Figma','Read and edit frames','var(--pl)',1],
['@github','GitHub','designme/web, PRs only','var(--fg)',0],
['@slack','Slack','#design, #launch','var(--pm)',1],
['db','Analytics','Read only, last 90 days','var(--pt)',1],
['globe','Web search','Any public site','var(--pb)',2]
];
const ADD = [['table','Google Sheets','var(--ok)'],['bell','Linear','var(--pp)'],['mail','Gmail','var(--pr)']];
const list = $('.hnx-list', r), st = $('.hnx-st', r), pop = $('.hnx-pop', r);
const PM = ['Off','Ask','Allow'];
function row(t){
const el = html(`<div class="hnx-t${t[4] === 0 ? ' off' : ''}">${t[0][0] === '@' ? `<span class="ic brandic">${BRAND[t[0].slice(1)]}</span>` : `<span class="ic" style="color:${t[3]};background:color-mix(in srgb,${t[3]} 12%,transparent)">${I(t[0],'xs')}</span>`}<span class="t">${t[1]}<span>${t[2]}</span></span><div class="hnx-pm" role="group" aria-label="${t[1]} permission"><span class="kn"></span>${PM.map((p, i) => `<button aria-pressed="${i === t[4]}" data-tip="${['Agent cannot use it','Agent asks first','Agent uses it freely'][i]}">${p}</button>`).join('')}</div></div>`);
const g = $('.hnx-pm', el), kn = $('.kn', g), bs = $$('button', g);
// the knob slides when a choice changes; when the row first appears it is simply in place
const place = now => { const b = bs[t[4]]; if (now) kn.style.transition = 'none'; kn.style.left = b.offsetLeft + 'px'; kn.style.width = b.offsetWidth + 'px'; if (now){ void kn.offsetWidth; kn.style.transition = ''; } };
bs.forEach((b, i) => b.onclick = () => { t[4] = i; bs.forEach((x, j) => x.setAttribute('aria-pressed', j === i)); place(); el.classList.toggle('off', i === 0); count(); });
// a click with the mouse doesn't take focus, so it never leaves a focus ring; the keyboard still reaches every option
bs.forEach(b => b.onmousedown = e => e.preventDefault());
requestAnimationFrame(() => place(true)); new ResizeObserver(() => place(true)).observe(g);
el._t = t; return el;
}
// the first count is written straight in, so the header doesn't crossfade from the template's number on load
const count = now => { const n = T.filter(t => t[4] > 0).length + ' connected'; if (now) st.textContent = n; else swap(st, n); };
T.forEach(t => list.appendChild(row(t))); stagger([...list.children], 50); count(true);
function menu(){ pop.innerHTML = ADD.length ? ADD.map((a, i) => `<button class="mi" data-close data-i="${i}">${I(a[0],'xs')}<span>${a[1]}</span></button>`).join('') : '<div class="ph" style="padding:8px;font-size:12.5px;color:var(--muted)">All tools connected</div>';
$$('.mi', pop).forEach(m => m.addEventListener('click', () => { const a = ADD.splice(+m.dataset.i, 1)[0]; const t = [a[0], a[1], 'Just connected', a[2], 1]; T.push(t); const el = row(t); list.appendChild(el); enter(el); count(); menu(); })); }
menu();
// Test all: every connected tool shows a small spinner at once, and they settle one by one; only a failure changes its line
const test = $('.hnx-test', r), testWas = test.innerHTML;
test.onclick = () => c.run(async () => {
const rows = [...list.children].filter(el => el._t[4] > 0);
test.disabled = true; test.innerHTML = '<span class="spin"></span>Testing'; swap(st, 'Testing ' + rows.length + ' connections');
rows.forEach(el => el.classList.add('chk'));
for (const el of rows){ await c.wait(600); el.classList.remove('chk');
if (el._t[1] === 'Slack' && !el._t.ok){ el.classList.add('err'); swap($('.t span', el), 'Token expired. Reconnect to use it'); }
}
test.disabled = false; test.innerHTML = testWas;
swap(st, rows.filter(el => !el.classList.contains('err')).length + ' of ' + rows.length + ' healthy');
const bad = [...list.children].find(el => el.classList.contains('err'));
// a second test finds the row already offering Reconnect, so it keeps that button
const pm = bad && $('.hnx-pm', bad);
if (pm){ const b = html(`<button class="pill accent sm">Reconnect</button>`); pm.replaceWith(b); enter(b);
b.onclick = () => c.run(async () => { b.innerHTML = '<span class="spin"></span>'; await c.wait(900); bad.classList.remove('err'); bad._t.ok = true; const n = row(bad._t); bad.replaceWith(n); swap(st, [...list.children].filter(el => el._t[4] > 0).length + ' of ' + [...list.children].filter(el => el._t[4] > 0).length + ' healthy'); }); }
});
// the preview's story: test everything, Slack's token has expired, reconnect it, and every connection is healthy
c.story(async s => {
await s.beat(3);
await s.tap(test); s.away();
await s.until(() => $('.hnx-t.err .pill', list)); await s.beat(3);
await s.tap($('.hnx-t.err .pill', list)); s.away();
await s.until(() => !$('.hnx-t.err', list)); await s.beat(1);
});
});
Questions
What people ask about it.
- How do I show a connection test in progress?
- Handle
onTestand updatetools: settesting, and each tool'sstatusto 'testing' (a small spinner on its dot), then to 'ok' or 'error'. Anote, like "Token expired. Reconnect to use it", replaces the detail line while it is set. - What does Reconnect do?
- It calls
onReconnectwith the tool's key. Set that tool'sstatusto 'reconnecting' while you refresh the token, then back to 'ok' and the row comes back with its permission switch. - How do I save permission changes?
- Pass
toolsandonToolsChange. Every switch change and newly connected tool comes back as the full updated list. - Can I use a tool that has no logo?
- Yes. Leave out
brandand seticonandcolor; the icon sits on a tile tinted with that colour.
More in Workspace
Navigation, search, settings, models and automations around the agent.