feat(chat): work-status panel, and MCP auth and settings fixes (#2776)
Adds a work-status panel beside the transcript. Context fill, model and cost, todos, running subagents and the permission requests blocking them, branch and working-tree state, MCP servers, pinned messages and context sources were scattered across the header, the composer and the context panel — a blocked subagent was reported nowhere at all. The panel reads them from live channels rather than persisted history, and becomes an overlay where the chat is too narrow to seat a column. It is on by default, including for existing installs. Because it now carries these readouts, the desktop header and composer drop the ones it duplicates: todo and changed-files chips, usage and MCP tabs. VS Code and mobile keep theirs — neither hosts the panel. Fixes MCP authorization, which was broken from the panel, invalidated by a directory switch through a redirect URI that encoded the working directory, and left the desktop app in the background because browsers will not follow a custom-protocol link without a user gesture. The settings page no longer asks the user to understand the MCP spec before adding a server: one field takes the command or the link, with the kind inferred and a visible override, and client-registration fields appear only when a server actually asks for its own credentials. Also: skills load from the panel instead of only when the composer's slash autocomplete opens; the header button names the current instance rather than falling through to the word "Instance" for relay hosts. Three new optional UI settings keys, all migrated. No change to stored MCP server configuration.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
import { MCP_OAUTH_CALLBACK_PATH, parseMcpOAuthCallbackStateKey } from './mcpOAuth';
|
||||
|
||||
/**
|
||||
* Starting MCP authorization, for every surface that offers it.
|
||||
*
|
||||
* A server in `needs_auth` cannot be fixed by reconnecting: `POST /mcp/:name/connect`
|
||||
* just repeats the attempt that produced `needs_auth` in the first place. The
|
||||
* flow OpenCode expects is explicit — ask for an authorization URL, send the
|
||||
* user to it, then hand the returned code back:
|
||||
*
|
||||
* POST /mcp/:name/auth → { authorizationUrl, oauthState }
|
||||
* (user authorises in a browser)
|
||||
* POST /mcp/:name/auth/callback → status
|
||||
*
|
||||
* OpenCode does not open the browser for this flow; that is the caller's job.
|
||||
*
|
||||
* The redirect URI matters as much as the call. Without one of ours in the
|
||||
* server's config, OpenCode falls back to its own loopback listener on
|
||||
* 127.0.0.1 — which only works when the browser runs on the same machine as
|
||||
* the OpenCode process. For a remote or web client the callback would simply
|
||||
* never arrive, so the first authorization writes our own callback URL into
|
||||
* the config before asking for the URL.
|
||||
*/
|
||||
|
||||
type McpAuthorizationStart = {
|
||||
authorizationUrl: string;
|
||||
/** False when the runtime refused to open a browser; the caller then offers a manual paste. */
|
||||
opened: boolean;
|
||||
};
|
||||
|
||||
class McpAuthorizationError extends Error {}
|
||||
|
||||
/**
|
||||
* The callback lands in the system browser, which is a different surface from
|
||||
* the desktop app. Recording where the flow began lets the callback page hand
|
||||
* control back correctly: a browser session returns to the app it is already
|
||||
* showing, while the desktop shell has to be raised through its own deep link.
|
||||
*
|
||||
* This travels with the pending context, not in the redirect URI. That URI is
|
||||
* written into the server's config once and never rewritten, so a marker
|
||||
* encoded there would be frozen at whatever runtime happened to authorise
|
||||
* first — a desktop user would keep being sent to the web UI forever.
|
||||
*/
|
||||
export const MCP_OAUTH_ORIGIN_DESKTOP = 'desktop';
|
||||
|
||||
/**
|
||||
* Stable for a given server, whatever session is open.
|
||||
*
|
||||
* It used to carry the directory as well, which made the address different for
|
||||
* every worktree: switching sessions produced a new value, so the config was
|
||||
* rewritten and OpenCode reloaded in front of the user. The directory is not
|
||||
* needed here — authorization is not per-directory — and the pending context
|
||||
* parked under the OAuth `state` carries it for the completion call.
|
||||
*
|
||||
* The server name stays. It never varies for a given entry, since the redirect
|
||||
* lives in that entry's own config, and it lets the callback page identify the
|
||||
* server straight from the URL rather than depending solely on server-side
|
||||
* memory surviving the reload this very write triggers.
|
||||
*/
|
||||
export const buildMcpAuthorizationRedirectUri = (name: string): string => {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new McpAuthorizationError('No browser context to build a callback URL from');
|
||||
}
|
||||
const url = new URL(MCP_OAUTH_CALLBACK_PATH, getRuntimeApiBaseUrl() || window.location.origin);
|
||||
url.searchParams.set('server', name);
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
/**
|
||||
* Correlates the eventual browser redirect with the server it belongs to. The
|
||||
* callback page has only the OAuth `state` to go on, so the pair is parked
|
||||
* server-side under that key.
|
||||
*/
|
||||
const queuePendingContext = async (input: {
|
||||
state: string;
|
||||
name: string;
|
||||
directory?: string | null;
|
||||
origin: string | null;
|
||||
}): Promise<void> => {
|
||||
const response = await runtimeFetch('/api/mcp/auth/pending', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
state: input.state,
|
||||
name: input.name,
|
||||
directory: input.directory?.trim() ? input.directory.trim() : null,
|
||||
origin: input.origin,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
throw new McpAuthorizationError(payload?.error || 'Failed to prepare the MCP authorization callback');
|
||||
}
|
||||
};
|
||||
|
||||
const clearPendingContext = async (state: string | null): Promise<void> => {
|
||||
if (!state) return;
|
||||
await runtimeFetch(`/api/mcp/auth/pending?state=${encodeURIComponent(state)}`, { method: 'DELETE' })
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
/** How long the user plausibly spends authorising before giving up on them. */
|
||||
const AUTHORIZATION_WATCH_MS = 3 * 60_000;
|
||||
const AUTHORIZATION_POLL_MS = 1_500;
|
||||
|
||||
const waitForAuthorizationThenFocus = async (name: string, directory: string | null): Promise<void> => {
|
||||
const deadline = Date.now() + AUTHORIZATION_WATCH_MS;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, AUTHORIZATION_POLL_MS));
|
||||
try {
|
||||
await useMcpStore.getState().refresh({ directory, silent: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const status = useMcpStore.getState().getStatusForDirectory(directory)[name]?.status;
|
||||
if (status === 'connected') {
|
||||
void focusDesktopWindow();
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const startMcpAuthorization = async (input: {
|
||||
name: string;
|
||||
directory?: string | null;
|
||||
/** VS Code cannot receive our callback route, so it keeps OpenCode's own redirect. */
|
||||
skipRedirectUriBootstrap?: boolean;
|
||||
}): Promise<McpAuthorizationStart> => {
|
||||
const { name, directory } = input;
|
||||
let queuedState: string | null = null;
|
||||
|
||||
try {
|
||||
if (!input.skipRedirectUriBootstrap) {
|
||||
// The config has to be loaded before its absence can mean anything. On
|
||||
// the first authorization after launch the store is often still empty,
|
||||
// and reading it then reported "no redirect URI" for a server that had
|
||||
// one — so the config was rewritten needlessly and OpenCode reloaded in
|
||||
// front of the user for no reason.
|
||||
if (!useMcpConfigStore.getState().getMcpByName(name)) {
|
||||
await useMcpConfigStore.getState().loadMcpConfigs();
|
||||
}
|
||||
|
||||
const configStore = useMcpConfigStore.getState();
|
||||
const existing = configStore.getMcpByName(name);
|
||||
// `oauth: false` means the user disabled it explicitly.
|
||||
const currentOAuth = existing && 'oauth' in existing && existing.oauth
|
||||
? existing.oauth
|
||||
: null;
|
||||
|
||||
// Rewritten when it does not match the callback we would receive right
|
||||
// now — not merely when it is missing.
|
||||
//
|
||||
// The desktop app's loopback port changes between launches, so a stored
|
||||
// redirect from an earlier session points at a port nothing serves any
|
||||
// more: the provider redirects into the void and authorization never
|
||||
// completes. Comparing instead of checking for absence also means the
|
||||
// config is left alone — and OpenCode is not reloaded — whenever the
|
||||
// stored value is already right, which is every run after the first.
|
||||
const desiredRedirectUri = buildMcpAuthorizationRedirectUri(name);
|
||||
if (existing && currentOAuth?.redirectUri !== desiredRedirectUri) {
|
||||
const saved = await configStore.updateMcp(name, {
|
||||
oauthEnabled: true,
|
||||
oauthClientId: currentOAuth?.clientId ?? '',
|
||||
oauthClientSecret: currentOAuth?.clientSecret ?? '',
|
||||
oauthScope: currentOAuth?.scope ?? '',
|
||||
oauthRedirectUri: desiredRedirectUri,
|
||||
});
|
||||
if (!saved.ok) {
|
||||
throw new McpAuthorizationError(
|
||||
saved.message || 'Failed to save the authorization callback URL',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const authorizationUrl = await useMcpStore.getState().startAuth(name, directory ?? null);
|
||||
|
||||
const state = parseMcpOAuthCallbackStateKey(new URL(authorizationUrl).searchParams);
|
||||
if (state) {
|
||||
queuedState = state;
|
||||
await queuePendingContext({
|
||||
state,
|
||||
name,
|
||||
directory,
|
||||
origin: isDesktopShell() ? MCP_OAUTH_ORIGIN_DESKTOP : null,
|
||||
});
|
||||
}
|
||||
|
||||
const opened = await openExternalUrl(authorizationUrl);
|
||||
|
||||
// The desktop app raises itself once the server reports success, rather
|
||||
// than waiting for the browser to hand control back. A browser will not
|
||||
// follow a custom-protocol link without a user gesture, and the completion
|
||||
// page has none — so the return trip cannot start from there.
|
||||
if (opened && isDesktopShell()) {
|
||||
void waitForAuthorizationThenFocus(name, directory ?? null);
|
||||
}
|
||||
|
||||
return { authorizationUrl, opened };
|
||||
} catch (error) {
|
||||
// A parked context whose flow never started would later resolve a stale
|
||||
// server for an unrelated callback.
|
||||
await clearPendingContext(queuedState);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user