From a42d56e15324ebdf5331fa2e163d125ab2f27eed Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Tue, 21 Apr 2026 11:20:40 +0530 Subject: [PATCH 1/4] fix(session): emit truncated title on first turn, upgrade on AI title The prior flow delayed the initial title until the first turn finished, which meant the sidebar showed "New conversation" for the entire turn. Now emit the truncated user-question title as soon as the turn starts, and upgrade to the AI-generated title at the end only when it differs. Co-Authored-By: Claude Opus 4.7 --- packages/agent-core/src/session.ts | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/agent-core/src/session.ts b/packages/agent-core/src/session.ts index ca9695d6..f54e444b 100644 --- a/packages/agent-core/src/session.ts +++ b/packages/agent-core/src/session.ts @@ -832,6 +832,7 @@ export class Session { const isFirstMessage = !this.title && !this.ephemeral // Strip image placeholders for title generation so [img:...] doesn't leak into titles const titleText = trimmedMessage.replace(/\[img:[^\]]+\]/g, '').trim() + let initialTitle: string | null = null if (isFirstMessage) { this.title = generateSmartTitle( titleText || @@ -841,6 +842,7 @@ export class Session { : `${attachments.length} images` : userMessage), ) + initialTitle = this.title } // Fire off AI title generation in parallel (non-blocking) @@ -859,9 +861,12 @@ export class Session { const events: SessionEvent[] = [] - // Title is emitted exactly once per conversation, after the first turn - // completes (see the aiTitlePromise handling below). The regex title set - // above acts as an in-memory fallback if AI title generation fails. + // Emit the truncated-user-question title up front so the UI has a title + // for the whole first turn. The AI-generated title (if any) is emitted + // again at the end only when it differs — see the aiTitlePromise block. + if (initialTitle) { + events.push({ type: 'title_update', title: initialTitle }) + } let resolveNext: (() => void) | null = null let done = false @@ -1080,17 +1085,14 @@ export class Session { this.pendingCompactionEvent = null } - // Emit the conversation title exactly once, on the first message. - // Prefer the AI-generated title; fall back to the regex title if the AI - // call failed or returned the generic placeholder. - if (isFirstMessage) { - if (aiTitlePromise) { - const aiTitle = await aiTitlePromise - if (aiTitle && aiTitle.toLowerCase() !== 'new conversation') { - this.title = aiTitle - } + // If the background AI-title call produced a better title than the + // truncated user question we emitted up front, upgrade it now. + if (isFirstMessage && aiTitlePromise) { + const aiTitle = await aiTitlePromise + if (aiTitle && aiTitle.toLowerCase() !== 'new conversation' && aiTitle !== this.title) { + this.title = aiTitle + yield { type: 'title_update', title: this.title } } - yield { type: 'title_update', title: this.title } } this.log.info({ eventCount, textEventCount }, 'processMessage complete') From 89bcfbe3b128e0a29e732a92c0ce887c17de9bab Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Tue, 21 Apr 2026 11:20:58 +0530 Subject: [PATCH 2/4] feat(connectors): declare capability metadata per connector Add capabilitySummary and capabilityExample fields to DirectConnector and populate them across all 17 built-in connectors. These feed the harness-side capability block so codex sees ground truth about which services are live instead of relying on its training priors. Co-Authored-By: Claude Opus 4.7 --- packages/connectors/src/airtable/index.ts | 2 ++ packages/connectors/src/exa/index.ts | 3 +++ packages/connectors/src/github/index.ts | 3 +++ packages/connectors/src/gmail/index.ts | 2 ++ packages/connectors/src/google-calendar/index.ts | 2 ++ packages/connectors/src/google-docs/index.ts | 2 ++ packages/connectors/src/google-drive/index.ts | 3 +++ .../connectors/src/google-search-console/index.ts | 3 +++ packages/connectors/src/google-sheets/index.ts | 2 ++ packages/connectors/src/granola/index.ts | 2 ++ packages/connectors/src/linear/index.ts | 3 +++ packages/connectors/src/linkedin/index.ts | 3 +++ packages/connectors/src/notion/index.ts | 3 +++ packages/connectors/src/polymarket/index.ts | 3 +++ packages/connectors/src/slack/index.ts | 6 ++++++ packages/connectors/src/telegram/index.ts | 2 ++ packages/connectors/src/types.ts | 15 +++++++++++++++ 17 files changed, 59 insertions(+) diff --git a/packages/connectors/src/airtable/index.ts b/packages/connectors/src/airtable/index.ts index 519a9977..69d8fd29 100644 --- a/packages/connectors/src/airtable/index.ts +++ b/packages/connectors/src/airtable/index.ts @@ -6,6 +6,8 @@ import { createAirtableTools } from './tools.js' export class AirtableConnector implements DirectConnector { readonly id = 'airtable' readonly name = 'Airtable' + readonly capabilitySummary = 'Read/write Airtable bases — list records, create/update/delete rows' + readonly capabilityExample = 'airtable_list_records' private api = new AirtableAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/exa/index.ts b/packages/connectors/src/exa/index.ts index 264ad13e..d2de7109 100644 --- a/packages/connectors/src/exa/index.ts +++ b/packages/connectors/src/exa/index.ts @@ -6,6 +6,9 @@ import { createExaTools } from './tools.js' export class ExaConnector implements DirectConnector { readonly id = 'exa-search' readonly name = 'Exa Search' + readonly capabilitySummary = + 'Web search with citations, page-content extraction, structured answers' + readonly capabilityExample = 'exa_search' private api = new ExaAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/github/index.ts b/packages/connectors/src/github/index.ts index 25de9f92..3a09da1b 100644 --- a/packages/connectors/src/github/index.ts +++ b/packages/connectors/src/github/index.ts @@ -6,6 +6,9 @@ import { createGitHubTools } from './tools.js' export class GitHubConnector implements DirectConnector { readonly id = 'github' readonly name = 'GitHub' + readonly capabilitySummary = + 'Read/write GitHub: repos, issues, PRs, branches, files, code search, merges' + readonly capabilityExample = 'github_create_issue' private api = new GitHubAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/gmail/index.ts b/packages/connectors/src/gmail/index.ts index 3be50562..f19339e4 100644 --- a/packages/connectors/src/gmail/index.ts +++ b/packages/connectors/src/gmail/index.ts @@ -6,6 +6,8 @@ import { createGmailTools } from './tools.js' export class GmailConnector implements DirectConnector { readonly id = 'gmail' readonly name = 'Gmail' + readonly capabilitySummary = 'Send/draft emails, search threads, read/trash messages, mark read' + readonly capabilityExample = 'gmail_send_email' private api = new GmailAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/google-calendar/index.ts b/packages/connectors/src/google-calendar/index.ts index 7d17d442..0914fcfe 100644 --- a/packages/connectors/src/google-calendar/index.ts +++ b/packages/connectors/src/google-calendar/index.ts @@ -6,6 +6,8 @@ import { createGoogleCalendarTools } from './tools.js' export class GoogleCalendarConnector implements DirectConnector { readonly id = 'google-calendar' readonly name = 'Google Calendar' + readonly capabilitySummary = 'List/create/update/delete calendar events, list calendars' + readonly capabilityExample = 'gcal_create_event' private api = new GoogleCalendarAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/google-docs/index.ts b/packages/connectors/src/google-docs/index.ts index dcde6e17..7d46de8b 100644 --- a/packages/connectors/src/google-docs/index.ts +++ b/packages/connectors/src/google-docs/index.ts @@ -6,6 +6,8 @@ import { createGoogleDocsTools } from './tools.js' export class GoogleDocsConnector implements DirectConnector { readonly id = 'google-docs' readonly name = 'Google Docs' + readonly capabilitySummary = 'List/read/create Google Docs, append or insert text' + readonly capabilityExample = 'gdocs_create_document' private api = new GoogleDocsAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/google-drive/index.ts b/packages/connectors/src/google-drive/index.ts index 3bc9c20b..5b35ab3e 100644 --- a/packages/connectors/src/google-drive/index.ts +++ b/packages/connectors/src/google-drive/index.ts @@ -6,6 +6,9 @@ import { createGoogleDriveTools } from './tools.js' export class GoogleDriveConnector implements DirectConnector { readonly id = 'google-drive' readonly name = 'Google Drive' + readonly capabilitySummary = + 'Browse/search Drive files, read contents, upload/delete, create folders' + readonly capabilityExample = 'gdrive_search_files' private api = new GoogleDriveAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/google-search-console/index.ts b/packages/connectors/src/google-search-console/index.ts index 8cb585b7..4a8f112a 100644 --- a/packages/connectors/src/google-search-console/index.ts +++ b/packages/connectors/src/google-search-console/index.ts @@ -6,6 +6,9 @@ import { createGoogleSearchConsoleTools } from './tools.js' export class GoogleSearchConsoleConnector implements DirectConnector { readonly id = 'google-search-console' readonly name = 'Google Search Console' + readonly capabilitySummary = + 'SEO analytics: top queries/pages, impressions, CTR, device/country breakdowns, sitemaps' + readonly capabilityExample = 'gsc_top_queries' private api = new GoogleSearchConsoleAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/google-sheets/index.ts b/packages/connectors/src/google-sheets/index.ts index 3b09b787..aaa4e92a 100644 --- a/packages/connectors/src/google-sheets/index.ts +++ b/packages/connectors/src/google-sheets/index.ts @@ -6,6 +6,8 @@ import { createGoogleSheetsTools } from './tools.js' export class GoogleSheetsConnector implements DirectConnector { readonly id = 'google-sheets' readonly name = 'Google Sheets' + readonly capabilitySummary = 'Read/write spreadsheet ranges, append rows, create sheets and tabs' + readonly capabilityExample = 'gsheets_append_rows' private api = new GoogleSheetsAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/granola/index.ts b/packages/connectors/src/granola/index.ts index a264ffe3..d1de9985 100644 --- a/packages/connectors/src/granola/index.ts +++ b/packages/connectors/src/granola/index.ts @@ -6,6 +6,8 @@ import { createGranolaTools } from './tools.js' export class GranolaConnector implements DirectConnector { readonly id = 'granola' readonly name = 'Granola' + readonly capabilitySummary = 'List, read, and search Granola meeting notes' + readonly capabilityExample = 'granola_search_notes' private api = new GranolaAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/linear/index.ts b/packages/connectors/src/linear/index.ts index 0b488174..bbb879fb 100644 --- a/packages/connectors/src/linear/index.ts +++ b/packages/connectors/src/linear/index.ts @@ -6,6 +6,9 @@ import { createLinearTools } from './tools.js' export class LinearConnector implements DirectConnector { readonly id = 'linear' readonly name = 'Linear' + readonly capabilitySummary = + 'Create/list/update Linear issues, add comments, browse teams/states/projects' + readonly capabilityExample = 'linear_create_issue' private api = new LinearAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/linkedin/index.ts b/packages/connectors/src/linkedin/index.ts index 59baf7cf..fead4252 100644 --- a/packages/connectors/src/linkedin/index.ts +++ b/packages/connectors/src/linkedin/index.ts @@ -6,6 +6,9 @@ import { createLinkedInTools } from './tools.js' export class LinkedInConnector implements DirectConnector { readonly id = 'linkedin' readonly name = 'LinkedIn' + readonly capabilitySummary = + 'Read profiles, search people, send messages/invitations, create/comment on posts' + readonly capabilityExample = 'linkedin_send_message' private api = new UnipileLinkedInAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/notion/index.ts b/packages/connectors/src/notion/index.ts index 622ee8f6..b2eb9ba7 100644 --- a/packages/connectors/src/notion/index.ts +++ b/packages/connectors/src/notion/index.ts @@ -6,6 +6,9 @@ import { createNotionTools } from './tools.js' export class NotionConnector implements DirectConnector { readonly id = 'notion' readonly name = 'Notion' + readonly capabilitySummary = + 'Search/read Notion, create and update pages, append blocks, query databases' + readonly capabilityExample = 'notion_create_page' private api = new NotionAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/polymarket/index.ts b/packages/connectors/src/polymarket/index.ts index dce52fa4..de8f0d81 100644 --- a/packages/connectors/src/polymarket/index.ts +++ b/packages/connectors/src/polymarket/index.ts @@ -6,6 +6,9 @@ import { createPolymarketTools } from './tools.js' export class PolymarketConnector implements DirectConnector { readonly id = 'polymarket' readonly name = 'Polymarket' + readonly capabilitySummary = + 'Browse prediction markets, get prices/orderbook, view portfolio, place/cancel orders' + readonly capabilityExample = 'polymarket_search' private api = new PolymarketAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/slack/index.ts b/packages/connectors/src/slack/index.ts index 1d641876..29d1ad18 100644 --- a/packages/connectors/src/slack/index.ts +++ b/packages/connectors/src/slack/index.ts @@ -13,6 +13,9 @@ import { createSlackTools } from './tools.js' export class SlackUserConnector implements DirectConnector { readonly id = 'slack' readonly name = 'Slack' + readonly capabilitySummary = + 'Send/edit messages, list channels, read history/threads, search, react, resolve users' + readonly capabilityExample = 'slack_send_message' private api = new SlackAPI() private tools: AgentTool[] = createSlackTools(this.api, { mode: 'user' }) @@ -53,6 +56,9 @@ export class SlackUserConnector implements DirectConnector { export class SlackBotConnector implements DirectConnector { readonly id = 'slack-bot' readonly name = 'Slack (Anton Bot)' + readonly capabilitySummary = + 'Post/reply as the Anton bot in-workspace; restricted to Slack surface sessions' + readonly capabilityExample = 'slack_send_message' /** * Bot tools are gated to Slack sessions only. They use the workspace * bot token (xoxb-) to post inside *this specific workspace*; exposing diff --git a/packages/connectors/src/telegram/index.ts b/packages/connectors/src/telegram/index.ts index 23b47957..473ff082 100644 --- a/packages/connectors/src/telegram/index.ts +++ b/packages/connectors/src/telegram/index.ts @@ -6,6 +6,8 @@ import { createTelegramTools } from './tools.js' export class TelegramConnector implements DirectConnector { readonly id = 'telegram' readonly name = 'Telegram' + readonly capabilitySummary = 'Send/forward Telegram messages, poll updates, look up chats' + readonly capabilityExample = 'telegram_send_message' private api = new TelegramBotAPI() private tools: AgentTool[] = [] diff --git a/packages/connectors/src/types.ts b/packages/connectors/src/types.ts index 882d1f01..c1e208c8 100644 --- a/packages/connectors/src/types.ts +++ b/packages/connectors/src/types.ts @@ -35,6 +35,21 @@ export interface DirectConnector { readonly id: string readonly name: string + /** + * One-line, action-oriented capability summary — fed to a harness CLI + * at thread-start so it knows what the service CAN do without reading + * every tool's schema. Short sentences, imperative voice, no trailing + * period. Example: `"Send/read Gmail, search threads, manage labels"`. + */ + readonly capabilitySummary?: string + + /** + * A single concrete tool-call example — surfaces a canonical intent + * the model can pattern-match on. Example: `"gmail_send_message"`. + * Should be a tool name this connector actually registers. + */ + readonly capabilityExample?: string + /** * Optional surface restriction. When set, this connector's tools are * only visible to sessions whose surface matches one of these values. From 2cd36d4b24606542e2d480bea84d7faade3a8997 Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Tue, 21 Apr 2026 11:21:25 +0530 Subject: [PATCH 3/4] feat(harness): thread-start capability block, MCP exposure, surface filter Teach the codex app-server harness about the connectors and external MCP servers live in the current Anton session so the model answers "do you have access to X?" from ground truth instead of training priors. - Expose Anton's ConnectorManager AND McpManager tools to harness sessions via AntonToolRegistry. Previously external MCP tools never reached harness CLIs, even when the server was connected. - Bake an "Active Anton Connectors" block into developerInstructions once at thread/start under the `anton:` MCP namespace. Immutable for the thread, so no per-turn token cost. - Add an optional `surfaces?: string[]` allowlist to McpServerConfig so developer-machine MCP servers don't leak their tools into Slack / Telegram sessions. Undefined = all surfaces (default). - Log tools/list responses served over the harness IPC bridge and log the capability block install after thread/start resolves. Co-Authored-By: Claude Opus 4.7 --- .../src/harness/codex-harness-session.ts | 72 +++++++++++++--- packages/agent-core/src/harness/index.ts | 3 + .../agent-core/src/harness/mcp-ipc-handler.ts | 8 ++ .../agent-core/src/harness/tool-registry.ts | 53 ++++++++++++ packages/agent-core/src/index.ts | 4 + packages/agent-core/src/mcp/index.ts | 7 +- packages/agent-core/src/mcp/mcp-client.ts | 85 +++++++++++++++++-- packages/agent-core/src/mcp/mcp-manager.ts | 26 +++++- packages/agent-core/src/prompt-layers.ts | 64 ++++++++++++++ packages/agent-server/src/server.ts | 68 +++++++++++++++ 10 files changed, 367 insertions(+), 23 deletions(-) diff --git a/packages/agent-core/src/harness/codex-harness-session.ts b/packages/agent-core/src/harness/codex-harness-session.ts index 32c64a67..af2cccf2 100644 --- a/packages/agent-core/src/harness/codex-harness-session.ts +++ b/packages/agent-core/src/harness/codex-harness-session.ts @@ -23,6 +23,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs' import { basename, extname } from 'node:path' import { createLogger } from '@anton/logger' import type { ChatImageAttachmentInput } from '@anton/protocol' +import { ANTON_MCP_NAMESPACE } from '../prompt-layers.js' import type { SessionEvent } from '../session.js' import { CodexRpcClient, CodexRpcError } from './codex-rpc.js' import { PINNED_CLI_VERSION, detectCodexCli } from './codex-version.js' @@ -45,6 +46,24 @@ export interface CodexHarnessSessionOpts { systemPrompt?: string /** Per-turn system-prompt builder — same contract as HarnessSession. */ buildSystemPrompt?: (userMessage: string, turnIndex: number) => Promise + /** + * Capability block appended ONCE to `developerInstructions` at + * thread-start — ground truth for which connectors are live in THIS + * session. Built by the server from live connector state (see + * `buildHarnessCapabilityBlock`). `developerInstructions` is immutable + * for the life of a codex thread, so baking it in here is both + * sufficient and cheap; no per-turn re-injection. + * + * Empty string = no connectors live (still safe to pass — appended + * as-is and produces no extra text). + */ + capabilityBlock?: string + /** + * Stable ids of the connectors reflected in `capabilityBlock`, purely + * for telemetry: logged after `thread/start` succeeds so we know which + * services the model was told it has. Not otherwise used. + */ + capabilityConnectorIds?: string[] /** Hook invoked after each turn with {userMessage, events}. */ onTurnEnd?: (turn: { userMessage: string; events: SessionEvent[] }) => void | Promise maxBudgetUsd?: number @@ -187,6 +206,18 @@ export class CodexHarnessSession { } else { systemPromptForTurn = this.opts.systemPrompt } + + // First-turn title: truncated user question. Mirrors ChatGPT/Claude.ai — + // the UI gets a title immediately; `thread/name/updated` may later + // upgrade it to a server-generated smart title. + if (this.turnIndex === 0 && !this.title) { + const seed = userMessage.trim().slice(0, 60).split('\n')[0] + if (seed.length > 0) { + this.title = seed + yield { type: 'title_update', title: this.title } + } + } + this.turnIndex += 1 try { @@ -252,18 +283,6 @@ export class CodexHarnessSession { while (!queue.done || queue.events.length > 0) { if (queue.events.length > 0) { const ev = queue.events.shift()! - - // Side effect: capture first text for auto-title. - if (!this.title && ev.type === 'text' && ev.content.length > 0) { - this.title = ev.content.slice(0, 60).split('\n')[0] - turnEvents.push(ev) - yield ev - const titleEv: SessionEvent = { type: 'title_update', title: this.title } - turnEvents.push(titleEv) - yield titleEv - continue - } - turnEvents.push(ev) yield ev } else if (!queue.done) { @@ -483,6 +502,18 @@ export class CodexHarnessSession { capabilities: null, }) + const baseInstructions = (systemPrompt ?? this.opts.systemPrompt ?? '').trim() + const capabilityBlock = (this.opts.capabilityBlock ?? '').trim() + // Explicit `\n\n` joiner — don't rely on the leading blank lines + // `systemReminder()` emits to separate the capability block from the + // prompt above it. Either piece may be empty; `filter(Boolean)` drops + // empties so we never leave a stray `\n\n` at the start or end. + const developerInstructionsJoined = [baseInstructions, capabilityBlock] + .filter((s) => s.length > 0) + .join('\n\n') + const developerInstructions = + developerInstructionsJoined.length > 0 ? developerInstructionsJoined : null + const threadStartParams: Record = { model: this.model, modelProvider: null, @@ -491,7 +522,7 @@ export class CodexHarnessSession { sandbox: 'workspace-write', config: this.buildConfig(), baseInstructions: null, - developerInstructions: systemPrompt ?? this.opts.systemPrompt ?? null, + developerInstructions, // Required fields in v2 — we do not opt into either. experimentalRawEvents: false, persistExtendedHistory: false, @@ -510,6 +541,17 @@ export class CodexHarnessSession { { sessionId: this.id, threadId: this.threadId, model: this.model }, 'codex app-server ready', ) + if (capabilityBlock.length > 0) { + log.info( + { + sessionId: this.id, + threadId: this.threadId, + capabilityBlockChars: capabilityBlock.length, + liveConnectorIds: this.opts.capabilityConnectorIds ?? [], + }, + 'capability block installed via thread/start', + ) + } } catch (err) { const message = err instanceof CodexRpcError ? err.message : (err as Error).message this.rpc?.close('init-failed') @@ -530,10 +572,12 @@ export class CodexHarnessSession { ANTON_SESSION: this.id, ANTON_AUTH: this.opts.authToken, } + // The namespace Codex uses to prefix our tools (e.g. `anton:gmail_*`) + // MUST match the value the identity + capability blocks reference. return { model_reasoning_summary: 'detailed', mcp_servers: { - anton: { + [ANTON_MCP_NAMESPACE]: { command: 'node', args: [this.opts.shimPath], env: mcpEnv, diff --git a/packages/agent-core/src/harness/index.ts b/packages/agent-core/src/harness/index.ts index 25c91ebc..6c108e23 100644 --- a/packages/agent-core/src/harness/index.ts +++ b/packages/agent-core/src/harness/index.ts @@ -27,9 +27,12 @@ export { // use buildHarnessContextPrompt when they need the full harness context // string; it lives next to Session.getSystemPrompt in prompt-layers.ts. export { + ANTON_MCP_NAMESPACE, + buildHarnessCapabilityBlock, buildHarnessContextPrompt, buildHarnessIdentityBlock, type HarnessContextPromptOpts, + type LiveConnectorSummary, type WorkflowEntry, } from '../prompt-layers.js' export { diff --git a/packages/agent-core/src/harness/mcp-ipc-handler.ts b/packages/agent-core/src/harness/mcp-ipc-handler.ts index bed2143f..12c1dd69 100644 --- a/packages/agent-core/src/harness/mcp-ipc-handler.ts +++ b/packages/agent-core/src/harness/mcp-ipc-handler.ts @@ -167,6 +167,14 @@ export function createMcpIpcServer(socketPath: string, provider: IpcToolProvider switch (request.method) { case 'tools/list': { const tools = provider.getTools(sessionId) + log.info( + { + sessionId, + toolCount: tools.length, + toolNames: tools.map((t) => t.name), + }, + 'tools/list served to harness', + ) result = { tools: tools.map((t) => ({ name: t.name, diff --git a/packages/agent-core/src/harness/tool-registry.ts b/packages/agent-core/src/harness/tool-registry.ts index 4789ab72..0fa766aa 100644 --- a/packages/agent-core/src/harness/tool-registry.ts +++ b/packages/agent-core/src/harness/tool-registry.ts @@ -155,6 +155,19 @@ export interface AntonToolRegistryOpts { * same ConnectorManager the Pi SDK uses — no per-backend duplication. */ connectorManager?: { getAllTools(surface?: string): AgentTool[] } + /** + * External MCP server tools (user-added MCP connectors). Mirrors what + * the Pi SDK path does in `buildTools()` — without this, harness + * sessions would silently never see any external MCP tool, even when + * the server is connected. Tool names are already namespaced with + * `mcp__` by McpManager, so they do not collide with Anton's core + * tools or connector tools. + * + * Accepts an optional `surface` hint so MCP configs with a + * `surfaces` allowlist stay out of surfaces they were not authorized + * for. Undefined `surfaces` on the config = all surfaces (default). + */ + mcpManager?: { getAllTools(surface?: string): AgentTool[] } /** * Called on every tools/list and tools/call to resolve the session's * context (projectId, surface, handlers). Returning undefined means @@ -169,10 +182,12 @@ export interface AntonToolRegistryOpts { */ export class AntonToolRegistry implements IpcToolProvider { private connectorManager?: AntonToolRegistryOpts['connectorManager'] + private mcpManager?: AntonToolRegistryOpts['mcpManager'] private getSessionContext?: AntonToolRegistryOpts['getSessionContext'] constructor(opts: AntonToolRegistryOpts = {}) { this.connectorManager = opts.connectorManager + this.mcpManager = opts.mcpManager this.getSessionContext = opts.getSessionContext } @@ -203,6 +218,8 @@ export class AntonToolRegistry implements IpcToolProvider { map.set(tool.name, agentToolToMcpDefinition(tool)) } + const coreCount = map.size + let connectorToolCount = 0 if (this.connectorManager) { const connectorTools = this.connectorManager.getAllTools(ctx?.surface) for (const tool of connectorTools) { @@ -215,12 +232,48 @@ export class AntonToolRegistry implements IpcToolProvider { ) } map.set(def.schema.name, def) + connectorToolCount += 1 } catch (err) { log.warn({ err, name: tool.name, sessionId }, 'failed to adapt connector tool — skipping') } } } + let mcpToolCount = 0 + if (this.mcpManager) { + const mcpTools = this.mcpManager.getAllTools(ctx?.surface) + for (const tool of mcpTools) { + try { + const def = agentToolToMcpDefinition(tool) + if (map.has(def.schema.name)) { + log.warn( + { name: def.schema.name, sessionId }, + 'mcp tool name collides with an existing tool — overriding', + ) + } + map.set(def.schema.name, def) + mcpToolCount += 1 + } catch (err) { + log.warn({ err, name: tool.name, sessionId }, 'failed to adapt mcp tool — skipping') + } + } + } + + log.info( + { + sessionId, + projectId: ctx?.projectId, + surface: ctx?.surface, + coreToolCount: coreCount, + connectorToolCount, + mcpToolCount, + totalToolCount: map.size, + hasConnectorManager: Boolean(this.connectorManager), + hasMcpManager: Boolean(this.mcpManager), + }, + 'built harness tool map', + ) + return map } diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 8b5c5ed1..dcb282d0 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -12,6 +12,7 @@ export type { DeliverResultHandler } from './tools/deliver-result.js' export { McpClient, McpManager, + matchesSurface, type McpServerConfig, type ConnectorStatus, type McpToolPermission, @@ -82,8 +83,11 @@ export { AntonToolRegistry, type AntonToolRegistryOpts, type HarnessSessionContext, + ANTON_MCP_NAMESPACE, + buildHarnessCapabilityBlock, buildHarnessContextPrompt, type HarnessContextPromptOpts, + type LiveConnectorSummary, type WorkflowEntry, synthesizeHarnessTurn, ensureHarnessSessionInit, diff --git a/packages/agent-core/src/mcp/index.ts b/packages/agent-core/src/mcp/index.ts index 84f72919..36b7a773 100644 --- a/packages/agent-core/src/mcp/index.ts +++ b/packages/agent-core/src/mcp/index.ts @@ -1,4 +1,9 @@ export { McpClient, type McpServerConfig, type McpTool, type McpToolResult } from './mcp-client.js' -export { McpManager, type ConnectorStatus, type McpToolPermission } from './mcp-manager.js' +export { + McpManager, + matchesSurface, + type ConnectorStatus, + type McpToolPermission, +} from './mcp-manager.js' export { mcpToolToAgentTool, mcpClientToAgentTools } from './mcp-tool-adapter.js' export { jsonSchemaToTypebox } from './json-schema-to-typebox.js' diff --git a/packages/agent-core/src/mcp/mcp-client.ts b/packages/agent-core/src/mcp/mcp-client.ts index 56a90a07..ca66ec72 100644 --- a/packages/agent-core/src/mcp/mcp-client.ts +++ b/packages/agent-core/src/mcp/mcp-client.ts @@ -53,6 +53,15 @@ export interface McpServerConfig { args: string[] env?: Record enabled: boolean + /** + * Surfaces this MCP connector may appear on. `undefined` (the default) = + * all surfaces. Set to e.g. `['desktop']` to keep a developer-machine + * MCP server from leaking its tools into Slack / Telegram conversations + * where the user context is different. Values are free-form strings so + * new surfaces can be added without touching agent-core; the current + * well-known set matches `ConnectorSurface` in @anton/connectors. + */ + surfaces?: string[] } // ── MCP Client ────────────────────────────────────────────────────── @@ -71,6 +80,11 @@ export class McpClient extends EventEmitter { private buffer = '' /** Serializes requests to prevent concurrent stdin writes to non-thread-safe MCP servers */ private requestQueue: Promise = Promise.resolve() + /** Ring buffer of the most recent stderr lines, surfaced in handshake errors. */ + private stderrTail: string[] = [] + private static readonly STDERR_TAIL_MAX = 40 + /** Process exit details captured before rejectAllPending fires. */ + private exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null private log constructor(config: McpServerConfig) { @@ -96,6 +110,15 @@ export class McpClient extends EventEmitter { const env = { ...process.env, ...this.config.env } + this.log.info( + { + command: this.config.command, + args: this.config.args, + envKeys: this.config.env ? Object.keys(this.config.env) : [], + }, + 'spawning MCP server', + ) + this.process = spawn(this.config.command, this.config.args, { stdio: ['pipe', 'pipe', 'pipe'], env, @@ -109,11 +132,21 @@ export class McpClient extends EventEmitter { rl.on('line', (line) => this.handleLine(line)) } - // Log stderr but don't crash + // Capture stderr into a ring buffer and log it. The buffer is attached + // to handshake errors so we can see *why* a server died on startup. if (this.process.stderr) { const rl = createInterface({ input: this.process.stderr }) rl.on('line', (line) => { - this.log.error({ stream: 'stderr' }, line) + this.stderrTail.push(line) + if (this.stderrTail.length > McpClient.STDERR_TAIL_MAX) { + this.stderrTail.shift() + } + // During handshake, promote to warn so it surfaces alongside the failure. + if (this.connected) { + this.log.error({ stream: 'stderr' }, line) + } else { + this.log.warn({ stream: 'stderr', phase: 'handshake' }, line) + } }) } @@ -123,10 +156,23 @@ export class McpClient extends EventEmitter { this.emit('error', err) }) - this.process.on('exit', (code) => { - this.log.info({ exitCode: code }, 'process exited') + this.process.on('exit', (code, signal) => { + this.exitInfo = { code, signal } + this.log.info( + { + exitCode: code, + signal, + pendingRequests: this.pending.size, + wasConnected: this.connected, + }, + 'process exited', + ) this.connected = false - this.rejectAllPending(new Error(`MCP server exited with code ${code}`)) + const reason = + signal !== null + ? `MCP server killed by signal ${signal}` + : `MCP server exited with code ${code}` + this.rejectAllPending(new Error(reason)) this.emit('disconnected', code) }) @@ -159,9 +205,27 @@ export class McpClient extends EventEmitter { // Discover tools await this.refreshTools() } catch (err) { + const cause = (err as Error).message + const tail = this.stderrTail.slice(-10) + const exitDetail = this.exitInfo + ? ` [exit: code=${this.exitInfo.code}${ + this.exitInfo.signal ? ` signal=${this.exitInfo.signal}` : '' + }]` + : '' + const stderrDetail = tail.length > 0 ? ` [stderr tail: ${tail.join(' | ')}]` : '' + this.log.error( + { + err, + cause, + exitInfo: this.exitInfo, + stderrTail: tail, + command: this.config.command, + }, + 'handshake failed', + ) this.kill() throw new Error( - `Failed to initialize MCP server "${this.config.id}": ${(err as Error).message}`, + `Failed to initialize MCP server "${this.config.id}": ${cause}${exitDetail}${stderrDetail}`, ) } } @@ -273,7 +337,14 @@ export class McpClient extends EventEmitter { try { msg = JSON.parse(trimmed) } catch { - // Not JSON — ignore (some servers emit non-JSON on stdout) + // Non-JSON stdout is a common MCP bug (servers logging to stdout). + // During handshake this silently swallows the initialize response, so + // surface it — quietly once we're past initialize. + if (!this.connected) { + this.log.warn({ stream: 'stdout', phase: 'handshake', line: trimmed }, 'non-JSON stdout') + } else { + this.log.debug({ stream: 'stdout', line: trimmed }, 'non-JSON stdout') + } return } diff --git a/packages/agent-core/src/mcp/mcp-manager.ts b/packages/agent-core/src/mcp/mcp-manager.ts index 60dbbea5..9baf2ff8 100644 --- a/packages/agent-core/src/mcp/mcp-manager.ts +++ b/packages/agent-core/src/mcp/mcp-manager.ts @@ -15,6 +15,22 @@ import { mcpClientToAgentTools } from './mcp-tool-adapter.js' const log = createLogger('mcp-manager') +/** + * Match rule for `McpServerConfig.surfaces` / `ConnectorStatus.surfaces`: + * an undefined or empty allowlist means "every surface"; otherwise the + * session's surface must appear in the list. Called without a `surface` + * (e.g. from Pi SDK `buildTools()`) also returns true — filtering is a + * harness / session-level feature, not a global block. + */ +export function matchesSurface( + allowed: string[] | undefined, + surface: string | undefined, +): boolean { + if (!allowed || allowed.length === 0) return true + if (!surface) return true + return allowed.includes(surface) +} + export interface ConnectorStatus { id: string name: string @@ -23,6 +39,8 @@ export interface ConnectorStatus { toolCount: number tools: string[] error?: string + /** Mirrors `McpServerConfig.surfaces` — undefined means "all surfaces". */ + surfaces?: string[] } /** Per-tool permission override applied at runtime. Anything missing from the map defaults to 'auto'. */ @@ -219,11 +237,16 @@ export class McpManager { /** * Get all tools from all connected MCP servers, as pi SDK AgentTools. * This is the key method — called by buildTools() to merge MCP tools. + * + * If `surface` is provided, skip any MCP server whose config declares a + * `surfaces` whitelist that doesn't include this surface. Undeclared + * `surfaces` = all surfaces (current default behavior). */ - getAllTools(): AgentTool[] { + getAllTools(surface?: string): AgentTool[] { const tools: AgentTool[] = [] for (const client of this.clients.values()) { if (!client.isConnected()) continue + if (!matchesSurface(client.config.surfaces, surface)) continue const connectorPerms = this.toolPermissions.get(client.config.id) for (const tool of mcpClientToAgentTools(client)) { // Filter 'never' tools — agent should not even see them @@ -252,6 +275,7 @@ export class McpManager { connected: client?.isConnected() ?? false, toolCount: client?.getTools().length ?? 0, tools: client?.getTools().map((t) => t.name) ?? [], + surfaces: config.surfaces, }) } return statuses diff --git a/packages/agent-core/src/prompt-layers.ts b/packages/agent-core/src/prompt-layers.ts index a04b82ba..67f62ccf 100644 --- a/packages/agent-core/src/prompt-layers.ts +++ b/packages/agent-core/src/prompt-layers.ts @@ -284,6 +284,70 @@ export function buildMemoryGuidelinesLayer(): string { * below reads well for both Claude and Codex based on their documented * prompting conventions. Revisit after we have usage telemetry. */ +/** + * Namespace Anton's MCP server uses when surfaced to a harness CLI that + * supports per-server tool prefixes (today: codex). Tools reach the model + * as `anton:`. Exported so the identity block, capability + * block, and the codex server-config use ONE source of truth — changing + * this string must stay consistent across all three. + */ +export const ANTON_MCP_NAMESPACE = 'anton' + +export interface LiveConnectorSummary { + /** Stable id, e.g. `gmail`, `google-calendar`, `slack-bot`. */ + id: string + /** Human-readable name, e.g. `Gmail`, `Google Calendar`. */ + name: string + /** One-line capability summary from the connector definition. Empty string if absent. */ + capabilitySummary: string + /** A canonical tool name for this connector (`gmail_send_email`). Empty if absent. */ + capabilityExample: string +} + +/** + * Thread-start capability block — lists the connectors that are actually + * live for THIS session, baked into `developerInstructions` once so the + * model answers "do you have access to X?" from ground truth instead of + * training priors. + * + * Deliberately injected at thread start only, NOT per turn: + * - `developerInstructions` is immutable for the life of a codex + * thread, so a one-time cost covers the entire conversation. + * - Per-turn injection of the same block wastes tokens and busts the + * prompt cache whenever connectors change mid-session, which almost + * never happens in practice. + * + * Returns `''` when no connectors are live — callers should still append + * unconditionally; an empty string is a no-op. + */ +export function buildHarnessCapabilityBlock( + liveConnectors: LiveConnectorSummary[], + namespace: string, +): string { + if (liveConnectors.length === 0) return '' + const list = liveConnectors + .map((c) => { + const summary = c.capabilitySummary ? ` — ${c.capabilitySummary}` : '' + const example = c.capabilityExample ? ` (e.g. \`${namespace}:${c.capabilityExample}\`)` : '' + return `- **${c.name}**${summary}${example}` + }) + .join('\n') + // Anchor the "call directly or tools/list" hint on a concrete example. + // Every connector now declares `capabilityExample`, so this is always set + // for at least one entry in practice; the `?? ''` only guards against a + // misconfigured connector sneaking through. + const anchor = liveConnectors.find((c) => c.capabilityExample)?.capabilityExample ?? '' + const exampleHint = anchor ? ` (e.g. \`${namespace}:${anchor}\`)` : '' + return systemReminder( + 'Active Anton Connectors', + `The user has authenticated these services in Anton right now. Their tools are live under the \`${namespace}\` MCP server: + +${list} + +These ARE available — call the tools directly${exampleHint} or run \`tools/list\` on the \`${namespace}\` server for exact tool names and schemas. Do NOT claim "no access" for any service in the list above. If the user asks about a service NOT listed, tell them to add it in Anton → Settings → Connectors instead of refusing generically.`, + ) +} + export function buildHarnessIdentityBlock(): string { return systemReminder( 'Anton', diff --git a/packages/agent-server/src/server.ts b/packages/agent-server/src/server.ts index 6cbb7ebd..089aa7a3 100644 --- a/packages/agent-server/src/server.ts +++ b/packages/agent-server/src/server.ts @@ -84,17 +84,20 @@ import { updateConnector as updateConnectorConfig, } from '@anton/agent-config' import { + ANTON_MCP_NAMESPACE, AntonToolRegistry, ClaudeAdapter, CodexAdapter, CodexHarnessSession, HarnessSession, + type LiveConnectorSummary, McpManager, type McpServerConfig, type Session, type SubAgentEventHandler, appendHarnessTurn, assembleConversationContext, + buildHarnessCapabilityBlock, buildHarnessContextPrompt, buildReplaySeed, createMcpIpcServer, @@ -104,6 +107,7 @@ import { extractHarnessMemoriesFromMirror, hashPromptVersion, isHarnessSession, + matchesSurface, readHarnessHistory, resolveModel, resumeSession, @@ -314,6 +318,7 @@ export class AgentServer { // connectorManager is assigned. this.toolRegistry = new AntonToolRegistry({ connectorManager: this.connectorManager, + mcpManager: this.mcpManager, getSessionContext: (sessionId) => this.harnessSessionContexts.get(sessionId), }) @@ -2104,6 +2109,10 @@ export class AgentServer { this.runHarnessMemoryExtraction(id, mirrorProjectId) } + const liveConnectorsAtStart = this.collectLiveConnectorsForPrompt(surfaceLabel) + const capabilityBlock = buildHarnessCapabilityBlock(liveConnectorsAtStart, ANTON_MCP_NAMESPACE) + const liveConnectorIdsAtStart = liveConnectorsAtStart.map((c) => c.id) + const session: HarnessSession | CodexHarnessSession = providerName === 'codex' ? new CodexHarnessSession({ @@ -2115,6 +2124,8 @@ export class AgentServer { authToken, cwd, buildSystemPrompt, + capabilityBlock, + capabilityConnectorIds: liveConnectorIdsAtStart, onTurnEnd, }) : new HarnessSession({ @@ -3641,6 +3652,63 @@ export class AgentServer { return this._workflowCatalog } + /** + * Collect the set of connectors actually live for this session, so the + * harness identity prompt can answer "what services do I have?" from + * ground truth instead of the model's training priors. Includes both + * direct connectors (Slack, GitHub, Gmail, …) and enabled MCP servers. + */ + private collectLiveConnectorsForPrompt(surface?: string): LiveConnectorSummary[] { + const result: LiveConnectorSummary[] = [] + const seen = new Set() + + for (const id of this.connectorManager.getActiveIds()) { + const connector = this.connectorManager.getConnector(id) + if (!connector) continue + if ( + surface && + connector.surfaces && + connector.surfaces.length > 0 && + !connector.surfaces.includes(surface) + ) { + continue + } + const toolNames = connector.getTools().map((t) => t.name) + if (toolNames.length === 0) continue + // Prefer the connector's declared example (authoritative) and fall + // back to the first tool name only when none is set. + const example = connector.capabilityExample ?? toolNames[0] + result.push({ + id, + name: connector.name, + capabilitySummary: connector.capabilitySummary ?? '', + capabilityExample: example ?? '', + }) + seen.add(id) + } + + for (const status of this.mcpManager.getStatus()) { + if (!status.connected || status.toolCount === 0) continue + // Skip if a direct connector with the same id already claimed it — + // prevents a duplicate "Gmail / Gmail (MCP)" entry when the user + // has both wired up. + if (seen.has(status.id)) continue + // Honor any surface allowlist the user configured on the MCP + // server so its tools don't get advertised in surfaces where they + // wouldn't execute anyway. + if (!matchesSurface(status.surfaces, surface)) continue + const example = status.tools[0] ?? '' + result.push({ + id: status.id, + name: status.name, + capabilitySummary: status.description ?? '', + capabilityExample: example, + }) + } + + return result + } + // ── Workflow handlers ────────────────────────────────────────────── private handleWorkflowRegistryList() { From 4722df7d650afb28e64da72cc1ad141c86087c88 Mon Sep 17 00:00:00 2001 From: Om Gupta Date: Tue, 21 Apr 2026 11:31:27 +0530 Subject: [PATCH 4/4] feat(desktop): harness setup modal, account store, and UI polish - Add HarnessSetupModal for CLI install/login flow per provider - Add local-only accountStore (display name + avatar color, localStorage) - Wire Sidebar avatar/name to accountStore, open Settings on click - Route ModelSelector "manage" to Settings > models via custom event - Portal popovers/previews (project menu, image hover) to document.body - Randomized greeting tail on home screen - Routines "New" opens a draft conversation instead of inline create - Settings modal redesign and expanded index.css styles Co-Authored-By: Claude Opus 4.7 --- packages/desktop/src/components/Sidebar.tsx | 60 +++- .../src/components/chat/HarnessSetupModal.tsx | 221 ++++++++++++++ .../src/components/chat/ModelSelector.tsx | 83 +++--- .../desktop/src/components/chat/RichInput.tsx | 29 +- .../src/components/home/StreamHome.tsx | 40 ++- .../src/components/routines/RoutinesView.tsx | 26 +- .../src/components/settings/SettingsModal.tsx | 273 +++++++++++++---- packages/desktop/src/index.css | 281 +++++++++++++++++- .../desktop/src/lib/store/accountStore.ts | 67 +++++ 9 files changed, 937 insertions(+), 143 deletions(-) create mode 100644 packages/desktop/src/components/chat/HarnessSetupModal.tsx create mode 100644 packages/desktop/src/lib/store/accountStore.ts diff --git a/packages/desktop/src/components/Sidebar.tsx b/packages/desktop/src/components/Sidebar.tsx index 8ac7810b..0496383f 100644 --- a/packages/desktop/src/components/Sidebar.tsx +++ b/packages/desktop/src/components/Sidebar.tsx @@ -21,10 +21,12 @@ import { X, Zap, } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { formatRelativeTime } from '../lib/agent-utils.js' import { sanitizeTitle } from '../lib/conversations.js' import { loadMachines, useStore } from '../lib/store.js' +import { accountColorValue, accountStore, avatarInitial } from '../lib/store/accountStore.js' import { projectStore } from '../lib/store/projectStore.js' import { sessionStore } from '../lib/store/sessionStore.js' import { uiStore } from '../lib/store/uiStore.js' @@ -60,6 +62,8 @@ const NAV: { id: NavId; label: string; icon: typeof SquareCheck }[] = [ export function Sidebar({ onViewChange, onOpenSettings }: Props) { const devMode = uiStore((s) => s.devMode) + const displayName = accountStore((s) => s.displayName) + const avatarColor = accountStore((s) => s.avatarColor) const switchConversation = useStore((s) => s.switchConversation) const newConversation = useStore((s) => s.newConversation) const deleteConversation = useStore((s) => s.deleteConversation) @@ -76,11 +80,36 @@ export function Sidebar({ onViewChange, onOpenSettings }: Props) { const [projectMenuOpen, setProjectMenuOpen] = useState(false) const projectWrapRef = useRef(null) + const projectMenuRef = useRef(null) + const [projectMenuPos, setProjectMenuPos] = useState<{ top: number; left: number } | null>(null) + + useLayoutEffect(() => { + if (!projectMenuOpen) { + setProjectMenuPos(null) + return + } + const computePos = () => { + const el = projectWrapRef.current + if (!el) return + const rect = el.getBoundingClientRect() + setProjectMenuPos({ top: rect.bottom + 6, left: rect.left }) + } + computePos() + window.addEventListener('resize', computePos) + window.addEventListener('scroll', computePos, true) + return () => { + window.removeEventListener('resize', computePos) + window.removeEventListener('scroll', computePos, true) + } + }, [projectMenuOpen]) useEffect(() => { if (!projectMenuOpen) return const onDoc = (e: MouseEvent) => { - if (projectWrapRef.current && !projectWrapRef.current.contains(e.target as Node)) { + const target = e.target as Node + const inWrap = projectWrapRef.current?.contains(target) + const inMenu = projectMenuRef.current?.contains(target) + if (!inWrap && !inMenu) { setProjectMenuOpen(false) } } @@ -256,8 +285,12 @@ export function Sidebar({ onViewChange, onOpenSettings }: Props) { - {projectMenuOpen && ( -
+ {projectMenuOpen && projectMenuPos && createPortal( +
Projects
{[...projects] @@ -339,7 +372,8 @@ export function Sidebar({ onViewChange, onOpenSettings }: Props) { Manage projects -
+
, + document.body, )}
@@ -456,10 +490,18 @@ export function Sidebar({ onViewChange, onOpenSettings }: Props) { )} -
-
O
- omg -
+ diff --git a/packages/desktop/src/components/chat/HarnessSetupModal.tsx b/packages/desktop/src/components/chat/HarnessSetupModal.tsx new file mode 100644 index 00000000..5ae72a4e --- /dev/null +++ b/packages/desktop/src/components/chat/HarnessSetupModal.tsx @@ -0,0 +1,221 @@ +import { Check, Loader2 } from 'lucide-react' +import { type ReactNode, useEffect, useState } from 'react' +import type { ProviderInfo } from '../../lib/store.js' +import { sessionStore } from '../../lib/store/sessionStore.js' +import { Modal } from '../ui/Modal.js' +import { providerIcons } from './model-utils.js' + +interface Props { + provider: ProviderInfo | null + onClose: () => void +} + +export function HarnessSetupModal({ provider, onClose }: Props) { + const statuses = sessionStore((s) => s.harnessStatuses) + const progressMap = sessionStore((s) => s.harnessSetupProgress) + const sendHarnessSetup = sessionStore((s) => s.sendHarnessSetup) + const sendDetectHarnesses = sessionStore((s) => s.sendDetectHarnesses) + const [code, setCode] = useState('') + + useEffect(() => { + if (provider) sendDetectHarnesses() + }, [provider, sendDetectHarnesses]) + + if (!provider) return null + + const status = statuses[provider.name] + const progress = progressMap[provider.name] + const icon = providerIcons[provider.name] + const providerLabel = provider.name.charAt(0).toUpperCase() + provider.name.slice(1) + + const installed = !!status?.installed + const loggedIn = !!status?.auth?.loggedIn + const busy = + !!progress && progress.success === undefined && progress.step !== 'done' && !progress.message + + const connected = installed && loggedIn + const installBusy = busy && progress?.action === 'install' + const loginBusy = busy && progress?.action === 'login' + const awaitingCode = progress?.action === 'login' && progress?.step === 'awaiting_code' + + return ( + +
+
+
+ {icon ? ( + + ) : ( + + {provider.name.charAt(0).toUpperCase()} + + )} + {providerLabel} CLI + {status?.version && v{status.version}} +
+ {connected && ( + + + Connected + + )} +
+ + {connected ? ( +
+
+ +
+
+
{providerLabel} CLI is ready
+
+ {status?.auth?.email ? ( + <> + Signed in as {status.auth.email} + + ) : ( + 'Signed in' + )} + {status?.auth?.subscriptionType && ( + {status.auth.subscriptionType} + )} +
+
+
+ ) : ( +
+ sendHarnessSetup(provider.name, 'install')} + > + {installBusy ? ( + <> + Installing… + + ) : ( + 'Install' + )} + + ) + } + /> + + sendHarnessSetup(provider.name, 'login')} + > + {loginBusy ? ( + <> + Waiting… + + ) : ( + 'Sign in' + )} + + ) + } + /> + + {awaitingCode && ( +
{ + e.preventDefault() + const trimmed = code.trim() + if (!trimmed) return + sendHarnessSetup(provider.name, 'login_code', trimmed) + setCode('') + }} + > + setCode(e.target.value)} + autoComplete="off" + spellCheck={false} + /> + +
+ )} +
+ )} + + {progress?.message && ( +
+ {progress.message} +
+ )} +
+
+ ) +} + +interface StepRowProps { + index: number + title: string + desc: string + state: 'pending' | 'busy' | 'done' + disabled?: boolean + action: ReactNode +} + +function StepRow({ index, title, desc, state, disabled, action }: StepRowProps) { + return ( +
+
+ {state === 'done' ? ( + + ) : state === 'busy' ? ( + + ) : ( + {index} + )} +
+
+
{title}
+
{desc}
+
+ {action &&
{action}
} +
+ ) +} diff --git a/packages/desktop/src/components/chat/ModelSelector.tsx b/packages/desktop/src/components/chat/ModelSelector.tsx index 57950f10..5006648c 100644 --- a/packages/desktop/src/components/chat/ModelSelector.tsx +++ b/packages/desktop/src/components/chat/ModelSelector.tsx @@ -3,9 +3,12 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { ProviderInfo } from '../../lib/store.js' import { sessionStore } from '../../lib/store/sessionStore.js' -import { SettingsModal } from '../settings/SettingsModal.js' import { formatModelName, providerIcons } from './model-utils.js' +function openSettingsModels() { + window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'models' } })) +} + function ProviderIcon({ provider, size = 16 }: { provider: string; size?: number }) { const icon = providerIcons[provider] if (icon) { @@ -119,7 +122,7 @@ function computePosition(rect: DOMRect): PopoverPosition { return { top, left, maxHeight, placement } } -function ModelPopover({ +export function ModelPopover({ anchorRef, providers, currentProvider, @@ -285,7 +288,6 @@ export function ModelSelector() { const currentModel = sessionStore((s) => s.currentModel) const providers = sessionStore((s) => s.providers) const [open, setOpen] = useState(false) - const [settingsOpen, setSettingsOpen] = useState(false) const buttonRef = useRef(null) const hasAnyProvider = providers.length > 0 @@ -301,48 +303,39 @@ export function ModelSelector() { } return ( - <> -
- - {open && ( - setOpen(false)} - onManage={() => { - setOpen(false) - setSettingsOpen(true) - }} - /> +
+
- - setSettingsOpen(false)} - onDisconnect={() => setSettingsOpen(false)} - initialPage="models" - /> - + {displayModel} + {tag && {tag}} + + + {open && ( + setOpen(false)} + onManage={() => { + setOpen(false) + openSettingsModels() + }} + /> + )} +
) } diff --git a/packages/desktop/src/components/chat/RichInput.tsx b/packages/desktop/src/components/chat/RichInput.tsx index 808d7d95..2d0bca1d 100644 --- a/packages/desktop/src/components/chat/RichInput.tsx +++ b/packages/desktop/src/components/chat/RichInput.tsx @@ -1,5 +1,6 @@ import type React from 'react' import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import type { ChatImageAttachment } from '../../lib/store.js' // ── Public types ────────────────────────────────────────────────────────────── @@ -451,19 +452,21 @@ export const RichInput = forwardRef(function RichInput( suppressContentEditableWarning style={{ minHeight, maxHeight }} /> - {preview && ( -
- {preview.name} - {preview.name} -
- )} + {preview && + createPortal( +
+ {preview.name} + {preview.name} +
, + document.body, + )} ) }) diff --git a/packages/desktop/src/components/home/StreamHome.tsx b/packages/desktop/src/components/home/StreamHome.tsx index a0218f02..a05bd3b5 100644 --- a/packages/desktop/src/components/home/StreamHome.tsx +++ b/packages/desktop/src/components/home/StreamHome.tsx @@ -35,14 +35,46 @@ function greetingForHour(h: number): string { return "It's late-night" } +const TAIL_PHRASES = [ + 'ready to build?', + 'what are we making today?', + "what's on your mind?", + 'what shall we tackle?', + "let's ship something.", + 'ready when you are.', + "what's next?", + 'got a spark?', + 'what are we exploring?', + "let's dig in.", + "what's cooking?", + 'shall we begin?', + "what's the move?", + 'got something in motion?', + 'ready to create?', + 'what are we solving?', + "let's make something good.", + 'what feels exciting today?', + 'where are we headed?', + 'pick up where you left off?', + 'ready for a fresh start?', + "what's the mission?", + "let's get into it.", + "what's calling you today?", + 'time to make a dent.', +] + +function pickTail(): string { + return TAIL_PHRASES[Math.floor(Math.random() * TAIL_PHRASES.length)] ?? '' +} + export function StreamHome({ onSkillSelect }: Props) { const [draft, setDraft] = useState('') const newConversation = useStore((s) => s.newConversation) + const activeConversationId = useStore((s) => s.activeConversationId) const setActiveView = uiStore((s) => s.setActiveView) - const onboardingRole = uiStore((s) => s.onboardingRole) const greeting = useMemo(() => greetingForHour(new Date().getHours()), []) - const userName = onboardingRole ?? '' + const tail = useMemo(() => pickTail(), []) // Generate suggestions once on mount — avoid regenerating when conversations mutate. const forYouRef = useRef(null) @@ -117,8 +149,7 @@ export function StreamHome({ onSkillSelect }: Props) {
- {greeting} - {userName ? ` ${userName}` : ''} + {greeting}, {tail}
@@ -130,6 +161,7 @@ export function StreamHome({ onSkillSelect }: Props) { placeholder="How can I help you today?" initialValue={draft} ignoreWorkingState + conversationId={activeConversationId ?? undefined} /> diff --git a/packages/desktop/src/components/routines/RoutinesView.tsx b/packages/desktop/src/components/routines/RoutinesView.tsx index 79c06be9..bc1047e8 100644 --- a/packages/desktop/src/components/routines/RoutinesView.tsx +++ b/packages/desktop/src/components/routines/RoutinesView.tsx @@ -1,7 +1,9 @@ import type { RoutineRunRecord, RoutineSession } from '@anton/protocol' import { useCallback, useEffect, useRef, useState } from 'react' +import { useStore } from '../../lib/store.js' import { connectionStore } from '../../lib/store/connectionStore.js' import { projectStore } from '../../lib/store/projectStore.js' +import { sessionStore } from '../../lib/store/sessionStore.js' import { uiStore } from '../../lib/store/uiStore.js' import { WorkflowPipelineView } from '../workflows/WorkflowPipelineView.js' import { RoutineCreateForm, type RoutineDraft, type RoutineTemplate } from './RoutineCreateForm.js' @@ -116,9 +118,27 @@ export function RoutinesView() { }, []) const startCreate = useCallback(() => { - setCreating(true) - setEditingId(null) - setSelectedRoutineId(null) + const ps = projectStore.getState() + const projectId = ps.projects.find((p) => p.isDefault)?.id ?? ps.activeProjectId ?? undefined + const sessionId = `sess_${Date.now().toString(36)}` + + const store = useStore.getState() + const ss = sessionStore.getState() + + const convId = store.newConversation(undefined, sessionId, projectId) + ss.createSession(sessionId, { + provider: ss.currentProvider, + model: ss.currentModel, + projectId, + }) + + store.setDraftInput( + convId, + "I want to create a new routine. It should ", + [], + ) + store.switchConversation(convId) + uiStore.getState().setActiveView('home') }, []) const startEdit = useCallback((id: string) => { diff --git a/packages/desktop/src/components/settings/SettingsModal.tsx b/packages/desktop/src/components/settings/SettingsModal.tsx index a1f63add..d66dda8b 100644 --- a/packages/desktop/src/components/settings/SettingsModal.tsx +++ b/packages/desktop/src/components/settings/SettingsModal.tsx @@ -8,14 +8,23 @@ import { HelpCircle, LogOut, MoreHorizontal, - Pencil, PlayCircle, Search, X, } from 'lucide-react' -import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ProviderInfo } from '../../lib/store.js' +import { + ACCOUNT_COLORS, + accountColorValue, + accountStore, + avatarInitial, +} from '../../lib/store/accountStore.js' import { sessionStore } from '../../lib/store/sessionStore.js' import { uiStore } from '../../lib/store/uiStore.js' +import { HarnessSetupModal } from '../chat/HarnessSetupModal.js' +import { ModelPopover } from '../chat/ModelSelector.js' +import { ProviderSettingsModal } from '../chat/ProviderSettingsModal.js' import { formatModelName, providerIcons } from '../chat/model-utils.js' // ── Types ───────────────────────────────────────────────────────── @@ -254,28 +263,85 @@ function GeneralSection({ - -
-
A
-
-
Anton user
-
Signed in locally
-
- -
+ + - - ) } +function ProfileEditor() { + const displayName = accountStore((s) => s.displayName) + const avatarColor = accountStore((s) => s.avatarColor) + const setDisplayName = accountStore((s) => s.setDisplayName) + const setAvatarColor = accountStore((s) => s.setAvatarColor) + const reset = accountStore((s) => s.reset) + const [draft, setDraft] = useState(displayName) + + useEffect(() => { + setDraft(displayName) + }, [displayName]) + + const commit = () => { + const trimmed = draft.trim() + if (!trimmed || trimmed === displayName) return + setDisplayName(trimmed) + } + + return ( +
+
+ {avatarInitial(draft || displayName)} +
+
+ setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur() + if (e.key === 'Escape') setDraft(displayName) + }} + placeholder="Anton" + maxLength={40} + aria-label="Display name" + /> +
+ {ACCOUNT_COLORS.map((c) => ( +
+
+ +
+ ) +} + function StartBehaviorRows() { const [restore, setRestore] = useState(() => localStorage.getItem('anton-restore') !== 'false') const [resume, setResume] = useState(() => localStorage.getItem('anton-resume') !== 'false') @@ -693,15 +759,95 @@ function ProviderMark({ provider, size = 18 }: { provider: string; size?: number return {provider.charAt(0).toUpperCase()} } +function ProviderRow({ + provider, + onOpen, +}: { + provider: ProviderInfo + onOpen: (p: ProviderInfo) => void +}) { + const isHarness = provider.type === 'harness' + const connected = provider.hasApiKey || provider.installed === true + const meta = isHarness + ? connected + ? 'CLI installed' + : 'Install to connect' + : connected + ? 'API key configured' + : 'Not connected' + return ( +
+
+ +
+
+
{provider.name}
+
{meta}
+
+ {connected ? ( + + Connected + + ) : ( + + )} + +
+ ) +} + function ModelsSection({ onOpenUsage }: { onOpenUsage?: () => void }) { const providers = sessionStore((s) => s.providers) const currentProvider = sessionStore((s) => s.currentProvider) const currentModel = sessionStore((s) => s.currentModel) const sendProvidersList = sessionStore((s) => s.sendProvidersList) + const sendDetectHarnesses = sessionStore((s) => s.sendDetectHarnesses) + const sendProviderSetDefault = sessionStore((s) => s.sendProviderSetDefault) + const setCurrentSession = sessionStore((s) => s.setCurrentSession) + const currentSessionId = sessionStore((s) => s.currentSessionId) + + const [pickerOpen, setPickerOpen] = useState(false) + const [apiProvider, setApiProvider] = useState(null) + const [harnessProvider, setHarnessProvider] = useState(null) + const changeBtnRef = useRef(null) useEffect(() => { sendProvidersList() - }, [sendProvidersList]) + sendDetectHarnesses() + }, [sendProvidersList, sendDetectHarnesses]) + + const { harnesses, apis } = useMemo(() => { + const h: ProviderInfo[] = [] + const a: ProviderInfo[] = [] + for (const p of providers) { + if (p.type === 'harness') h.push(p) + else a.push(p) + } + return { harnesses: h, apis: a } + }, [providers]) + + const openProvider = useCallback((p: ProviderInfo) => { + if (p.type === 'harness') setHarnessProvider(p) + else setApiProvider(p) + }, []) + + const handleSelectModel = useCallback( + (provider: string, model: string) => { + setCurrentSession(currentSessionId || '', provider, model) + sendProviderSetDefault(provider, model) + setPickerOpen(false) + }, + [currentSessionId, sendProviderSetDefault, setCurrentSession], + ) return ( <> @@ -715,63 +861,33 @@ function ModelsSection({ onOpenUsage }: { onOpenUsage?: () => void }) {
{currentProvider}
- - {providers.length === 0 ? ( - + + {harnesses.length === 0 ? ( + Empty ) : ( - providers.map((p) => { - const connected = p.hasApiKey || p.installed === true - const meta = - p.type === 'harness' - ? connected - ? 'CLI installed' - : 'Install to connect' - : connected - ? 'API key configured' - : 'Not connected' - return ( -
-
- -
-
-
{p.name}
-
{meta}
-
- {connected ? ( - - Connected - - ) : ( - - )} - -
- ) - }) + harnesses.map((p) => ) + )} +
+ + + {apis.length === 0 ? ( + + Empty + + ) : ( + apis.map((p) => ) )} @@ -782,6 +898,20 @@ function ModelsSection({ onOpenUsage }: { onOpenUsage?: () => void }) {
+ + {pickerOpen && ( + setPickerOpen(false)} + onManage={() => setPickerOpen(false)} + /> + )} + setApiProvider(null)} /> + setHarnessProvider(null)} /> ) } @@ -1169,9 +1299,20 @@ export function SettingsModal({ ) })} -
-
Anton
-
preview
+
+ +
+
Anton
+
preview
+
diff --git a/packages/desktop/src/index.css b/packages/desktop/src/index.css index 9a8be92c..0cf55bd1 100644 --- a/packages/desktop/src/index.css +++ b/packages/desktop/src/index.css @@ -879,6 +879,10 @@ button { border-radius: 100px; cursor: pointer; transition: background 0.1s; + background: none; + border: none; + color: inherit; + font: inherit; } .sb-footer-user:hover { background: var(--bg-elev-2); @@ -6951,6 +6955,157 @@ button { padding-top: 12px; } +/* ── Harness setup: steps list ── */ + +.prov-steps { + display: flex; + flex-direction: column; + gap: 8px; +} +.prov-step { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + transition: border-color 0.12s ease, opacity 0.12s ease; +} +.prov-step__indicator { + flex-shrink: 0; + width: 24px; + height: 24px; + border-radius: 50%; + display: grid; + place-items: center; + font-size: 11px; + font-weight: 600; + border: 1px solid var(--border-strong); + color: var(--text-subtle); + background: rgba(var(--overlay), 0.04); +} +.prov-step--busy .prov-step__indicator { + color: var(--text); + border-color: var(--text-subtle); +} +.prov-step--done .prov-step__indicator { + color: var(--success); + border-color: rgba(34, 197, 94, 0.35); + background: rgba(34, 197, 94, 0.1); +} +.prov-step__body { + flex: 1; + min-width: 0; +} +.prov-step__title { + font-size: 13px; + font-weight: 500; + color: var(--text); + letter-spacing: -0.003em; +} +.prov-step__desc { + font-size: 12px; + color: var(--text-subtle); + margin-top: 2px; + line-height: 1.4; +} +.prov-step--done .prov-step__desc { + color: var(--text-muted); +} +.prov-step__action { + flex-shrink: 0; +} +.prov-step.is-disabled { + opacity: 0.5; + pointer-events: none; +} +.prov-step__code { + display: flex; + gap: 8px; + padding: 2px 0 0 36px; +} + +/* ── Harness setup: connected summary ── */ + +.prov-connected { + display: flex; + align-items: center; + gap: 14px; + padding: 16px 18px; + border: 1px solid rgba(34, 197, 94, 0.2); + border-radius: 12px; + background: linear-gradient( + 135deg, + rgba(34, 197, 94, 0.06), + rgba(34, 197, 94, 0.015) 60%, + transparent + ); +} +.prov-connected__check { + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 50%; + display: grid; + place-items: center; + color: var(--success); + background: rgba(34, 197, 94, 0.14); + border: 1px solid rgba(34, 197, 94, 0.3); +} +.prov-connected__body { + flex: 1; + min-width: 0; +} +.prov-connected__title { + font-size: 14px; + font-weight: 600; + color: var(--text); + letter-spacing: -0.005em; +} +.prov-connected__meta { + font-size: 12.5px; + color: var(--text-muted); + margin-top: 3px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.prov-connected__meta strong { + color: var(--text); + font-weight: 500; +} +.prov-connected__chip { + font-size: 10.5px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--success); + padding: 2px 7px; + border-radius: 999px; + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.25); +} + +/* ── Harness setup: status/error line ── */ + +.prov-modal__status { + margin-top: 14px; + padding: 8px 12px; + border-radius: 8px; + font-size: 12px; + color: var(--text-muted); + background: rgba(var(--overlay), 0.04); + border: 1px solid var(--border); + line-height: 1.45; +} +.prov-modal__status.is-error { + color: var(--error, #ef4444); + background: rgba(239, 68, 68, 0.06); + border-color: rgba(239, 68, 68, 0.25); +} + .prov-modal__save-btn { width: 100%; padding: 10px; @@ -19990,6 +20145,10 @@ button { border-color: var(--accent-line); } .mem-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; padding: 60px 20px; text-align: center; color: var(--text-4); @@ -19998,7 +20157,7 @@ button { border-radius: 10px; } .mem-empty svg { - margin-bottom: 10px; + flex-shrink: 0; color: var(--text-4); } @@ -20315,7 +20474,7 @@ button { font-weight: 500; margin-bottom: 8px; letter-spacing: 0.02em; - display: inline-flex; + display: flex; align-items: center; gap: 5px; text-transform: uppercase; @@ -20619,6 +20778,52 @@ button { filter: brightness(1.1); } +/* Base button */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 28px; + padding: 0 12px; + border-radius: 6px; + border: 1px solid var(--border); + background: transparent; + color: var(--text-2); + font-family: inherit; + font-size: 12px; + font-weight: 500; + line-height: 1; + white-space: nowrap; + cursor: pointer; + transition: background 0.1s, color 0.1s, border-color 0.1s, opacity 0.1s; +} +.btn:hover { + border-color: var(--border-strong); + color: var(--text); + background: var(--bg-elev-1); +} +.btn:disabled { + opacity: 0.4; + cursor: default; +} +.btn--primary { + background: var(--accent); + color: var(--accent-on); + border-color: var(--accent); +} +.btn--primary:hover { + background: color-mix(in srgb, var(--accent) 88%, white); + border-color: color-mix(in srgb, var(--accent) 88%, white); + color: var(--accent-on); +} +.btn--primary:disabled { + background: var(--bg-elev-2); + color: var(--text-4); + border-color: var(--border); + filter: none; +} + /* Icon button variant */ .btn--icon { display: inline-flex; @@ -20626,6 +20831,8 @@ button { justify-content: center; width: 28px; height: 28px; + padding: 0; + gap: 0; background: transparent; border: 1px solid var(--border); border-radius: 6px; @@ -24831,8 +25038,37 @@ button { .sm-rail__item.on svg { color: var(--accent); } -.sm-rail__meta { +.sm-rail__footer { margin-top: auto; + display: flex; + flex-direction: column; + gap: 10px; +} +.sm-rail__disconnect { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: transparent; + border: 1px solid var(--border); + border-radius: 6px; + color: color-mix(in oklch, oklch(0.72 0.15 25) 75%, var(--text-2)); + font-family: inherit; + font-size: 12.5px; + font-weight: 500; + cursor: pointer; + text-align: left; + transition: background .12s, border-color .12s, color .12s; +} +.sm-rail__disconnect span { + flex: 1; +} +.sm-rail__disconnect:hover { + color: oklch(0.72 0.15 25); + background: color-mix(in oklch, oklch(0.72 0.15 25) 10%, transparent); + border-color: color-mix(in oklch, oklch(0.72 0.15 25) 40%, transparent); +} +.sm-rail__meta { padding: 10px 10px 0; border-top: 1px solid var(--border); display: flex; @@ -25158,6 +25394,10 @@ button { background: color-mix(in oklch, oklch(0.72 0.15 25) 12%, transparent); border-color: color-mix(in oklch, oklch(0.72 0.15 25) 35%, transparent); } +.sm-btn--inline { + align-self: flex-start; + padding: 7px 14px; +} /* Tags */ .stag { @@ -25222,6 +25462,41 @@ button { margin-top: 1px; font-family: "JetBrains Mono", monospace; } +.sprofile__input { + font-size: 13.5px; + font-weight: 500; + color: var(--text); + background: transparent; + border: none; + padding: 2px 0; + outline: none; + width: 100%; + border-bottom: 1px solid transparent; + transition: border-color 0.1s; +} +.sprofile__input:focus { + border-bottom-color: var(--border); +} +.sprofile__colors { + display: flex; + gap: 6px; + margin-top: 8px; +} +.sprofile__color { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + padding: 0; + transition: transform 0.1s; +} +.sprofile__color:hover { + transform: scale(1.1); +} +.sprofile__color.is-active { + border-color: var(--text); +} /* Appearance cards */ .sm-appearance-grid { diff --git a/packages/desktop/src/lib/store/accountStore.ts b/packages/desktop/src/lib/store/accountStore.ts new file mode 100644 index 00000000..b2fe484a --- /dev/null +++ b/packages/desktop/src/lib/store/accountStore.ts @@ -0,0 +1,67 @@ +/** + * Account domain store — local-only profile (display name + avatar color). + * No auth, no sync. Persisted to localStorage on this machine. + */ + +import { create } from 'zustand' + +export const ACCOUNT_COLORS = [ + { id: 'blue', value: '#6C8CFF' }, + { id: 'green', value: '#4ECB71' }, + { id: 'purple', value: '#B57EFF' }, + { id: 'orange', value: '#FF8A4C' }, + { id: 'pink', value: '#FF6F91' }, + { id: 'teal', value: '#3CC8B4' }, +] as const + +export type AccountColorId = (typeof ACCOUNT_COLORS)[number]['id'] + +const DEFAULT_NAME = 'Anton' +const DEFAULT_COLOR: AccountColorId = 'blue' + +interface AccountState { + displayName: string + avatarColor: AccountColorId + setDisplayName: (name: string) => void + setAvatarColor: (color: AccountColorId) => void + reset: () => void +} + +function loadName(): string { + const saved = localStorage.getItem('anton-account.name') + return saved && saved.trim() ? saved : DEFAULT_NAME +} + +function loadColor(): AccountColorId { + const saved = localStorage.getItem('anton-account.color') as AccountColorId | null + if (saved && ACCOUNT_COLORS.some((c) => c.id === saved)) return saved + return DEFAULT_COLOR +} + +export const accountStore = create((set) => ({ + displayName: loadName(), + avatarColor: loadColor(), + setDisplayName: (name) => { + const trimmed = name.trim() || DEFAULT_NAME + localStorage.setItem('anton-account.name', trimmed) + set({ displayName: trimmed }) + }, + setAvatarColor: (color) => { + localStorage.setItem('anton-account.color', color) + set({ avatarColor: color }) + }, + reset: () => { + localStorage.removeItem('anton-account.name') + localStorage.removeItem('anton-account.color') + set({ displayName: DEFAULT_NAME, avatarColor: DEFAULT_COLOR }) + }, +})) + +export function avatarInitial(name: string): string { + const trimmed = name.trim() + return trimmed ? trimmed.charAt(0).toUpperCase() : 'A' +} + +export function accountColorValue(id: AccountColorId): string { + return ACCOUNT_COLORS.find((c) => c.id === id)?.value ?? ACCOUNT_COLORS[0].value +}