Skip to content

Commit 40eca03

Browse files
ARHAEEMclaude
andcommitted
feat(webview): show configured named-tunnel hostname; prompt edits wait for confirmation
Closes the two scope cuts from the dead-end sweep: - TunnelState gains optional namedTunnelHostname. The extension reads it from cloudflared-named.yml (same fixed format the daemon's writeTunnelConfig emits, including quoted-value handling). The Setup tab's cf-named block now shows a 'Configured' chip with the hostname, relabels the input 'New Hostname (optional)', and the helper text says exactly which tunnel an empty field reuses. - PromptEditor no longer navigates back optimistically. savePrompt / deletePrompt / resetPrompt return their action id, markActionDone records a consumable result (bounded map), and the editor leaves only on confirmed success — failures keep the editor open with the user's input intact and show an inline alert. Save shows 'Saving…' while in flight. 3 new store tests cover the id/result round-trip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d3e1dc9 commit 40eca03

7 files changed

Lines changed: 155 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,24 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how
66

77
## [Unreleased]
88

9+
### Dashboard — named-tunnel hostname display + confirmed prompt saves (2026-06-12)
10+
11+
- **`TunnelState.namedTunnelHostname`** (new optional shared-protocol field):
12+
the extension now reads the hostname from `cloudflared-named.yml` (same
13+
mechanical format the daemon writes) and the Setup tab shows a
14+
"Configured: <hostname>" row for the named-tunnel provider, relabels the
15+
input "New Hostname (optional)", and explains that leaving it empty reuses
16+
the configured tunnel ([`types.ts`](packages/shared/src/types.ts),
17+
[`DashboardProvider.ts`](packages/extension/src/webview/DashboardProvider.ts),
18+
[`Setup.tsx`](packages/webview/src/tabs/Setup.tsx)).
19+
- **PromptEditor waits for confirmation**: `savePrompt`/`deletePrompt`/
20+
`resetPrompt` now return their action id, `markActionDone` records a
21+
consumable per-action result, and the editor navigates back only after the
22+
extension confirms success — a failure keeps the editor open with the
23+
user's input intact and shows an inline error
24+
([`store.ts`](packages/webview/src/store.ts), [`Prompts.tsx`](packages/webview/src/tabs/Prompts.tsx)).
25+
3 new store tests cover the id/result round-trip.
26+
927
### Dashboard — second-pass dead-end & cosmetics sweep (2026-06-12)
1028

1129
A targeted second audit (flow dead-ends, unreachable conditional UI, stale

packages/extension/src/webview/DashboardProvider.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -954,6 +954,27 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
954954
// Check SecretStorage for ngrok authtoken
955955
const ngrokAuthtokenSet = !!(await this.context.secrets.get('airtable-formula.ngrok.authtoken'));
956956

957+
// Read the named-tunnel hostname from cloudflared-named.yml (written by
958+
// the daemon's writeTunnelConfig — fixed mechanical format, same
959+
// `- hostname:` extraction as the daemon's parseConfigYaml).
960+
let namedTunnelHostname: string | null = null;
961+
const namedConfigPath = pathMod.join(configDir, 'cloudflared-named.yml');
962+
if (fsMod.existsSync(namedConfigPath)) {
963+
try {
964+
const rawYaml = fsMod.readFileSync(namedConfigPath, 'utf8');
965+
for (const line of rawYaml.split(/\r?\n/)) {
966+
const m = line.trim().match(/^- hostname:\s*(.+)$/u);
967+
if (m) {
968+
const value = m[1].trim();
969+
namedTunnelHostname = value.startsWith('"') && value.endsWith('"')
970+
? value.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
971+
: value;
972+
break;
973+
}
974+
}
975+
} catch { /* unreadable — treat as not configured */ }
976+
}
977+
957978
// Determine TunnelStatus
958979
let status: import('@airtable-formula/shared').TunnelStatus = 'disabled';
959980
if (tunnelUrl) {
@@ -970,6 +991,7 @@ export class DashboardProvider implements vscode.WebviewViewProvider {
970991
provider,
971992
ngrokAuthtokenSet,
972993
autoDisabledReason,
994+
namedTunnelHostname,
973995
};
974996
} catch {
975997
return undefined;

packages/shared/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,10 @@ export interface TunnelState {
169169
provider: TunnelProviderId;
170170
ngrokAuthtokenSet: boolean; // true when VS Code SecretStorage has a token for 'airtable-formula.ngrok.authtoken'
171171
autoDisabledReason: TunnelAutoDisabledReason | null;
172+
/** Hostname from an existing cloudflared-named.yml, or null when no named
173+
* tunnel has been configured yet. Lets the UI show what "reuse the
174+
* already-configured tunnel" actually points at. */
175+
namedTunnelHostname?: string | null;
172176
}
173177

174178
export interface DaemonStatusInfo {

packages/webview/src/store.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,14 @@ interface Store extends DashboardState {
4646
copyAirtablePat: () => void;
4747
configureOfficialAirtable: (ideId: import('@shared/types.js').IdeId) => void;
4848
unconfigureOfficialAirtable: (ideId: import('@shared/types.js').IdeId) => void;
49-
savePrompt: (prompt: PromptDef) => void;
50-
deletePrompt: (name: string) => void;
51-
resetPrompt: (name: string) => void;
49+
/** Prompt actions return their action id so callers can await completion
50+
* (via pendingActions + consumeActionResult) before navigating away. */
51+
savePrompt: (prompt: PromptDef) => string;
52+
deletePrompt: (name: string) => string;
53+
resetPrompt: (name: string) => string;
54+
/** Read-and-clear the success flag recorded by markActionDone. Undefined
55+
* when no result was recorded (e.g. id unknown). */
56+
consumeActionResult: (id: string) => boolean | undefined;
5257
}
5358

5459
const defaultSettings: SettingsSnapshot = {
@@ -86,6 +91,12 @@ const defaultAuth: AuthState = {
8691
const PENDING_TIMEOUT_MS = 60_000;
8792
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>();
8893

94+
// Per-action outcomes recorded by markActionDone, consumed by components that
95+
// wait for confirmation before navigating (PromptEditor). Bounded — unclaimed
96+
// results from fire-and-forget actions are evicted oldest-first.
97+
const actionResults = new Map<string, boolean>();
98+
const ACTION_RESULTS_MAX = 50;
99+
89100
export const useStore = create<Store>((set, get) => {
90101
/** Track an in-flight action and schedule its auto-expiry. */
91102
const beginAction = (id: string, timeoutMs = PENDING_TIMEOUT_MS) => {
@@ -342,23 +353,38 @@ export const useStore = create<Store>((set, get) => {
342353
const id = randomId();
343354
beginAction(id);
344355
sendToExtension({ type: 'action:save-prompt', id, prompt });
356+
return id;
345357
},
346358

347359
deletePrompt: (name) => {
348360
const id = randomId();
349361
beginAction(id);
350362
sendToExtension({ type: 'action:delete-prompt', id, name });
363+
return id;
351364
},
352365

353366
resetPrompt: (name) => {
354367
const id = randomId();
355368
beginAction(id);
356369
sendToExtension({ type: 'action:reset-prompt', id, name });
370+
return id;
371+
},
372+
373+
consumeActionResult: (id) => {
374+
const result = actionResults.get(id);
375+
actionResults.delete(id);
376+
return result;
357377
},
358378

359-
markActionDone: (id, _ok) => {
379+
markActionDone: (id, ok) => {
360380
const timer = pendingTimers.get(id);
361381
if (timer) { clearTimeout(timer); pendingTimers.delete(id); }
382+
actionResults.set(id, ok);
383+
while (actionResults.size > ACTION_RESULTS_MAX) {
384+
const oldest = actionResults.keys().next().value;
385+
if (oldest === undefined) break;
386+
actionResults.delete(oldest);
387+
}
362388
set(s => {
363389
const next = new Set(s.pendingActions);
364390
next.delete(id);

packages/webview/src/tabs/Prompts.tsx

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState } from 'react';
1+
import React, { useState, useEffect } from 'react';
22
import { useStore } from '../store.js';
33
import type { PromptDef, PromptArg } from '@shared/types.js';
44
import { Plus, ArrowLeft, Trash2, RotateCcw, Save, X, Info } from 'lucide-react';
@@ -85,32 +85,50 @@ function PromptEditor({
8585
isNew: boolean;
8686
onBack: () => void;
8787
}) {
88-
const { savePrompt, deletePrompt, resetPrompt, pendingActions } = useStore();
89-
const busy = pendingActions.size > 0;
88+
const { savePrompt, deletePrompt, resetPrompt, pendingActions, consumeActionResult } = useStore();
9089
const [name, setName] = useState(initial.name);
9190
const [desc, setDesc] = useState(initial.description);
9291
const [args, setArgs] = useState<PromptArg[]>(initial.arguments);
9392
const [template, setTemplate] = useState(initial.template);
9493
const [dirty, setDirty] = useState(isNew);
94+
// The action we are waiting on — navigation back happens only once the
95+
// extension confirms it (action:result), and a failure keeps the editor
96+
// open with the user's input intact instead of silently discarding it.
97+
const [inFlightId, setInFlightId] = useState<string | null>(null);
98+
const [actionError, setActionError] = useState<string | null>(null);
9599

100+
const busy = inFlightId !== null || pendingActions.size > 0;
96101
const nameIsValid = /^[a-z][a-z0-9-]*$/.test(name);
97102

103+
useEffect(() => {
104+
if (!inFlightId || pendingActions.has(inFlightId)) return;
105+
const ok = consumeActionResult(inFlightId);
106+
setInFlightId(null);
107+
if (ok === false) {
108+
setActionError('The change did not apply — check the VS Code notifications for details, then try again.');
109+
} else {
110+
onBack();
111+
}
112+
}, [pendingActions, inFlightId, consumeActionResult, onBack]);
113+
98114
function markDirty() { setDirty(true); }
99115

100116
function handleSave() {
101-
if (!dirty || !nameIsValid) return;
102-
savePrompt({ name, description: desc, arguments: args, template, isBuiltin: initial.isBuiltin, isModified: initial.isBuiltin });
103-
onBack();
117+
if (!dirty || !nameIsValid || inFlightId) return;
118+
setActionError(null);
119+
setInFlightId(savePrompt({ name, description: desc, arguments: args, template, isBuiltin: initial.isBuiltin, isModified: initial.isBuiltin }));
104120
}
105121

106122
function handleDelete() {
107-
deletePrompt(initial.name);
108-
onBack();
123+
if (inFlightId) return;
124+
setActionError(null);
125+
setInFlightId(deletePrompt(initial.name));
109126
}
110127

111128
function handleReset() {
112-
resetPrompt(initial.name);
113-
onBack();
129+
if (inFlightId) return;
130+
setActionError(null);
131+
setInFlightId(resetPrompt(initial.name));
114132
}
115133

116134
function addArg() {
@@ -218,6 +236,13 @@ function PromptEditor({
218236
/>
219237
</div>
220238

239+
{/* Action failure — stay on the editor so the user's input is kept */}
240+
{actionError && (
241+
<div role="alert" style={{ fontSize: '0.7rem', color: 'var(--fg-warn)', padding: '6px 10px', background: 'var(--bg-warn)', borderRadius: 'var(--radius-md)' }}>
242+
{actionError}
243+
</div>
244+
)}
245+
221246
{/* Actions */}
222247
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
223248
<button
@@ -227,7 +252,7 @@ function PromptEditor({
227252
className="btn btn-primary"
228253
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, opacity: !dirty || !nameIsValid || busy ? 0.5 : 1 }}
229254
>
230-
<Save size={12} /> Save
255+
<Save size={12} /> {inFlightId ? 'Saving…' : 'Save'}
231256
</button>
232257

233258
{initial.isBuiltin && initial.isModified && (

packages/webview/src/tabs/Setup.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -609,13 +609,24 @@ export function Setup() {
609609
{/* Cloudflare named tunnel — hostname is required for first-time setup */}
610610
{selectedProvider === 'cf-named' && (
611611
<div style={{ marginBottom: 8 }}>
612+
{tunnel?.namedTunnelHostname && (
613+
<div className="list-row" style={{ alignItems: 'center', gap: 8, marginBottom: 8 }}>
614+
<span className="chip chip-ok">Configured</span>
615+
<span
616+
title={tunnel.namedTunnelHostname}
617+
style={{ fontFamily: 'var(--font-mono)', fontSize: '0.7rem', flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'right' }}
618+
>
619+
{tunnel.namedTunnelHostname}
620+
</span>
621+
</div>
622+
)}
612623
<label htmlFor="cf-named-hostname" className="uppercase-label" style={{ display: 'block', marginBottom: 4 }}>
613-
Hostname
624+
{tunnel?.namedTunnelHostname ? 'New Hostname (optional)' : 'Hostname'}
614625
</label>
615626
<input
616627
id="cf-named-hostname"
617628
type="text"
618-
placeholder="mcp.your-domain.com"
629+
placeholder={tunnel?.namedTunnelHostname ?? 'mcp.your-domain.com'}
619630
aria-describedby="cf-named-hostname-helper"
620631
value={namedHostnameInput}
621632
onChange={e => setNamedHostnameInput(e.target.value)}
@@ -633,9 +644,10 @@ export function Setup() {
633644
}}
634645
/>
635646
<div id="cf-named-hostname-helper" style={{ fontSize: '0.7rem', color: 'var(--fg-subtle)' }}>
636-
A domain you manage in Cloudflare (the tunnel gets a permanent URL there).
637-
Required the first time — setup opens a one-time Cloudflare login in your browser.
638-
Leave empty to reuse an already-configured tunnel.
647+
{tunnel?.namedTunnelHostname
648+
? <>Leave empty to reuse <strong>{tunnel.namedTunnelHostname}</strong>, or enter a different Cloudflare-managed hostname to reconfigure.</>
649+
: <>A domain you manage in Cloudflare (the tunnel gets a permanent URL there).
650+
Required the first time — setup opens a one-time Cloudflare login in your browser.</>}
639651
</div>
640652
</div>
641653
)}

packages/webview/src/test/store.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,31 @@ describe('store daemon field', () => {
106106
expect(useStore.getState().daemon).toBeUndefined();
107107
});
108108
});
109+
110+
describe('action result confirmation (PromptEditor wait-for-result)', () => {
111+
it('savePrompt returns its action id and tracks it as pending', () => {
112+
const id = useStore.getState().savePrompt({
113+
name: 'my-prompt', description: 'd', arguments: [], template: 't',
114+
isBuiltin: false, isModified: false,
115+
});
116+
expect(typeof id).toBe('string');
117+
expect(id.length).toBeGreaterThan(0);
118+
expect(useStore.getState().pendingActions.has(id)).toBe(true);
119+
expect(sendToExtension).toHaveBeenCalledWith(expect.objectContaining({ type: 'action:save-prompt', id }));
120+
});
121+
122+
it('markActionDone records a consumable result and clears pending', () => {
123+
const id = useStore.getState().deletePrompt('my-prompt');
124+
useStore.getState().markActionDone(id, false);
125+
expect(useStore.getState().pendingActions.has(id)).toBe(false);
126+
expect(useStore.getState().consumeActionResult(id)).toBe(false);
127+
// consumed — second read is undefined
128+
expect(useStore.getState().consumeActionResult(id)).toBeUndefined();
129+
});
130+
131+
it('resetPrompt success result round-trips as true', () => {
132+
const id = useStore.getState().resetPrompt('built-in');
133+
useStore.getState().markActionDone(id, true);
134+
expect(useStore.getState().consumeActionResult(id)).toBe(true);
135+
});
136+
});

0 commit comments

Comments
 (0)