diff --git a/.github/workflows/publish-agor-live.yml b/.github/workflows/publish-agor-live.yml new file mode 100644 index 000000000..5439c1024 --- /dev/null +++ b/.github/workflows/publish-agor-live.yml @@ -0,0 +1,66 @@ +name: Publish agor-live (manual) + +on: + workflow_dispatch: + inputs: + ref: + description: 'Git ref to publish (branch, tag, or SHA)' + required: false + default: 'main' + dry_run: + description: 'Run npm publish with --dry-run instead of publishing' + required: false + default: false + type: boolean + +concurrency: + group: publish-agor-live + cancel-in-progress: false + +jobs: + publish: + name: Build and publish agor-live + runs-on: self-hosted + permissions: + contents: read + + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.event.inputs.ref || github.ref }} + + - name: Validate NPM token is configured + shell: bash + run: | + set -euo pipefail + if [ -z "${NPM_TOKEN:-}" ]; then + echo "NPM_TOKEN secret is not configured for this repository/environment." + exit 1 + fi + + - name: Publish with flake wrapper (dry-run) + if: ${{ github.event.inputs.dry_run == 'true' }} + shell: bash + run: | + set -euo pipefail + nix run .#build-agor-live + cd packages/agor-live + TMP_NPMRC="$(mktemp)" + trap 'rm -f "$TMP_NPMRC"' EXIT + cat > "$TMP_NPMRC" <(); + + const isInitializeRequest = (body: unknown): boolean => { + if (!body || typeof body !== 'object') return false; + const maybeRequest = body as { method?: unknown }; + return maybeRequest.method === 'initialize'; + }; + const handler = async (req: Request, res: Response) => { try { console.log(`🔌 Incoming MCP request: ${req.method} /mcp`); - // Extract session token from query params or Authorization header - let sessionToken = req.query.sessionToken as string | undefined; - if (!sessionToken) { + // Extract credentials from query params or headers. + // Supports: + // - sessionToken query param (existing behavior) + // - Authorization: Bearer + // - X-API-Key: agor_sk_* + let credential = req.query.sessionToken as string | undefined; + if (!credential) { const authHeader = req.headers.authorization; if (authHeader?.startsWith('Bearer ')) { - sessionToken = authHeader.slice(7); + credential = authHeader.slice(7); + } + } + if (!credential) { + const xApiKey = req.headers['x-api-key']; + if (typeof xApiKey === 'string' && xApiKey.startsWith('agor_sk_')) { + credential = xApiKey; } } - if (!sessionToken) { - console.warn('⚠️ MCP request missing sessionToken'); + if (!credential) { + console.warn('⚠️ MCP request missing credentials'); return res.status(401).json({ jsonrpc: '2.0', id: (req.body as { id?: unknown })?.id, error: { code: -32001, message: - 'Authentication required: session token must be provided in query params or Authorization header', + 'Authentication required: provide sessionToken, Authorization Bearer token, or X-API-Key', }, }); } - // Validate token and extract context - const context = await validateSessionToken(app, sessionToken); - if (!context) { - console.warn('⚠️ Invalid MCP session token'); - return res.status(401).json({ - jsonrpc: '2.0', - id: (req.body as { id?: unknown })?.id, - error: { - code: -32001, - message: 'Invalid or expired session token', - }, - }); - } + // Support long-lived personal API keys for external orchestrators (Hermes, etc.). + let authenticatedUser: AuthenticatedUser; + let userId: UserID; + let sessionId: SessionID; + + if (credential.startsWith('agor_sk_')) { + const apiKeysRepo = new UserApiKeysRepository(db); + const keyRow = await apiKeysRepo.verifyKey(credential); + if (!keyRow) { + console.warn('⚠️ Invalid MCP API key'); + return res.status(401).json({ + jsonrpc: '2.0', + id: (req.body as { id?: unknown })?.id, + error: { + code: -32001, + message: 'Invalid API key', + }, + }); + } - console.log( - `🔌 MCP request authenticated (user: ${context.userId.substring(0, 8)}, session: ${context.sessionId.substring(0, 8)})` - ); + apiKeysRepo.updateLastUsed(keyRow.id).catch((err: unknown) => { + console.warn('⚠️ Failed to update MCP API key last_used_at:', err); + }); - // Fetch the authenticated user - let authenticatedUser: AuthenticatedUser; - try { - authenticatedUser = await app.service('users').get(context.userId); - } catch (error) { - if (error instanceof NotFoundError) { + userId = keyRow.user_id as UserID; + authenticatedUser = await app.service('users').get(userId); + + // Session context for tools like agor_sessions_get_current/agor_sessions_spawn. + // In API key mode, allow explicit session selection via query/header. + const requestedSessionId = + coerceString(req.query.sessionId as string | undefined) || + coerceString( + typeof req.headers['x-agor-session-id'] === 'string' + ? req.headers['x-agor-session-id'] + : undefined + ); + + sessionId = requestedSessionId as SessionID; + } else { + // Existing deterministic MCP session-token flow. + const context = await validateSessionToken(app, credential); + if (!context) { + console.warn('⚠️ Invalid MCP session token'); return res.status(401).json({ jsonrpc: '2.0', id: (req.body as { id?: unknown })?.id, @@ -459,9 +484,35 @@ export function setupMCPRoutes( }, }); } - throw error; + + userId = context.userId; + sessionId = context.sessionId; + + try { + authenticatedUser = await app.service('users').get(userId); + } catch (error) { + if (error instanceof NotFoundError) { + return res.status(401).json({ + jsonrpc: '2.0', + id: (req.body as { id?: unknown })?.id, + error: { + code: -32001, + message: 'Invalid or expired session token', + }, + }); + } + throw error; + } } + console.log( + `🔌 MCP request authenticated (user: ${userId.substring(0, 8)}, session: ${sessionId?.substring(0, 8) || 'none'})` + ); + + // Sessionless access is permitted for personal API keys. Tools that need + // a current session (e.g. agor_sessions_get_current, spawn) will surface + // their own error if called without ?sessionId=/X-Agor-Session-Id set. + const baseServiceParams: Pick = { user: { user_id: authenticatedUser.user_id, @@ -472,33 +523,110 @@ export function setupMCPRoutes( provider: 'mcp', }; - // Create a per-request McpServer with tools registered per service tier - const mcpServer = createMcpServer( - { - app, - db, - userId: context.userId, - sessionId: context.sessionId, - authenticatedUser, - baseServiceParams, - }, - toolSearchEnabled, - servicesConfig - ); + const mcpSessionId = coerceString(req.headers['mcp-session-id']); - // Create stateless transport (one per request, no session tracking) - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - }); + // ───────────────────────────────────────────────────────────────────────────── + // Stateful mode (streamable HTTP sessions): supports GET /mcp SSE + DELETE /mcp + // ───────────────────────────────────────────────────────────────────────────── + if (req.method === 'GET' || req.method === 'DELETE' || mcpSessionId) { + if (!mcpSessionId || !transports.has(mcpSessionId)) { + return res.status(400).json({ + jsonrpc: '2.0', + id: (req.body as { id?: unknown })?.id, + error: { + code: -32000, + message: 'Bad Request: Invalid or missing MCP session ID', + }, + }); + } + + const existing = transports.get(mcpSessionId)!; + if (existing.userId !== userId) { + return res.status(403).json({ + jsonrpc: '2.0', + id: (req.body as { id?: unknown })?.id, + error: { + code: -32003, + message: 'Forbidden: MCP session belongs to a different user', + }, + }); + } - // Connect and handle the request - await mcpServer.connect(transport); - await transport.handleRequest(req, res, req.body); + await existing.transport.handleRequest(req, res, req.body); + return; + } + + // Initialize a new stateful streamable HTTP session + if (req.method === 'POST' && isInitializeRequest(req.body)) { + const mcpServer = createMcpServer( + { + app, + db, + userId, + sessionId, + authenticatedUser, + baseServiceParams, + }, + toolSearchEnabled, + servicesConfig + ); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (newSessionId) => { + transports.set(newSessionId, { transport, server: mcpServer, userId }); + }, + }); + + transport.onclose = () => { + const sid = transport.sessionId; + if (sid) transports.delete(sid); + mcpServer.close().catch(() => {}); + }; + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } + + // ───────────────────────────────────────────────────────────────────────────── + // Stateless fallback mode: preserves legacy behavior for direct POST usage + // ───────────────────────────────────────────────────────────────────────────── + if (req.method === 'POST') { + const mcpServer = createMcpServer( + { + app, + db, + userId, + sessionId, + authenticatedUser, + baseServiceParams, + }, + toolSearchEnabled, + servicesConfig + ); - // Clean up after response is done - res.on('close', () => { - transport.close().catch(() => {}); - mcpServer.close().catch(() => {}); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + + res.on('close', () => { + transport.close().catch(() => {}); + mcpServer.close().catch(() => {}); + }); + return; + } + + return res.status(405).json({ + jsonrpc: '2.0', + id: (req.body as { id?: unknown })?.id, + error: { + code: -32005, + message: `Method ${req.method} not allowed on /mcp`, + }, }); } catch (error) { console.error('❌ MCP request failed:', error); @@ -514,6 +642,12 @@ export function setupMCPRoutes( // Register as Express POST route // @ts-expect-error - FeathersJS app extends Express app.post('/mcp', handler); + // GET supports SSE stream in streamable HTTP transport + // @ts-expect-error - FeathersJS app extends Express + app.get('/mcp', handler); + // DELETE supports streamable HTTP session termination + // @ts-expect-error - FeathersJS app extends Express + app.delete('/mcp', handler); - console.log('✅ MCP routes registered at POST /mcp'); + console.log('✅ MCP routes registered at /mcp (POST + GET + DELETE)'); } diff --git a/apps/agor-daemon/src/mcp/tool-registry.ts b/apps/agor-daemon/src/mcp/tool-registry.ts index ab26e85a1..ad43f568b 100644 --- a/apps/agor-daemon/src/mcp/tool-registry.ts +++ b/apps/agor-daemon/src/mcp/tool-registry.ts @@ -41,15 +41,16 @@ export interface SearchOptions { /** Domain descriptions for the domain listing. */ const DOMAIN_DESCRIPTIONS: Record = { - sessions: 'Agent conversations with genealogy (fork/spawn), task tracking, and message history', - repos: 'Repository registration and management', - worktrees: 'Git worktrees with isolated branches, board placement, and zone pinning', - environment: 'Start/stop/health/logs/nuke for worktree dev environments', - boards: 'Spatial canvases with zones for organizing worktrees and cards', - cards: 'Kanban-style cards and card type definitions on boards', - users: 'User accounts, profiles, preferences, and administration', - analytics: 'Usage and cost tracking leaderboard', - 'mcp-servers': 'External MCP server configuration and OAuth management', + sessions: 'Agent sessions, genealogy, prompts, tasks, messages', + repos: 'Repositories', + worktrees: 'Git worktrees, zones, assistants', + environment: 'Worktree dev environments', + boards: 'Spatial canvases', + cards: 'Kanban cards + types', + artifacts: 'Sandpack artifacts', + users: 'Users & admin', + analytics: 'Usage leaderboard', + 'mcp-servers': 'External MCP configs', }; /** Tools always visible in `tools/list` even when search mode is enabled. */ diff --git a/apps/agor-daemon/src/mcp/tools/artifacts.ts b/apps/agor-daemon/src/mcp/tools/artifacts.ts index 0efebafd7..8a8bcb914 100644 --- a/apps/agor-daemon/src/mcp/tools/artifacts.ts +++ b/apps/agor-daemon/src/mcp/tools/artifacts.ts @@ -18,40 +18,8 @@ export function registerArtifactTools(server: McpServer, ctx: McpContext): void server.registerTool( 'agor_artifacts_publish', { - description: `Publish a folder as a live Sandpack artifact on a board. Reads all files from the given folder path, serializes them to the database, and places (or updates) the artifact on the board. - -If artifact_id is omitted, creates a new artifact. -If artifact_id is provided, updates the existing artifact (must be owned by you). - -The folder should contain source files and optionally a sandpack.json manifest. The agent decides where to create the folder — inside the worktree, a temp directory, etc. The folder is only read at publish time; after that, the artifact lives in the database. - -Recommended: create the folder inside your worktree so files can be version-controlled. - -CONFIG CONVENTION (agor.config.js): -If you include a file named "/agor.config.js", it is treated as a Handlebars template and rendered per-user at view time. This lets artifacts access API credentials and Agor context without hardcoding secrets. - -Available template variables: - {{ user.env.VAR_NAME }} - User's environment variable (configured in Settings > Environment Variables) - {{ user.id }} - Current user's ID - {{ user.name }} - Current user's display name - {{ user.email }} - Current user's email - {{ agor.apiUrl }} - Agor daemon URL - {{ artifact.id }} - This artifact's ID - {{ artifact.boardId }} - Board ID - {{ board.id }} - Board ID (same as artifact.boardId) - {{ board.slug }} - Board slug (for URL construction) - -IMPORTANT: -- Use {{ user.env.X }} for secrets (API keys, tokens). NEVER hardcode sensitive values. -- All users can see the raw template file, but each user's rendered values are private. -- Rendered secrets are injected into the artifact JS at view time. Artifact code CAN access these values, so only use this for artifacts you trust. The security guarantee is that secrets never enter the LLM context or conversation history. -- Missing env vars render as empty string "". Your app should check for empty values and show a helpful message (e.g. "Please configure OPENAI_API_KEY in Settings > Environment Variables") instead of making API calls with empty credentials. - -Example /agor.config.js: - export const apiKey = "{{ user.env.OPENAI_API_KEY }}"; - export const apiUrl = "{{ agor.apiUrl }}"; - -Then in your app: import { apiKey, apiUrl } from '/agor.config.js';`, + description: + 'Publish a folder as a Sandpack artifact on a board. Creates new if artifactId is omitted, else updates (must own). Include /agor.config.js for per-user Handlebars-templated secrets — see docs.', inputSchema: z.object({ folderPath: z.string().describe('Absolute path to folder containing artifact files'), boardId: z.string().describe('Board to place the artifact on'), @@ -92,19 +60,7 @@ Then in your app: import { apiKey, apiUrl } from '/agor.config.js';`, .boolean() .optional() .describe( - `Use the daemon's self-hosted Sandpack bundler at /static/sandpack/ instead of the default CodeSandbox hosted bundler. Default: false. - -When to set true: -- Daemon is on a private network / VPN with no egress to codesandbox.io -- Air-gapped deployments, compliance constraints, or fully offline demos - -REQUIRES the daemon to have been built with \`./build.sh --with-sandpack\`. If the local bundler is not available, artifact creation fails with a clear error. - -KNOWN LIMITATIONS of the local bundler (upstream sandpack-bundler v2): -- CommonJS npm packages fail to resolve. Popular examples that break: recharts, lodash (use lodash-es instead), moment. Stick to ESM-only packages when this flag is true. -- Fewer features and slower updates than the hosted bundler. Upstream issues: https://github.com/codesandbox/sandpack-bundler - -When in doubt, leave unset — the hosted bundler supports the widest range of packages and is the recommended default.` + 'Use self-hosted bundler (requires --with-sandpack build). Default: false. Breaks CJS-only packages.' ), }), }, @@ -165,18 +121,8 @@ When in doubt, leave unset — the hosted bundler supports the widest range of p server.registerTool( 'agor_artifacts_status', { - description: `Get artifact build status, Sandpack bundler errors, and recent console logs from the browser runtime. Use this to debug rendering issues. - -build_status reflects both file validation AND Sandpack runtime state. If the Sandpack bundler reports an error (e.g. "Could not find module './data'"), build_status will be 'error' even if files were accepted. - -Fields: -- build_status: 'success' | 'error' | 'unknown' — reflects the worst of file validation and Sandpack runtime -- build_errors: array of error messages (includes Sandpack errors prefixed with [Sandpack]) -- sandpack_error: the raw Sandpack bundler/runtime error object (null if no error) -- sandpack_status: Sandpack bundler status ('idle', 'running', 'timeout', etc.) -- console_logs: console.log/warn/error output from the running app - -NOTE: sandpack_error and console_logs require a browser to be viewing the artifact. If no browser is connected, these fields will be empty/null.`, + description: + "Get artifact build status, Sandpack errors, and console logs. Live fields (sandpack_error, console_logs) require a browser viewing the artifact.", annotations: { readOnlyHint: true }, inputSchema: z.object({ artifactId: z.string().describe('Artifact ID'), diff --git a/apps/agor-daemon/src/mcp/tools/boards.ts b/apps/agor-daemon/src/mcp/tools/boards.ts index 611f1cc77..d19557303 100644 --- a/apps/agor-daemon/src/mcp/tools/boards.ts +++ b/apps/agor-daemon/src/mcp/tools/boards.ts @@ -70,7 +70,7 @@ export function registerBoardTools(server: McpServer, ctx: McpContext): void { }, async (args) => { const query: Record = {}; - if (args.limit) query.$limit = args.limit; + query.$limit = args.limit ?? 50; if (args.archived === true) { query.archived = true; } else if (!args.includeArchived) { diff --git a/apps/agor-daemon/src/mcp/tools/cards.ts b/apps/agor-daemon/src/mcp/tools/cards.ts index 71c38751a..b4babcad8 100644 --- a/apps/agor-daemon/src/mcp/tools/cards.ts +++ b/apps/agor-daemon/src/mcp/tools/cards.ts @@ -90,16 +90,17 @@ export function registerCardTools(server: McpServer, ctx: McpContext): void { 'agor_cards_list', { description: - 'List cards with optional filtering by board, card type, zone, search query, or archive status.', + 'List cards. Archived cards are excluded by default.', annotations: { readOnlyHint: true }, inputSchema: z.object({ boardId: z.string().optional().describe('Filter by board ID'), cardTypeId: z.string().optional().describe('Filter by card type ID'), zoneId: z.string().optional().describe('Filter by zone ID (requires boardId)'), search: z.string().optional().describe('Search query for card titles/descriptions'), - archived: z.boolean().optional().describe('Filter by archive status'), - limit: z.number().optional().describe('Maximum number of results (default: 50)'), - offset: z.number().optional().describe('Number of results to skip (default: 0)'), + includeArchived: z.boolean().optional().describe('Include archived (default: false)'), + archived: z.boolean().optional().describe('ONLY archived (overrides includeArchived)'), + limit: z.number().optional().describe('Default: 50'), + offset: z.number().optional().describe('Default: 0'), }), }, async (args) => { @@ -109,32 +110,48 @@ export function registerCardTools(server: McpServer, ctx: McpContext): void { const cardTypeId = coerceString(args.cardTypeId); const zoneId = coerceString(args.zoneId); const search = coerceString(args.search); - const archived = args.archived === true; + // archived semantics: true = only archived, false/undefined with includeArchived = both, otherwise only non-archived + const archivedFilter: boolean | undefined = + args.archived === true ? true : args.includeArchived ? undefined : false; const limit = typeof args.limit === 'number' ? args.limit : 50; const offset = typeof args.offset === 'number' ? args.offset : 0; let cardsList: Card[]; if (zoneId && boardId) { - cardsList = await cardsService.findByZoneId(boardId as never, zoneId); + const all = await cardsService.findByZoneId(boardId as never, zoneId); + const filtered = + archivedFilter === undefined + ? all + : all.filter((c) => Boolean(c.archived) === archivedFilter); + cardsList = filtered.slice(offset, offset + limit); } else if (search) { cardsList = await cardsService.searchCards(search, { boardId: boardId as never, - archived, + ...(archivedFilter !== undefined && { archived: archivedFilter }), limit, offset, }); } else if (cardTypeId) { - cardsList = await cardsService.findByCardTypeId(cardTypeId as never, { limit, offset }); + // Pull a larger window so post-filter can still fill `limit`. + const all = await cardsService.findByCardTypeId(cardTypeId as never, { + limit: limit + offset, + offset: 0, + }); + const filtered = + archivedFilter === undefined + ? all + : all.filter((c) => Boolean(c.archived) === archivedFilter); + cardsList = filtered.slice(offset, offset + limit); } else if (boardId) { cardsList = await cardsService.findByBoardId(boardId as never, { - archived, + ...(archivedFilter !== undefined && { archived: archivedFilter }), limit, offset, }); } else { - const result = await cardsService.find({ - query: { $limit: limit, $skip: offset }, - } as never); + const query: Record = { $limit: limit, $skip: offset }; + if (archivedFilter !== undefined) query.archived = archivedFilter; + const result = await cardsService.find({ query } as never); cardsList = 'data' in result ? result.data : result; } diff --git a/apps/agor-daemon/src/mcp/tools/messages.ts b/apps/agor-daemon/src/mcp/tools/messages.ts index 511c28a1f..7c336dbb7 100644 --- a/apps/agor-daemon/src/mcp/tools/messages.ts +++ b/apps/agor-daemon/src/mcp/tools/messages.ts @@ -25,7 +25,7 @@ export function registerMessageTools(server: McpServer, ctx: McpContext): void { 'agor_messages_list', { description: - 'Page through session conversation messages or search across sessions by keyword. When sessionId is provided, returns messages chronologically (like reading a transcript). When search is provided without sessionId, finds messages across all sessions. Tool calls are filtered out by default for cleaner output.', + 'Page through a session\'s messages or search across sessions. With sessionId: chronological transcript. Without sessionId: searches active sessions only (archived excluded unless includeArchived=true). Tool calls filtered by default.', annotations: { readOnlyHint: true }, inputSchema: z.object({ sessionId: z @@ -60,6 +60,12 @@ export function registerMessageTools(server: McpServer, ctx: McpContext): void { 'Sort order by message index. Default: "asc" when browsing a session, "desc" when searching.' ), role: z.enum(['user', 'assistant']).optional().describe('Filter by message role'), + includeArchived: z + .boolean() + .optional() + .describe( + 'Include messages from archived sessions (default: false). Ignored when sessionId/taskId is explicitly set.' + ), }), }, async (args) => { @@ -73,6 +79,7 @@ export function registerMessageTools(server: McpServer, ctx: McpContext): void { const sessionId = sessionIdRaw ? await resolveSessionId(ctx, sessionIdRaw) : undefined; const taskId = taskIdRaw ? await resolveTaskId(ctx, taskIdRaw) : undefined; + const includeArchived = args.includeArchived === true; const includeToolCalls = args.includeToolCalls === true; const contentMode = args.contentMode === 'full' ? 'full' : 'preview'; @@ -116,6 +123,22 @@ export function registerMessageTools(server: McpServer, ctx: McpContext): void { if (searchCondition) conditions.push(searchCondition); } + // Default-exclude messages from archived sessions for unscoped queries. + // An explicit sessionId/taskId (caller opted in) bypasses this filter. + if (!sessionId && !taskId && !includeArchived) { + const activeSessions = await ctx.app.service('sessions').find({ + query: { archived: false, $limit: 10000, $select: ['session_id'] }, + ...ctx.baseServiceParams, + }); + const activeIds = ( + Array.isArray(activeSessions) ? activeSessions : activeSessions.data + ).map((s: { session_id: string }) => s.session_id); + if (activeIds.length === 0) { + return textResult({ messages: [], total: 0, offset, limit }); + } + conditions.push(inArray(messagesTable.session_id, activeIds)); + } + // RBAC enforcement: when worktree_rbac is enabled, restrict this search // to sessions the caller can access. Superadmins bypass. When RBAC is // disabled (default / open-access mode), skip this filter entirely to diff --git a/apps/agor-daemon/src/mcp/tools/repos.ts b/apps/agor-daemon/src/mcp/tools/repos.ts index 95792dd7f..3082ed2fd 100644 --- a/apps/agor-daemon/src/mcp/tools/repos.ts +++ b/apps/agor-daemon/src/mcp/tools/repos.ts @@ -18,9 +18,8 @@ export function registerRepoTools(server: McpServer, ctx: McpContext): void { }), }, async (args) => { - const query: Record = {}; + const query: Record = { $limit: args.limit ?? 50 }; if (args.slug) query.slug = args.slug; - if (args.limit) query.$limit = args.limit; const repos = await ctx.app.service('repos').find({ query, ...ctx.baseServiceParams }); return textResult(repos); } diff --git a/apps/agor-daemon/src/mcp/tools/search.ts b/apps/agor-daemon/src/mcp/tools/search.ts index c416c6b19..426dcd1ad 100644 --- a/apps/agor-daemon/src/mcp/tools/search.ts +++ b/apps/agor-daemon/src/mcp/tools/search.ts @@ -77,34 +77,21 @@ export function registerSearchTools(server: McpServer, registry: ToolRegistry): 'agor_search_tools', { description: - 'Search and browse available Agor MCP tools. Call with no args to see domains overview. Filter by domain, keyword, or annotation. Use detail="full" to get input schemas before calling agor_execute_tool.', + 'Browse/search Agor MCP tools. No args = domains overview. Use detail:"full" to get inputSchema.', inputSchema: z.object({ - query: z - .string() - .optional() - .describe( - 'Search keywords (e.g. "worktree create", "cards", "environment"). Omit to browse by domain.' - ), - domain: z - .string() - .optional() - .describe( - 'Filter by domain (e.g. "sessions", "worktrees", "boards", "cards", "environment")' - ), + query: z.string().optional().describe('Keywords (omit to browse by domain)'), + domain: z.string().optional().describe('Filter by domain'), detail: z .enum(['list', 'full']) .optional() - .describe( - 'Detail level: "list" returns name+description (default), "full" includes inputSchema and annotations' - ), - read_only: z.boolean().optional().describe('Filter to read-only tools only'), - destructive: z.boolean().optional().describe('Filter to destructive tools only'), - max_results: z.number().optional().describe('Max results to return (default: 10)'), + .describe('"list" (default) = name+description; "full" = includes inputSchema'), + read_only: z.boolean().optional().describe('Filter read-only tools'), + destructive: z.boolean().optional().describe('Filter destructive tools'), + max_results: z.number().optional().describe('Default: 10'), }), annotations: { readOnlyHint: true }, }, async (args) => { - const domains = registry.listDomains(); const detail = args.detail ?? 'list'; // No query and no domain filter — return domains overview only @@ -115,9 +102,8 @@ export function registerSearchTools(server: McpServer, registry: ToolRegistry): args.destructive === undefined ) { return textResult({ - total_available: registry.size, - domains, - hint: 'Use domain or query params to discover specific tools. Use detail="full" to get input schemas.', + total: registry.size, + domains: registry.listDomains(), }); } @@ -131,9 +117,7 @@ export function registerSearchTools(server: McpServer, registry: ToolRegistry): const tools = detail === 'full' ? results : ToolRegistry.toSummaries(results); return textResult({ - total_available: registry.size, - domains, - results_count: results.length, + count: results.length, tools, }); } @@ -142,11 +126,10 @@ export function registerSearchTools(server: McpServer, registry: ToolRegistry): server.registerTool( 'agor_execute_tool', { - description: - 'Execute an Agor MCP tool by name. Use agor_search_tools first to discover available tools and their input schemas, then call this to invoke them.', + description: 'Invoke an Agor MCP tool by name. Discover tools via agor_search_tools first.', inputSchema: z .object({ - tool_name: z.string().describe('The tool name to execute (e.g. "agor_worktrees_list")'), + tool_name: z.string().describe('Tool name (e.g. "agor_worktrees_list")'), arguments: z .preprocess( // Some MCP clients double-serialize nested objects as JSON strings. @@ -155,7 +138,7 @@ export function registerSearchTools(server: McpServer, registry: ToolRegistry): z.record(z.string(), z.unknown()) ) .optional() - .describe('Arguments to pass to the tool, matching its input schema'), + .describe('Arguments matching the tool\'s inputSchema'), }) .passthrough(), }, diff --git a/apps/agor-daemon/src/mcp/tools/sessions.ts b/apps/agor-daemon/src/mcp/tools/sessions.ts index 38ea76b97..bbb3c7375 100644 --- a/apps/agor-daemon/src/mcp/tools/sessions.ts +++ b/apps/agor-daemon/src/mcp/tools/sessions.ts @@ -27,43 +27,30 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { server.registerTool( 'agor_sessions_list', { - description: - 'List all sessions accessible to the current user. Each session includes a `url` field with a clickable link to view the session in the UI.', + description: 'List sessions accessible to the user. Each result includes a `url` field.', annotations: { readOnlyHint: true }, inputSchema: z.object({ - limit: z.number().optional().describe('Maximum number of sessions to return (default: 50)'), + limit: z.number().optional().describe('Default: 50'), status: z .enum(['idle', 'running', 'completed', 'failed']) .optional() - .describe('Filter by session status'), - boardId: z.string().optional().describe('Filter sessions by board ID (UUIDv7 or short ID)'), - worktreeId: z.string().optional().describe('Filter sessions by worktree ID'), - includeArchived: z - .boolean() - .optional() - .describe( - 'Include archived sessions in results (default: false). By default, archived sessions are excluded.' - ), - archived: z - .boolean() - .optional() - .describe( - 'Filter to show ONLY archived sessions. When true, returns only archived sessions. Overrides includeArchived.' - ), + .describe('Filter by status'), + boardId: z.string().optional().describe('Filter by board'), + worktreeId: z.string().optional().describe('Filter by worktree'), + includeArchived: z.boolean().optional().describe('Include archived (default: false)'), + archived: z.boolean().optional().describe('ONLY archived (overrides includeArchived)'), sessionType: z .enum(['gateway', 'scheduled', 'agent']) .optional() - .describe( - "Filter by session type. 'gateway' = sessions from messaging integrations (Slack, Discord, GitHub). 'scheduled' = sessions created by worktree schedules. 'agent' = manually created sessions (excludes gateway and scheduled)." - ), + .describe('Filter: gateway (messaging) | scheduled | agent (manual)'), }), }, async (args) => { const query: Record = {}; // When sessionType is set, skip service-level pagination (it runs before our filter) // and apply the requested limit ourselves after filtering. - const requestedLimit = args.limit; - if (!args.sessionType && requestedLimit) query.$limit = requestedLimit; + const requestedLimit = args.limit ?? 50; + if (!args.sessionType) query.$limit = requestedLimit; if (args.status) query.status = args.status; if (args.boardId) query.board_id = await resolveBoardId(ctx, args.boardId); if (args.worktreeId) query.worktree_id = await resolveWorktreeId(ctx, args.worktreeId); @@ -96,11 +83,10 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { server.registerTool( 'agor_sessions_get', { - description: - 'Get detailed information about a specific session, including genealogy and current state. The response includes a `url` field with a clickable link to view the session in the UI.', + description: 'Get a session with genealogy, state, and a `url` field.', annotations: { readOnlyHint: true }, inputSchema: z.object({ - sessionId: z.string().describe('Session ID (UUIDv7 or short ID like 01a1b2c3)'), + sessionId: z.string().describe('Session ID (UUIDv7 or short ID)'), }), }, async (args) => { @@ -121,7 +107,7 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { 'agor_sessions_get_current', { description: - 'Get information about the current session (the one making this MCP call). Returns session details plus denormalized worktree, repo, and board context — useful for introspection and getting IDs needed by other tools.', + 'Get the current session plus denormalized worktree/repo/board context. Useful for IDs needed by other tools.', annotations: { readOnlyHint: true }, inputSchema: z.object({}), }, @@ -200,15 +186,13 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { 'agor_sessions_get_current_context', { description: - 'Get a lean orientation snapshot for the current session in ONE call. Returns deduplicated context: session identity, user, git state, worktree (zone, issue/PR, notes, environment), board (with zones), repo (slug, default branch), genealogy, and sibling sessions. Every field appears exactly once. Use get_current or entity-specific tools for full details.', + 'Lean orientation snapshot for the current session: identity, user, git, worktree, board, repo, genealogy, siblings. Deduplicated.', annotations: { readOnlyHint: true }, inputSchema: z.object({ includeSiblings: z .boolean() .optional() - .describe( - 'Include other active sessions in the same worktree (default: true). Set false to reduce response size.' - ), + .describe('Include sibling sessions in same worktree (default: true)'), }), }, async (args) => { @@ -377,40 +361,29 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { 'agor_sessions_spawn', { description: - 'Spawn a child session (subsession) for delegating work to another agent. Inherits the current worktree and tracks parent-child genealogy. Use for subtasks like "run tests", "review this code", or "fix linting errors". Configuration is inherited from parent (same agent) or user defaults (different agent).', + 'Spawn a child session in the current worktree. Tracks parent-child genealogy. Config inherits from parent.', inputSchema: z.object({ - prompt: z.string().describe('The prompt/task for the subsession agent to execute'), - title: z - .string() - .optional() - .describe('Optional title for the session (defaults to first 100 chars of prompt)'), + prompt: z.string().describe('Prompt for the subsession'), + title: z.string().optional().describe('Defaults to first 100 chars of prompt'), agenticTool: z .enum(['claude-code', 'codex', 'gemini', 'opencode']) .optional() - .describe('Which agent to use for the subsession (defaults to same as parent)'), - enableCallback: z - .boolean() - .optional() - .describe('Enable callback to parent on completion (default: true)'), + .describe('Agent (default: parent)'), + enableCallback: z.boolean().optional().describe('Callback parent on completion (default: true)'), includeLastMessage: z .boolean() .optional() - .describe("Include child's final result in callback (default: true)"), + .describe('Include final result in callback (default: true)'), includeOriginalPrompt: z .boolean() .optional() - .describe('Include original spawn prompt in callback (default: false)'), - extraInstructions: z - .string() - .optional() - .describe('Extra instructions appended to spawn prompt'), - taskId: z.string().optional().describe('Optional task ID to link the spawned session to'), + .describe('Include spawn prompt in callback (default: false)'), + extraInstructions: z.string().optional().describe('Appended to spawn prompt'), + taskId: z.string().optional().describe('Link to task'), mcpServerIds: z .array(z.string()) .optional() - .describe( - 'MCP server IDs to attach. Overrides parent session inheritance. Omit to inherit from parent. Pass empty array for no MCPs.' - ), + .describe('MCP server IDs. Omit to inherit, [] for none.'), }), }, async (args) => { @@ -456,29 +429,23 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { 'agor_sessions_prompt', { description: - 'Prompt an existing session to continue work. Supports four modes: continue (append to conversation), fork (branch at decision point), subsession (delegate to child agent), or btw (ephemeral fork — ask a side question without disrupting the target session, even if running). Configuration is inherited from parent session or user defaults.', + 'Prompt a session. Modes: continue (append), fork (sibling), subsession (child), btw (ephemeral fork; auto-callback + auto-archive).', inputSchema: z.object({ - sessionId: z.string().describe('Session ID to prompt (UUIDv7 or short ID)'), - prompt: z.string().describe('The prompt/task to execute'), + sessionId: z.string().describe('Target session ID'), + prompt: z.string().describe('Prompt to execute'), mode: z .enum(['continue', 'fork', 'subsession', 'btw']) - .describe( - 'How to route the work: continue (add to existing session), fork (create sibling session), subsession (create child session), btw (ephemeral fork — works even on running sessions, auto-callbacks result to caller, auto-archives when done)' - ), + .describe('continue | fork | subsession | btw'), agenticTool: z .enum(['claude-code', 'codex', 'gemini']) .optional() - .describe( - 'Agent for subsession (subsession mode only, defaults to parent agent). Fork mode always uses parent agent.' - ), - title: z.string().optional().describe('Session title (for fork/subsession only)'), - taskId: z.string().optional().describe('Fork/spawn point task ID (optional)'), + .describe('subsession mode only; defaults to parent'), + title: z.string().optional().describe('For fork/subsession'), + taskId: z.string().optional().describe('Fork/spawn point task ID'), mcpServerIds: z .array(z.string()) .optional() - .describe( - 'MCP server IDs for subsession mode. Overrides parent inheritance. Omit to inherit from parent. Pass empty array for no MCPs.' - ), + .describe('Subsession MCP IDs. Omit to inherit, [] for none.'), }), }, async (args) => { @@ -610,56 +577,40 @@ export function registerSessionTools(server: McpServer, ctx: McpContext): void { 'agor_sessions_create', { description: - 'Create a new session in an existing worktree. Use for starting fresh work on a new task in the same codebase (e.g., new feature branch, separate investigation). Unlike spawn, this creates an independent session with no parent-child relationship. MCP servers are inherited from the worktree (if configured) or user defaults. Supports optional callbacks to notify the creating session when the new session completes.', + 'Create an independent session in a worktree (no parent-child link). MCPs inherit from worktree > user defaults.', inputSchema: z.object({ - worktreeId: z.string().describe('Worktree ID where the session will run (required)'), + worktreeId: z.string().describe('Worktree ID (required)'), agenticTool: z .enum(['claude-code', 'codex', 'gemini']) - .describe('Which agent to use for this session (required)'), - title: z.string().optional().describe('Session title (optional)'), - description: z.string().optional().describe('Session description (optional)'), - contextFiles: z - .array(z.string()) - .optional() - .describe('Context file paths to load (optional)'), - initialPrompt: z - .string() - .optional() - .describe('Initial prompt to execute immediately after creating the session (optional)'), + .describe('Agent (required)'), + title: z.string().optional(), + description: z.string().optional(), + contextFiles: z.array(z.string()).optional().describe('Context file paths'), + initialPrompt: z.string().optional().describe('Prompt to execute immediately'), enableCallback: z .boolean() .optional() - .describe( - 'Enable callback to the creating session when the new session completes (default: false). When true, the creating session will receive a completion notification.' - ), + .describe('Notify creator on completion (default: false)'), callbackSessionId: z .string() .optional() - .describe( - 'Session ID to notify on completion (defaults to the current/creating session when enableCallback is true)' - ), + .describe('Session to notify (default: creating session)'), includeLastMessage: z .boolean() .optional() - .describe( - "Include the new session's final result in the callback message (default: true)" - ), + .describe('Include final result in callback (default: true)'), includeOriginalPrompt: z .boolean() .optional() - .describe('Include the original prompt in the callback message (default: false)'), + .describe('Include original prompt in callback (default: false)'), callbackMode: z .enum(['once', 'persistent']) .optional() - .describe( - 'Callback firing mode: "once" (default) fires on first completion then auto-disables, "persistent" fires on every completion' - ), + .describe('"once" (default) or "persistent"'), mcpServerIds: z .array(z.string()) .optional() - .describe( - 'MCP server IDs to attach. Overrides worktree and user default inheritance. Omit to use worktree config > user defaults.' - ), + .describe('MCP IDs. Omit to inherit.'), }), }, async (args) => { diff --git a/apps/agor-daemon/src/mcp/tools/tasks.ts b/apps/agor-daemon/src/mcp/tools/tasks.ts index 98fa76190..247b01b44 100644 --- a/apps/agor-daemon/src/mcp/tools/tasks.ts +++ b/apps/agor-daemon/src/mcp/tools/tasks.ts @@ -9,17 +9,35 @@ export function registerTaskTools(server: McpServer, ctx: McpContext): void { server.registerTool( 'agor_tasks_list', { - description: 'List tasks (user prompts) in a session', + description: + 'List tasks (user prompts) in a session. Tasks from archived sessions are excluded unless sessionId is passed explicitly.', annotations: { readOnlyHint: true }, inputSchema: z.object({ - sessionId: z.string().optional().describe('Session ID to get tasks from'), - limit: z.number().optional().describe('Maximum number of results (default: 50)'), + sessionId: z.string().optional().describe('Session ID to scope to'), + limit: z.number().optional().describe('Default: 50'), + includeArchived: z + .boolean() + .optional() + .describe('Include tasks from archived sessions (default: false)'), }), }, async (args) => { - const query: Record = {}; - if (args.sessionId) query.session_id = await resolveSessionId(ctx, args.sessionId); - if (args.limit) query.$limit = args.limit; + const query: Record = { $limit: args.limit ?? 50 }; + if (args.sessionId) { + // Explicit sessionId = caller opted in, even if archived + query.session_id = await resolveSessionId(ctx, args.sessionId); + } else if (!args.includeArchived) { + // Unscoped listing: exclude tasks whose parent session is archived + const sessionsResult = await ctx.app.service('sessions').find({ + query: { archived: false, $limit: 10000, $select: ['session_id'] }, + ...ctx.baseServiceParams, + }); + const ids = (Array.isArray(sessionsResult) ? sessionsResult : sessionsResult.data).map( + (s: { session_id: string }) => s.session_id + ); + if (ids.length === 0) return textResult({ total: 0, data: [] }); + query.session_id = { $in: ids }; + } const tasks = await ctx.app.service('tasks').find({ query, ...ctx.baseServiceParams }); return textResult(tasks); } diff --git a/apps/agor-daemon/src/mcp/tools/users.ts b/apps/agor-daemon/src/mcp/tools/users.ts index 237cc869c..543a1093f 100644 --- a/apps/agor-daemon/src/mcp/tools/users.ts +++ b/apps/agor-daemon/src/mcp/tools/users.ts @@ -16,8 +16,7 @@ export function registerUserTools(server: McpServer, ctx: McpContext): void { }), }, async (args) => { - const query: Record = {}; - if (args.limit) query.$limit = args.limit; + const query: Record = { $limit: args.limit ?? 50 }; const users = await ctx.app.service('users').find({ query, ...ctx.baseServiceParams }); return textResult(users); } diff --git a/apps/agor-daemon/src/mcp/tools/worktrees.ts b/apps/agor-daemon/src/mcp/tools/worktrees.ts index 3010290ff..262707cce 100644 --- a/apps/agor-daemon/src/mcp/tools/worktrees.ts +++ b/apps/agor-daemon/src/mcp/tools/worktrees.ts @@ -34,17 +34,20 @@ export function registerWorktreeTools(server: McpServer, ctx: McpContext): void server.registerTool( 'agor_worktrees_get', { - description: - 'Get detailed information about a worktree, including path, branch, and git state', + description: 'Get a worktree (path, branch, git state). Sessions omitted by default.', annotations: { readOnlyHint: true }, inputSchema: z.object({ worktreeId: z.string().describe('Worktree ID (UUIDv7 or short ID)'), + includeSessions: z + .boolean() + .optional() + .describe('Include sessions in response (default: false)'), }), }, async (args) => { const worktreeParams: WorktreeParams = { ...ctx.baseServiceParams, - _include_sessions: true, + _include_sessions: args.includeSessions === true, _last_message_truncation_length: 500, }; const worktree = await ctx.app @@ -80,7 +83,7 @@ export function registerWorktreeTools(server: McpServer, ctx: McpContext): void async (args) => { const query: Record = {}; if (args.repoId) query.repo_id = await resolveRepoId(ctx, args.repoId); - if (args.limit) query.$limit = args.limit; + query.$limit = args.limit ?? 50; if (args.archived === true) { query.archived = true; } else if (!args.includeArchived) { @@ -343,87 +346,46 @@ export function registerWorktreeTools(server: McpServer, ctx: McpContext): void 'agor_worktrees_update', { description: - 'Update metadata for an existing worktree (issue/PR URLs, notes, board placement, custom context, RBAC permissions, owners)', + 'Update worktree metadata (issue/PR URLs, notes, board, custom context, RBAC, owners).', annotations: { idempotentHint: true }, inputSchema: z.object({ worktreeId: z .string() .optional() - .describe( - 'Worktree ID to update. Optional when calling from a session with a bound worktree.' - ), + .describe('Optional when current session has a bound worktree'), issueUrl: z .string() .nullable() .optional() - .describe('Issue URL to associate. Pass null to clear. Must be http(s) when provided.'), + .describe('Issue URL (http(s)). null to clear.'), pullRequestUrl: z .string() .nullable() .optional() - .describe( - 'Pull request URL to associate. Pass null to clear. Must be http(s) when provided.' - ), - notes: z - .string() - .nullable() - .optional() - .describe( - 'Freeform notes about the worktree (markdown supported). Pass null or empty string to clear.' - ), - boardId: z - .string() - .nullable() - .optional() - .describe('Board ID to place this worktree on. Pass null to remove from any board.'), + .describe('PR URL (http(s)). null to clear.'), + notes: z.string().nullable().optional().describe('Markdown notes. null/"" to clear.'), + boardId: z.string().nullable().optional().describe('Board ID. null to unplace.'), customContext: z .record(z.string(), z.unknown()) .nullable() .optional() - .describe( - 'Custom context object for templates and automations. Pass null to clear existing context.' - ), + .describe('Template/automation context. null to clear.'), mcpServerIds: z .array(z.string()) .nullable() .optional() - .describe( - 'Default MCP server IDs for new sessions in this worktree. Sessions inherit these unless they explicitly specify their own. Pass null to clear.' - ), + .describe('Default MCP IDs for new sessions. null to clear.'), // RBAC fields (optional, safe to ignore for single-user setups) othersCan: z .enum(WORKTREE_PERMISSION_LEVELS) .optional() - .describe( - 'App-layer permission for non-owner users. ' + - '"none" = no access, "view" = read-only, "session" = can create & prompt own sessions, ' + - '"prompt" = can prompt ANY session (including other users\'), "all" = full access. ' + - 'Always effective regardless of Unix isolation mode. Single-user setups can ignore this.' - ), + .describe('Non-owner permission: none | view | session | prompt | all'), othersFsAccess: z .enum(['none', 'read', 'write']) .optional() - .describe( - 'OS-level filesystem permission for non-owner users. ' + - '"none" = no filesystem access, "read" = read-only, "write" = read-write. ' + - 'Only effective when Unix isolation (AGOR_UNIX_MODE) is configured. ' + - 'Has no effect in simple mode. Single-user setups can ignore this.' - ), - addOwnerIds: z - .array(z.string()) - .optional() - .describe( - 'User IDs to ADD as owners of this worktree. ' + - 'Owners have full access regardless of othersCan/othersFsAccess settings. ' + - 'Idempotent — adding an existing owner is a no-op.' - ), - removeOwnerIds: z - .array(z.string()) - .optional() - .describe( - 'User IDs to REMOVE as owners of this worktree. ' + - 'Idempotent — removing a non-owner is a no-op.' - ), + .describe('Non-owner filesystem: none | read | write (requires Unix isolation mode)'), + addOwnerIds: z.array(z.string()).optional().describe('User IDs to add as owners'), + removeOwnerIds: z.array(z.string()).optional().describe('User IDs to remove as owners'), }), }, async (args) => { diff --git a/apps/agor-daemon/src/register-routes.ts b/apps/agor-daemon/src/register-routes.ts index 69e278f7d..d8a0dfee1 100755 --- a/apps/agor-daemon/src/register-routes.ts +++ b/apps/agor-daemon/src/register-routes.ts @@ -1965,32 +1965,18 @@ export async function registerRoutes(ctx: RegisterRoutesContext): Promise async create(data: { name: string }, params: AuthenticatedParams) { return userApiKeysService.create(data, params); }, - }, - { - find: { role: ROLES.MEMBER, action: 'list API keys' }, - create: { role: ROLES.MEMBER, action: 'create API keys' }, - }, - requireAuth - ); - - registerAuthenticatedRoute( - app, - '/api/v1/user/api-keys/:id', - { - // biome-ignore lint/suspicious/noExplicitAny: Feathers service type - async patch(data: { name?: string }, params: any) { - const id = params.route?.id; + async patch(id: string, data: { name?: string }, params: AuthenticatedParams) { if (!id) throw new BadRequest('API key ID required'); return userApiKeysService.patch(id, data, params); }, - // biome-ignore lint/suspicious/noExplicitAny: Feathers service type - async remove(_id: unknown, params: any) { - const keyId = params.route?.id; - if (!keyId) throw new BadRequest('API key ID required'); - return userApiKeysService.remove(keyId, params); + async remove(id: string, params: AuthenticatedParams) { + if (!id) throw new BadRequest('API key ID required'); + return userApiKeysService.remove(id, params); }, }, { + find: { role: ROLES.MEMBER, action: 'list API keys' }, + create: { role: ROLES.MEMBER, action: 'create API keys' }, patch: { role: ROLES.MEMBER, action: 'update API keys' }, remove: { role: ROLES.MEMBER, action: 'delete API keys' }, }, diff --git a/apps/agor-ui/index.html b/apps/agor-ui/index.html index 6e2bcc6f1..dfa098023 100644 --- a/apps/agor-ui/index.html +++ b/apps/agor-ui/index.html @@ -3,19 +3,43 @@ - + + + + + + + + + + + + Agor diff --git a/apps/agor-ui/public/icons/agor-icon.svg b/apps/agor-ui/public/icons/agor-icon.svg new file mode 100644 index 000000000..a43e203ab --- /dev/null +++ b/apps/agor-ui/public/icons/agor-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/apps/agor-ui/public/manifest.webmanifest b/apps/agor-ui/public/manifest.webmanifest new file mode 100644 index 000000000..888775cad --- /dev/null +++ b/apps/agor-ui/public/manifest.webmanifest @@ -0,0 +1,34 @@ +{ + "id": "agor", + "name": "Agor", + "short_name": "Agor", + "description": "Multiplayer canvas for orchestrating Claude Code, Codex, and Gemini sessions.", + "start_url": "./", + "scope": "./", + "display": "standalone", + "display_override": ["standalone", "minimal-ui", "browser"], + "orientation": "any", + "background_color": "#0f1115", + "theme_color": "#1677ff", + "categories": ["developer", "productivity"], + "icons": [ + { + "src": "./icons/agor-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "./icons/agor-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "maskable" + }, + { + "src": "./favicon.png", + "sizes": "192x192 256x256 384x384 512x512", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/apps/agor-ui/public/sw.js b/apps/agor-ui/public/sw.js new file mode 100644 index 000000000..4a56baeac --- /dev/null +++ b/apps/agor-ui/public/sw.js @@ -0,0 +1,182 @@ +/** + * Agor service worker. + * + * The UI may be hosted at the origin root (`/`) or under a sub-path + * (typically `/ui/` when the daemon serves the production bundle). We + * derive the scope dynamically from `self.registration.scope` so the + * same SW works in both setups. + * + * Strategy: + * - Pre-cache the app shell (index.html + manifest + icon) so cold launches + * in standalone PWA mode never see a blank screen when offline. + * - Navigation requests use **network-first** with a cached-shell fallback, + * so users always pick up the latest deploy when online. + * - Static assets use stale-while-revalidate so they load instantly while + * a fresh copy is fetched in the background. + * - API and live data (sessions/tasks/messages/worktrees/auth) bypass the + * cache entirely so we never serve stale data. + * - WebSocket and EventSource traffic is never touched. + */ + +const SW_VERSION = 'agor-v3'; +const APP_SHELL_CACHE = `${SW_VERSION}-shell`; +const ASSET_CACHE = `${SW_VERSION}-assets`; + +// Derive the base path from the SW scope. When the SW is registered at +// `/ui/sw.js` the scope is `https://host/ui/`, so basePath becomes `/ui/`. +const scopeUrl = new URL(self.registration.scope); +const basePath = scopeUrl.pathname.endsWith('/') ? scopeUrl.pathname : `${scopeUrl.pathname}/`; + +const APP_SHELL_FILES = [ + basePath, + `${basePath}index.html`, + `${basePath}manifest.webmanifest`, + `${basePath}favicon.png`, + `${basePath}icons/agor-icon.svg`, +]; + +// API/data prefixes that must never be cached. +const NO_CACHE_PREFIXES = [ + 'sessions', + 'tasks', + 'messages', + 'worktrees', + 'boards', + 'board-comments', + 'repos', + 'users', + 'mcp-servers', + 'gateway-channels', + 'artifacts', + 'cards', + 'card-types', + 'authentication', + 'authentication-refresh', + 'config', + 'instance-config', + 'auth-config', + 'permissions', + 'reports', + 'logs', + 'health', + 'socket.io', + 'mcp', + 'static', +]; + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(APP_SHELL_CACHE).then((cache) => + // Best-effort: don't fail the entire install if a single asset is missing + Promise.all( + APP_SHELL_FILES.map((url) => + cache.add(new Request(url, { cache: 'reload' })).catch(() => undefined) + ) + ) + ) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches + .keys() + .then((keys) => + Promise.all( + keys.filter((key) => !key.startsWith(SW_VERSION)).map((key) => caches.delete(key)) + ) + ) + .then(() => self.clients.claim()) + ); +}); + +/** + * Detect API/data requests that should always go to the network. + */ +function isNoCacheRequest(url) { + if (url.origin !== self.location.origin) return true; + const pathname = url.pathname; + // strip basePath if present so rules apply uniformly in `/ui/` and `/` + const stripped = pathname.startsWith(basePath) + ? pathname.slice(basePath.length) + : pathname.slice(1); + const firstSegment = stripped.split('/')[0]; + if (!firstSegment) return false; + return NO_CACHE_PREFIXES.includes(firstSegment); +} + +self.addEventListener('fetch', (event) => { + const request = event.request; + if (request.method !== 'GET') return; + + let url; + try { + url = new URL(request.url); + } catch { + return; + } + + // Never touch WS upgrade or EventSource + if (request.headers.get('upgrade') === 'websocket') return; + if (request.headers.get('accept') === 'text/event-stream') return; + + // API/data: bypass cache entirely + if (isNoCacheRequest(url)) { + event.respondWith(fetch(request)); + return; + } + + // Navigation: network-first with cached shell fallback + if (request.mode === 'navigate') { + event.respondWith( + fetch(request) + .then((response) => { + // Cache a copy of the latest shell for offline fallback + if (response.ok) { + const copy = response.clone(); + caches + .open(APP_SHELL_CACHE) + .then((cache) => cache.put(`${basePath}index.html`, copy)) + .catch(() => undefined); + } + return response; + }) + .catch(() => + caches + .match(`${basePath}index.html`) + .then((cached) => cached || caches.match(basePath)) + .then((cached) => cached || Response.error()) + ) + ); + return; + } + + // Static assets: stale-while-revalidate + if (url.origin === self.location.origin) { + event.respondWith( + caches.match(request).then((cached) => { + const networkFetch = fetch(request) + .then((response) => { + if (response.ok) { + const copy = response.clone(); + caches + .open(ASSET_CACHE) + .then((cache) => cache.put(request, copy)) + .catch(() => undefined); + } + return response; + }) + .catch(() => cached || Response.error()); + return cached || networkFetch; + }) + ); + } +}); + +// Allow the page to trigger an immediate update when a new SW is waiting. +self.addEventListener('message', (event) => { + if (event.data === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); diff --git a/apps/agor-ui/src/App.tsx b/apps/agor-ui/src/App.tsx index cf96e399d..35b1ec834 100644 --- a/apps/agor-ui/src/App.tsx +++ b/apps/agor-ui/src/App.tsx @@ -23,8 +23,9 @@ import { AVAILABLE_AGENTS } from './components/AgentSelectionGrid'; import { App as AgorApp } from './components/App'; import { ForcePasswordChangeModal } from './components/ForcePasswordChangeModal'; import { LoginPage } from './components/LoginPage'; -import { MobileApp } from './components/mobile/MobileApp'; +import { MobileLegacyRedirect } from './components/mobile/MobileLegacyRedirect'; import { OnboardingWizard } from './components/OnboardingWizard'; +import { PWAShell } from './components/PWAShell'; import { SandboxBanner } from './components/SandboxBanner'; import type { WorktreeUpdate } from './components/WorktreeModal/tabs/GeneralTab'; import { ConnectionProvider } from './contexts/ConnectionContext'; @@ -39,54 +40,8 @@ import { useSessionActions, } from './hooks'; import { StreamdownDemoPage } from './pages/StreamdownDemoPage'; -import { isMobileDevice } from './utils/deviceDetection'; import { useThemedMessage } from './utils/message'; -/** - * DeviceRouter - Redirects users to mobile or desktop site based on device detection - * Responds to window resize events for responsive switching - */ -function DeviceRouter() { - const location = useLocation(); - const navigate = useNavigate(); - - useEffect(() => { - const checkAndRoute = () => { - const isMobile = isMobileDevice(); - const isOnMobilePath = location.pathname.startsWith('/m'); - - // Redirect mobile devices to mobile site - if (isMobile && !isOnMobilePath) { - navigate('/m', { replace: true }); - } - // Redirect desktop devices away from mobile site - else if (!isMobile && isOnMobilePath) { - navigate('/', { replace: true }); - } - }; - - // Check on mount and route change - checkAndRoute(); - - // Debounced resize handler to avoid excessive redirects - let resizeTimeout: NodeJS.Timeout; - const handleResize = () => { - clearTimeout(resizeTimeout); - resizeTimeout = setTimeout(checkAndRoute, 200); - }; - - // Listen for window resize events for responsive switching - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - clearTimeout(resizeTimeout); - }; - }, [location.pathname, navigate]); - - return null; -} - function AppContent() { const { token } = theme.useToken(); const { getCurrentThemeConfig } = useTheme(); @@ -178,9 +133,6 @@ function AppContent() { } }, [location.pathname, location.search]); - // Per-session prompt drafts (persists across session switches) - const [promptDrafts, setPromptDrafts] = useState>(new Map()); - // Track if we've successfully loaded data at least once // This prevents UI from unmounting during reconnections const [hasLoadedOnce, setHasLoadedOnce] = useState(false); @@ -506,35 +458,11 @@ function AppContent() { } }; - // Update draft for a specific session - const handleUpdateDraft = (sessionId: string, draft: string) => { - setPromptDrafts((prev) => { - const next = new Map(prev); - if (draft.trim()) { - next.set(sessionId, draft); - } else { - next.delete(sessionId); // Clean up empty drafts - } - return next; - }); - }; - - // Clear draft for a specific session - const handleClearDraft = (sessionId: string) => { - setPromptDrafts((prev) => { - const next = new Map(prev); - next.delete(sessionId); - return next; - }); - }; - // Handle fork session const handleForkSession = async (sessionId: string, prompt: string) => { const session = await forkSession(sessionId as SessionID, prompt); if (session) { showSuccess('Session forked successfully!'); - // Clear the draft after forking - handleClearDraft(sessionId); } else { showError('Failed to fork session'); } @@ -545,7 +473,6 @@ function AppContent() { const session = await btwForkSession(sessionId as SessionID, prompt); if (session) { showSuccess('Side question sent via btw fork'); - handleClearDraft(sessionId); } else { showError('Failed to create btw fork'); } @@ -558,8 +485,6 @@ function AppContent() { const session = await spawnSession(sessionId as SessionID, spawnConfig); if (session) { showSuccess('Subsession session spawned successfully!'); - // Clear the draft after spawning subsession - handleClearDraft(sessionId); } else { showError('Failed to spawn session'); } @@ -578,9 +503,6 @@ function AppContent() { permissionMode, messageSource: 'agor', }); - - // Clear the draft after sending - handleClearDraft(sessionId); } catch (error) { showError(`Failed to send prompt: ${error instanceof Error ? error.message : String(error)}`); console.error('Prompt error:', error); @@ -1139,6 +1061,89 @@ function AppContent() { setOpenNewWorktree(false); }; + // Single instance of AgorApp shared across all desktop/mobile routes. + // Mobile devices used to be redirected to a trimmed-down `/m` shell; we now + // render the full UI everywhere and let CSS/responsive components adapt. + const agorAppElement = ( + <> + + + + ); + // Render main app return ( @@ -1173,289 +1178,26 @@ function AppContent() { systemCredentials={onboardingConfig?.systemCredentials} /> - + {/* Demo route */} } /> - {/* Mobile routes */} + {/* Legacy mobile shell URLs — redirect to canonical board/session URLs. + Anything not matched falls through to the catch-all `/*` below. */} - } + element={} /> - {/* Desktop routes - board with session (Django-style trailing slash) */} - - - - - } - /> + {/* Board + session deep link */} + - {/* Desktop routes - board only (Django-style trailing slash) */} - - - - - } - /> + {/* Board only */} + - {/* Desktop routes - fallback for root path */} - - - - - } - /> + {/* Catch-all (root, etc.) */} + diff --git a/apps/agor-ui/src/components/App/App.tsx b/apps/agor-ui/src/components/App/App.tsx index 84fe0cb9b..4cecb50b8 100644 --- a/apps/agor-ui/src/components/App/App.tsx +++ b/apps/agor-ui/src/components/App/App.tsx @@ -20,7 +20,7 @@ import type { Worktree, } from '@agor-live/client'; import { hasMinimumRole, PermissionScope } from '@agor-live/client'; -import { Layout, Upload } from 'antd'; +import { Drawer, Layout, Upload } from 'antd'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type ImperativePanelHandle, @@ -34,6 +34,7 @@ import { AppDataProvider } from '../../contexts/AppDataContext'; import { useBoardTitle } from '../../hooks/useBoardTitle'; import { useEventStream } from '../../hooks/useEventStream'; import { useFaviconStatus } from '../../hooks/useFaviconStatus'; +import { useIsCompactViewport } from '../../hooks/useIsCompactViewport'; import { useLocalStorage } from '../../hooks/useLocalStorage'; import { usePresence } from '../../hooks/usePresence'; import { useRecentBoards } from '../../hooks/useRecentBoards'; @@ -49,6 +50,7 @@ import type { AssistantTabResult } from '../CreateDialog/tabs/AssistantTab'; import type { WorktreeTabConfig } from '../CreateDialog/tabs/WorktreeTab'; import { EnvironmentLogsModal } from '../EnvironmentLogsModal'; import { EventStreamPanel } from '../EventStreamPanel'; +import { MobileBoardView } from '../mobile/MobileBoardView'; import { NewSessionButton } from '../NewSessionButton'; import { type NewSessionConfig, NewSessionModal } from '../NewSessionModal'; import { SessionCanvas, type SessionCanvasRef } from '../SessionCanvas'; @@ -568,6 +570,10 @@ export const App: React.FC = ({ const sessionSettingsSession = sessionSettingsId ? sessionById.get(sessionSettingsId) : null; const currentBoard = boardById.get(currentBoardId); + // Compact viewport (phones, narrow tablets) get a list/drawer-based layout + // instead of the React Flow canvas which is hard to navigate on touch. + const isCompact = useIsCompactViewport(); + // Update browser tab title based on current board useBoardTitle(currentBoard); @@ -760,29 +766,63 @@ export const App: React.FC = ({ instanceDescription={instanceDescription} /> - { - // Save left panel size when user resizes (only when panel is open) - if (!commentsPanelCollapsed && sizes.length >= 2) { - // Comments panel is the first panel (index 0) - setCommentsPanelSize(sizes[0]); - } - }} - > - - {!commentsPanelCollapsed && ( + { + setNewWorktreeDefaultPosition(null); + setCreateDialogOpen(true); + }} + onOpenComments={() => setCommentsPanelCollapsed(false)} + onOpenWorktree={(worktreeId) => setWorktreeModalWorktreeId(worktreeId)} + onCreateSessionForWorktree={(worktreeId) => setNewSessionWorktreeId(worktreeId)} + /> + {/* Full-screen session drawer (replaces side panel on phones) */} + setSelectedSessionId(null)} + placement="right" + width="100%" + destroyOnClose={false} + styles={{ body: { padding: 0 }, header: { display: 'none' } }} + rootStyle={{ zIndex: 1050 }} + closable={false} + > + {effectiveSelectedSessionId && ( + setSelectedSessionId(null)} + /> + )} + + {/* Full-screen comments drawer */} + setCommentsPanelCollapsed(true)} + placement="left" + width="100%" + title="Comments" + styles={{ body: { padding: 0 } }} + rootStyle={{ zIndex: 1040 }} + > = ({ currentUserId={user?.user_id || 'anonymous'} boardObjects={currentBoard?.objects} worktreeById={worktreeById} - collapsed={commentsPanelCollapsed} - onToggleCollapse={() => setCommentsPanelCollapsed(!commentsPanelCollapsed)} + collapsed={false} + onToggleCollapse={() => setCommentsPanelCollapsed(true)} onSendComment={(content) => onSendComment?.(currentBoardId || '', content)} onReplyComment={onReplyComment} onResolveComment={onResolveComment} @@ -803,174 +843,249 @@ export const App: React.FC = ({ hoveredCommentId={hoveredCommentId} selectedCommentId={selectedCommentId} /> - )} - - { - if (!commentsPanelCollapsed) { - (e.currentTarget as unknown as HTMLDivElement).style.background = - 'var(--ant-color-primary)'; - } - }} - onMouseLeave={(e) => { - if (!commentsPanelCollapsed) { - (e.currentTarget as unknown as HTMLDivElement).style.background = - 'var(--ant-color-border-secondary)'; + + {/* Event stream drawer (toggled from header) */} + setEventStreamPanelCollapsed(true)} + placement="bottom" + height="80%" + title="Event stream" + styles={{ body: { padding: 0 } }} + rootStyle={{ zIndex: 1040 }} + > + setEventStreamPanelCollapsed(true)} + events={events} + onClear={clearEvents} + currentUserId={user?.user_id} + selectedSessionId={effectiveSelectedSessionId} + currentBoard={currentBoard} + client={client} + worktreeActions={{ + onSessionClick: handleSessionClick, + onCreateSession: (worktreeId) => setNewSessionWorktreeId(worktreeId), + onOpenSettings: (worktreeId) => setWorktreeModalWorktreeId(worktreeId), + onNukeEnvironment, + }} + /> + + + ) : ( + { + // Save left panel size when user resizes (only when panel is open) + if (!commentsPanelCollapsed && sizes.length >= 2) { + // Comments panel is the first panel (index 0) + setCommentsPanelSize(sizes[0]); } }} - /> - - { - // Save right panel size when user resizes (only when panel is open) - if (effectiveSelectedSessionId && sizes.length === 2) { - setSessionPanelSize(sizes[1]); + + {!commentsPanelCollapsed && ( + c.board_id === currentBoardId + )} + userById={userById} + currentUserId={user?.user_id || 'anonymous'} + boardObjects={currentBoard?.objects} + worktreeById={worktreeById} + collapsed={commentsPanelCollapsed} + onToggleCollapse={() => setCommentsPanelCollapsed(!commentsPanelCollapsed)} + onSendComment={(content) => onSendComment?.(currentBoardId || '', content)} + onReplyComment={onReplyComment} + onResolveComment={onResolveComment} + onToggleReaction={onToggleReaction} + onDeleteComment={onDeleteComment} + hoveredCommentId={hoveredCommentId} + selectedCommentId={selectedCommentId} + /> + )} + + { + if (!commentsPanelCollapsed) { + (e.currentTarget as unknown as HTMLDivElement).style.background = + 'var(--ant-color-primary)'; } }} + onMouseLeave={(e) => { + if (!commentsPanelCollapsed) { + (e.currentTarget as unknown as HTMLDivElement).style.background = + 'var(--ant-color-border-secondary)'; + } + }} + /> + - { + // Save right panel size when user resizes (only when panel is open) + if (effectiveSelectedSessionId && sizes.length === 2) { + setSessionPanelSize(sizes[1]); + } + }} > -
- { - setSessionSettingsId(sessionId); - }} - onCreateSessionForWorktree={(worktreeId) => { - setNewSessionWorktreeId(worktreeId); - }} - onOpenWorktree={(worktreeId) => { - setWorktreeModalWorktreeId(worktreeId); - }} - onArchiveOrDeleteWorktree={onArchiveOrDeleteWorktree} - onOpenTerminal={canOpenTerminal ? handleOpenTerminal : undefined} - onStartEnvironment={onStartEnvironment} - onStopEnvironment={onStopEnvironment} - onViewLogs={setLogsModalWorktreeId} - onNukeEnvironment={onNukeEnvironment} - onOpenCommentsPanel={() => setCommentsPanelCollapsed(false)} - onCommentHover={setHoveredCommentId} - onCommentSelect={(commentId) => { - // Toggle selection: if clicking same comment, deselect - setSelectedCommentId((prev) => (prev === commentId ? null : commentId)); - }} - /> - { - const center = sessionCanvasRef.current?.getViewportCenter(); - setNewWorktreeDefaultPosition(center || null); - setCreateDialogOpen(true); - }} - /> -
-
- {(effectiveSelectedSessionId || !eventStreamPanelCollapsed) && ( - <> - { - (e.currentTarget as unknown as HTMLDivElement).style.background = - 'var(--ant-color-primary)'; - }} - onMouseLeave={(e) => { - (e.currentTarget as unknown as HTMLDivElement).style.background = - 'var(--ant-color-border-secondary)'; - }} - /> - - {effectiveSelectedSessionId ? ( - { - setSelectedSessionId(null); - }} - /> - ) : ( - setEventStreamPanelCollapsed(true)} - events={events} - onClear={clearEvents} - currentUserId={user?.user_id} - selectedSessionId={effectiveSelectedSessionId} - currentBoard={currentBoard} - client={client} - worktreeActions={{ - onSessionClick: handleSessionClick, - onCreateSession: (worktreeId) => setNewSessionWorktreeId(worktreeId), - onOpenSettings: (worktreeId) => - setWorktreeModalWorktreeId(worktreeId), - onNukeEnvironment, - }} - /> - )} - - - )} -
-
-
+ +
+ { + setSessionSettingsId(sessionId); + }} + onCreateSessionForWorktree={(worktreeId) => { + setNewSessionWorktreeId(worktreeId); + }} + onOpenWorktree={(worktreeId) => { + setWorktreeModalWorktreeId(worktreeId); + }} + onArchiveOrDeleteWorktree={onArchiveOrDeleteWorktree} + onOpenTerminal={canOpenTerminal ? handleOpenTerminal : undefined} + onStartEnvironment={onStartEnvironment} + onStopEnvironment={onStopEnvironment} + onViewLogs={setLogsModalWorktreeId} + onNukeEnvironment={onNukeEnvironment} + onOpenCommentsPanel={() => setCommentsPanelCollapsed(false)} + onCommentHover={setHoveredCommentId} + onCommentSelect={(commentId) => { + // Toggle selection: if clicking same comment, deselect + setSelectedCommentId((prev) => (prev === commentId ? null : commentId)); + }} + /> + { + const center = sessionCanvasRef.current?.getViewportCenter(); + setNewWorktreeDefaultPosition(center || null); + setCreateDialogOpen(true); + }} + /> +
+
+ {(effectiveSelectedSessionId || !eventStreamPanelCollapsed) && ( + <> + { + (e.currentTarget as unknown as HTMLDivElement).style.background = + 'var(--ant-color-primary)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as unknown as HTMLDivElement).style.background = + 'var(--ant-color-border-secondary)'; + }} + /> + + {effectiveSelectedSessionId ? ( + { + setSelectedSessionId(null); + }} + /> + ) : ( + setEventStreamPanelCollapsed(true)} + events={events} + onClear={clearEvents} + currentUserId={user?.user_id} + selectedSessionId={effectiveSelectedSessionId} + currentBoard={currentBoard} + client={client} + worktreeActions={{ + onSessionClick: handleSessionClick, + onCreateSession: (worktreeId) => + setNewSessionWorktreeId(worktreeId), + onOpenSettings: (worktreeId) => + setWorktreeModalWorktreeId(worktreeId), + onNukeEnvironment, + }} + /> + )} + + + )} +
+ + + )}
{/* Invisible mount of antd Upload so its CSS-in-JS styles stay registered even after the SessionPanel (which contains FileUpload) diff --git a/apps/agor-ui/src/components/PWAShell/PWAShell.tsx b/apps/agor-ui/src/components/PWAShell/PWAShell.tsx new file mode 100644 index 000000000..cba4e9e6c --- /dev/null +++ b/apps/agor-ui/src/components/PWAShell/PWAShell.tsx @@ -0,0 +1,124 @@ +import { CloudDownloadOutlined, DisconnectOutlined, DownloadOutlined } from '@ant-design/icons'; +import { Alert, Button, Space, theme } from 'antd'; +import { useEffect, useState } from 'react'; +import { usePWAInstall, usePWAUpdate } from '../../hooks'; + +/** + * Global PWA shell affordances: + * - "Install Agor" prompt when the browser fires `beforeinstallprompt` + * - "New version available" prompt when the service worker has an update waiting + * - "You're offline" banner when the browser reports `navigator.onLine === false` + * + * Each banner sits at the very top of the viewport and is dismissible. They do + * NOT push the rest of the layout — they overlay so existing fixed headers + * (board canvas, mobile prompt input) stay where they are. + */ +export function PWAShell() { + const { token } = theme.useToken(); + const { canInstall, install, isInstalled } = usePWAInstall(); + const { updateReady, applyUpdate } = usePWAUpdate(); + const [installDismissed, setInstallDismissed] = useState(false); + const [offline, setOffline] = useState( + typeof navigator !== 'undefined' ? !navigator.onLine : false + ); + + useEffect(() => { + if (typeof window === 'undefined') return; + const onOnline = () => setOffline(false); + const onOffline = () => setOffline(true); + window.addEventListener('online', onOnline); + window.addEventListener('offline', onOffline); + return () => { + window.removeEventListener('online', onOnline); + window.removeEventListener('offline', onOffline); + }; + }, []); + + // Reset dismissal once installed so it would re-prompt next session if needed + useEffect(() => { + if (isInstalled) setInstallDismissed(true); + }, [isInstalled]); + + const banners: React.ReactNode[] = []; + + if (offline) { + banners.push( + } + message="You're offline. Reconnecting automatically when the network returns." + style={{ borderRadius: 0 }} + /> + ); + } + + if (updateReady) { + banners.push( + } + style={{ borderRadius: 0 }} + message={ + + A new version of Agor is ready. + + + } + /> + ); + } + + if (canInstall && !installDismissed) { + banners.push( + } + style={{ + borderRadius: 0, + borderBottom: `1px solid ${token.colorBorderSecondary}`, + }} + message={ + + Install Agor for a full-screen app experience and faster relaunch. + + + + + + } + /> + ); + } + + if (banners.length === 0) return null; + + return ( +
+ {banners} +
+ ); +} diff --git a/apps/agor-ui/src/components/PWAShell/index.ts b/apps/agor-ui/src/components/PWAShell/index.ts new file mode 100644 index 000000000..82f5c53f1 --- /dev/null +++ b/apps/agor-ui/src/components/PWAShell/index.ts @@ -0,0 +1 @@ +export { PWAShell } from './PWAShell'; diff --git a/apps/agor-ui/src/components/SettingsModal/SettingsModal.tsx b/apps/agor-ui/src/components/SettingsModal/SettingsModal.tsx index 42dcf9585..fab67b269 100644 --- a/apps/agor-ui/src/components/SettingsModal/SettingsModal.tsx +++ b/apps/agor-ui/src/components/SettingsModal/SettingsModal.tsx @@ -26,6 +26,7 @@ import { ExperimentOutlined, FolderOutlined, InfoCircleOutlined, + KeyOutlined, MessageOutlined, RobotOutlined, TeamOutlined, @@ -45,6 +46,7 @@ import { BoardsTable } from './BoardsTable'; import { CardsTable } from './CardsTable'; import { GatewayChannelsTable } from './GatewayChannelsTable'; import { MCPServersTable } from './MCPServersTable'; +import { PersonalApiKeysTab } from './PersonalApiKeysTab'; import { ReposTable } from './ReposTable'; import { UsersTable } from './UsersTable'; import { WorktreesTable } from './WorktreesTable'; @@ -288,6 +290,11 @@ export const SettingsModal: React.FC = ({ }, ] : []), + { + key: 'personal-api-keys', + label: 'Personal API Keys', + icon: , + }, { key: 'agentic-tools', label: 'Agentic Tools', @@ -422,6 +429,8 @@ export const SettingsModal: React.FC = ({ onDelete={onDeleteMCPServer} /> ); + case 'personal-api-keys': + return ; case 'agentic-tools': return ; case 'gateway': diff --git a/apps/agor-ui/src/components/mobile/MobileApp.tsx b/apps/agor-ui/src/components/mobile/MobileApp.tsx deleted file mode 100644 index 2999d28a8..000000000 --- a/apps/agor-ui/src/components/mobile/MobileApp.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import type { - AgorClient, - Board, - BoardComment, - Repo, - Session, - User, - Worktree, -} from '@agor-live/client'; -import { Drawer, Layout, Typography } from 'antd'; -import { useState } from 'react'; -import { Route, Routes } from 'react-router-dom'; -import { MobileCommentsPage } from './MobileCommentsPage'; -import { MobileHeader } from './MobileHeader'; -import { MobileNavTree } from './MobileNavTree'; -import { SessionPage } from './SessionPage'; - -const { Content } = Layout; -const { Text } = Typography; - -interface MobileAppProps { - client: AgorClient | null; - user?: User | null; - sessionById: Map; // O(1) ID lookups - sessionsByWorktree: Map; // O(1) worktree filtering - boardById: Map; - commentById: Map; - repoById: Map; - worktreeById: Map; - userById: Map; - onSendPrompt?: (sessionId: string, prompt: string) => void; - onSendComment: (boardId: string, content: string) => void; - onReplyComment?: (parentId: string, content: string) => void; - onResolveComment?: (commentId: string) => void; - onToggleReaction?: (commentId: string, emoji: string) => void; - onDeleteComment?: (commentId: string) => void; - onLogout?: () => void; - promptDrafts: Map; - onUpdateDraft: (sessionId: string, draft: string) => void; -} - -export const MobileApp: React.FC = ({ - client, - user, - sessionById, - sessionsByWorktree, - boardById, - commentById, - repoById, - worktreeById, - userById, - onSendPrompt, - onSendComment, - onReplyComment, - onResolveComment, - onToggleReaction, - onDeleteComment, - onLogout, - promptDrafts, - onUpdateDraft, -}) => { - const [drawerOpen, setDrawerOpen] = useState(false); - - return ( - - {/* Navigation Drawer - shared across all routes */} - setDrawerOpen(false)} - open={drawerOpen} - width="85%" - styles={{ - body: { padding: 0 }, - }} - > - setDrawerOpen(false)} - /> - - - - {/* Home page - just shows header, drawer opened by hamburger */} - - setDrawerOpen(true)} - onLogout={onLogout} - /> - - Agor - - Tap the menu icon to browse boards and sessions - - - - } - /> - - {/* Session conversation page */} - setDrawerOpen(true)} - promptDrafts={promptDrafts} - onUpdateDraft={onUpdateDraft} - /> - } - /> - - {/* Comments page */} - setDrawerOpen(true)} - onSendComment={onSendComment} - onReplyComment={onReplyComment} - onResolveComment={onResolveComment} - onToggleReaction={onToggleReaction} - onDeleteComment={onDeleteComment} - /> - } - /> - - - ); -}; diff --git a/apps/agor-ui/src/components/mobile/MobileBoardView.tsx b/apps/agor-ui/src/components/mobile/MobileBoardView.tsx new file mode 100644 index 000000000..f4375cb49 --- /dev/null +++ b/apps/agor-ui/src/components/mobile/MobileBoardView.tsx @@ -0,0 +1,423 @@ +import type { + Board, + BoardComment, + BoardEntityObject, + Repo, + Session, + Worktree, +} from '@agor-live/client'; +import { + CommentOutlined, + EnvironmentOutlined, + HistoryOutlined, + PlayCircleOutlined, + PlusOutlined, + TableOutlined, +} from '@ant-design/icons'; +import { + Badge, + Button, + Card, + Collapse, + Empty, + List, + Segmented, + Space, + Tag, + Typography, + theme, +} from 'antd'; +import { useMemo, useState } from 'react'; +import { mapToArray } from '@/utils/mapHelpers'; +import { getSessionDisplayTitle } from '@/utils/sessionTitle'; + +const { Text, Title } = Typography; + +type Tab = 'worktrees' | 'sessions' | 'comments'; + +export interface MobileBoardViewProps { + board: Board | null; + worktrees: Worktree[]; + sessionsByWorktree: Map; + commentById: Map; + boardObjectById: Map; + repoById: Map; + onSessionClick: (sessionId: string) => void; + onCreateSession: () => void; + onOpenComments: () => void; + onOpenWorktree: (worktreeId: string) => void; + onCreateSessionForWorktree: (worktreeId: string) => void; +} + +function statusIcon(status?: string): string { + if (status === 'running') return '▶'; + if (status === 'completed') return '✓'; + if (status === 'failed') return '✕'; + return '⏸'; +} + +function statusColor(status?: string): string { + if (status === 'running') return 'processing'; + if (status === 'completed') return 'success'; + if (status === 'failed') return 'error'; + return 'default'; +} + +/** + * Mobile-friendly replacement for the desktop SessionCanvas. + * + * Canvas-based UIs are cumbersome on phones (drag, pinch-to-zoom on a + * heavyweight React Flow scene). We instead expose the same data as plain + * scrollable lists with a Segmented switcher: worktrees, sessions, comments. + * + * Tapping a session triggers the parent to open the SessionPanel as a + * full-screen drawer (handled in App/App.tsx). + */ +export function MobileBoardView({ + board, + worktrees, + sessionsByWorktree, + commentById, + onSessionClick, + onCreateSession, + onOpenComments, + onOpenWorktree, + onCreateSessionForWorktree, +}: MobileBoardViewProps) { + const { token } = theme.useToken(); + const [tab, setTab] = useState('worktrees'); + + // Sort worktrees by most recent session activity + const sortedWorktrees = useMemo(() => { + return [...worktrees].sort((a, b) => { + const aMax = Math.max( + ...(sessionsByWorktree.get(a.worktree_id) || []).map((s) => + new Date(s.last_updated).getTime() + ), + 0 + ); + const bMax = Math.max( + ...(sessionsByWorktree.get(b.worktree_id) || []).map((s) => + new Date(s.last_updated).getTime() + ), + 0 + ); + return bMax - aMax; + }); + }, [worktrees, sessionsByWorktree]); + + // Flat session list for the "Sessions" tab — most recent first + const allSessions = useMemo(() => { + const sessions: { session: Session; worktree?: Worktree }[] = []; + for (const wt of worktrees) { + const wtSessions = sessionsByWorktree.get(wt.worktree_id) || []; + for (const s of wtSessions) { + sessions.push({ session: s, worktree: wt }); + } + } + return sessions.sort( + (a, b) => + new Date(b.session.last_updated).getTime() - new Date(a.session.last_updated).getTime() + ); + }, [worktrees, sessionsByWorktree]); + + const activeComments = useMemo( + () => + mapToArray(commentById).filter( + (c) => c.board_id === board?.board_id && !c.resolved && !c.parent_comment_id + ), + [commentById, board?.board_id] + ); + + const headerBadge = ( + + + {worktrees.length} worktree{worktrees.length === 1 ? '' : 's'} + + + {allSessions.length} session{allSessions.length === 1 ? '' : 's'} + + {activeComments.length > 0 && ( + + {activeComments.length} comment{activeComments.length === 1 ? '' : 's'} + + )} + + ); + + const renderSessionItem = (session: Session, worktree?: Worktree) => { + const title = getSessionDisplayTitle(session, { + fallbackChars: 60, + includeIdFallback: true, + }); + const needsAttention = !!worktree?.needs_attention || !!session.ready_for_prompt; + return ( + onSessionClick(session.session_id)} + style={{ + padding: '12px 12px', + cursor: 'pointer', + borderRadius: 8, + background: needsAttention ? `${token.colorWarningBg}` : undefined, + }} + > +
+ + + + {statusIcon(session.status)} {session.status || 'idle'} + + {session.agentic_tool && ( + {session.agentic_tool} + )} + + {needsAttention && ( + Attention} /> + )} + +
+ + {title} + +
+
+ + {worktree?.name && `🌳 ${worktree.name}`} + {session.model_config?.model && ` • ${session.model_config.model}`} + +
+
+
+ ); + }; + + return ( +
+ {/* Sticky header with board info & tabs */} +
+ +
+ + {board?.icon ? `${board.icon} ` : ''} + {board?.name || 'Board'} + +
{headerBadge}
+
+
+ + + block + value={tab} + onChange={(value) => setTab(value as Tab)} + options={[ + { + label: ( + + Worktrees + + ), + value: 'worktrees', + }, + { + label: ( + + Sessions + + ), + value: 'sessions', + }, + { + label: ( + + + Comments + + + ), + value: 'comments', + }, + ]} + /> +
+ + {/* Scrollable content area */} +
+ {tab === 'worktrees' && ( + <> + {sortedWorktrees.length === 0 ? ( + + +
+ +
+
+ ) : ( + { + const wtSessions = sessionsByWorktree.get(wt.worktree_id) || []; + const lastActivity = wtSessions.reduce( + (max, s) => Math.max(max, new Date(s.last_updated).getTime()), + 0 + ); + return { + key: wt.worktree_id, + label: ( + +
+ + 🌳 + {wt.name} + {wt.needs_attention && Attention} + +
+ + {wtSessions.length} session{wtSessions.length === 1 ? '' : 's'} + {lastActivity > 0 && + ` • last ${new Date(lastActivity).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + })}`} + +
+
+ + + ), + }; + })} + /> + )} + + )} + + {tab === 'sessions' && ( + + {allSessions.length === 0 ? ( + + ) : ( + renderSessionItem(session, worktree)} + /> + )} + + )} + + {tab === 'comments' && ( + + {activeComments.length === 0 ? ( + + ) : ( + + + {activeComments.length} active comment + {activeComments.length === 1 ? '' : 's'} + + + + )} + + )} +
+ + {/* Floating action button */} +
+ ); +} diff --git a/apps/agor-ui/src/components/mobile/MobileCommentsPage.tsx b/apps/agor-ui/src/components/mobile/MobileCommentsPage.tsx deleted file mode 100644 index e907ca345..000000000 --- a/apps/agor-ui/src/components/mobile/MobileCommentsPage.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { AgorClient, Board, BoardComment, User, Worktree } from '@agor-live/client'; -import { Alert } from 'antd'; -import { useParams } from 'react-router-dom'; -import { mapToArray } from '@/utils/mapHelpers'; -import { CommentsPanel } from '../CommentsPanel'; -import { MobileHeader } from './MobileHeader'; - -interface MobileCommentsPageProps { - client: AgorClient | null; - boardById: Map; - commentById: Map; - worktreeById: Map; - userById: Map; - currentUser?: User | null; - onMenuClick?: () => void; - onSendComment: (boardId: string, content: string) => void; - onReplyComment?: (parentId: string, content: string) => void; - onResolveComment?: (commentId: string) => void; - onToggleReaction?: (commentId: string, emoji: string) => void; - onDeleteComment?: (commentId: string) => void; -} - -export const MobileCommentsPage: React.FC = ({ - client, - boardById, - commentById, - worktreeById, - userById, - currentUser, - onMenuClick, - onSendComment, - onReplyComment, - onResolveComment, - onToggleReaction, - onDeleteComment, -}) => { - const { boardId } = useParams<{ boardId: string }>(); - - const board = boardId ? boardById.get(boardId) : undefined; - const boardComments = mapToArray(commentById).filter((c: BoardComment) => c.board_id === boardId); - - if (!boardId) { - return ( -
- -
- ); - } - - if (!board) { - return ( -
- -
- ); - } - - return ( -
- -
- onSendComment(boardId, content)} - onReplyComment={onReplyComment} - onResolveComment={onResolveComment} - onToggleReaction={onToggleReaction} - onDeleteComment={onDeleteComment} - /> -
-
- ); -}; diff --git a/apps/agor-ui/src/components/mobile/MobileHeader.tsx b/apps/agor-ui/src/components/mobile/MobileHeader.tsx deleted file mode 100644 index 7723c4db0..000000000 --- a/apps/agor-ui/src/components/mobile/MobileHeader.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { User } from '@agor-live/client'; -import { UnorderedListOutlined } from '@ant-design/icons'; -import { Button, Layout, Space, Typography, theme } from 'antd'; - -const { Header } = Layout; -const { Title } = Typography; - -interface MobileHeaderProps { - showMenu?: boolean; - showLogo?: boolean; - title?: string; - user?: User | null; - onMenuClick?: () => void; - onLogout?: () => void; -} - -export const MobileHeader: React.FC = ({ - showMenu = true, - showLogo = false, - title, - user, - onMenuClick, - onLogout, -}) => { - const { token } = theme.useToken(); - - return ( -
- - {showLogo && ( - Agor logo - )} - - - {title || 'agor'} - - - - - {user && ( -
- {user.emoji || '👤'} -
- )} - - {showMenu && ( -
- ); -}; diff --git a/apps/agor-ui/src/components/mobile/MobileLegacyRedirect.test.tsx b/apps/agor-ui/src/components/mobile/MobileLegacyRedirect.test.tsx new file mode 100644 index 000000000..b30ec2667 --- /dev/null +++ b/apps/agor-ui/src/components/mobile/MobileLegacyRedirect.test.tsx @@ -0,0 +1,103 @@ +import type { Session, Worktree } from '@agor-live/client'; +import { render } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; +import { describe, expect, it } from 'vitest'; +import { MobileLegacyRedirect } from './MobileLegacyRedirect'; + +/** + * Capture the current location after rendering so we can assert the + * redirect destination without needing jsdom navigation support. + */ +function LocationCapture({ onCapture }: { onCapture: (path: string) => void }) { + const location = useLocation(); + onCapture(location.pathname); + return null; +} + +function renderWithRouter( + initialPath: string, + sessionById: Map, + worktreeById: Map +): string { + let captured = ''; + + render( + + + {/* The /m/* route mirrors how App.tsx registers MobileLegacyRedirect */} + } + /> + {/* Catch-all — records where we ended up after the redirect */} + (captured = p)} />} /> + + + ); + + return captured; +} + +const emptyMaps = { + sessionById: new Map(), + worktreeById: new Map(), +}; + +describe('MobileLegacyRedirect', () => { + it('redirects /m root to /', () => { + const dest = renderWithRouter('/m', emptyMaps.sessionById, emptyMaps.worktreeById); + expect(dest).toBe('/'); + }); + + it('redirects /m/ to /', () => { + const dest = renderWithRouter('/m/', emptyMaps.sessionById, emptyMaps.worktreeById); + expect(dest).toBe('/'); + }); + + it('redirects /m/comments/:boardId to /b/:boardId/', () => { + const dest = renderWithRouter( + '/m/comments/board-abc', + emptyMaps.sessionById, + emptyMaps.worktreeById + ); + expect(dest).toBe('/b/board-abc/'); + }); + + it('redirects /m/session/:id with known session to /b/:boardId/:sessionId/', () => { + const sessionId = 'sess-123'; + const worktreeId = 'wt-456'; + const boardId = 'board-789'; + + const sessionById = new Map([ + [ + sessionId, + { + session_id: sessionId, + worktree_id: worktreeId, + } as unknown as Session, + ], + ]); + + const worktreeById = new Map([ + [ + worktreeId, + { + worktree_id: worktreeId, + board_id: boardId, + } as unknown as Worktree, + ], + ]); + + const dest = renderWithRouter(`/m/session/${sessionId}`, sessionById, worktreeById); + expect(dest).toBe(`/b/${boardId}/${sessionId}/`); + }); + + it('redirects /m/session/:id with missing session to /', () => { + const dest = renderWithRouter( + '/m/session/nonexistent-id', + emptyMaps.sessionById, + emptyMaps.worktreeById + ); + expect(dest).toBe('/'); + }); +}); diff --git a/apps/agor-ui/src/components/mobile/MobileLegacyRedirect.tsx b/apps/agor-ui/src/components/mobile/MobileLegacyRedirect.tsx new file mode 100644 index 000000000..2a7c95b41 --- /dev/null +++ b/apps/agor-ui/src/components/mobile/MobileLegacyRedirect.tsx @@ -0,0 +1,57 @@ +import type { Session, Worktree } from '@agor-live/client'; +import { useEffect } from 'react'; +import { Navigate, Route, Routes, useNavigate, useParams } from 'react-router-dom'; + +/** + * Handles legacy `/m/...` URLs that pointed at the trimmed-down mobile shell + * (PWA installs, bookmarks, deep links from Slack/email, etc). + * + * The full Agor UI now renders on every device, so we just redirect each old + * mobile-only path to its canonical desktop equivalent. Anything we don't + * recognize falls back to the landing route. + */ +interface Props { + sessionById: Map; + worktreeById: Map; +} + +function SessionRedirect({ sessionById, worktreeById }: Props) { + const { sessionId } = useParams<{ sessionId: string }>(); + const navigate = useNavigate(); + + useEffect(() => { + if (!sessionId) { + navigate('/', { replace: true }); + return; + } + const session = sessionById.get(sessionId); + const worktree = session?.worktree_id ? worktreeById.get(session.worktree_id) : undefined; + const boardId = worktree?.board_id; + if (boardId) { + navigate(`/b/${boardId}/${sessionId}/`, { replace: true }); + } else { + // Fall back to landing — session wasn't found in current state yet. + navigate('/', { replace: true }); + } + }, [sessionId, sessionById, worktreeById, navigate]); + + return null; +} + +function CommentsRedirect() { + const { boardId } = useParams<{ boardId: string }>(); + return ; +} + +export function MobileLegacyRedirect({ sessionById, worktreeById }: Props) { + return ( + + } + /> + } /> + } /> + + ); +} diff --git a/apps/agor-ui/src/components/mobile/MobileNavTree.tsx b/apps/agor-ui/src/components/mobile/MobileNavTree.tsx deleted file mode 100644 index ddff80b8a..000000000 --- a/apps/agor-ui/src/components/mobile/MobileNavTree.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import type { Board, BoardComment, Session, Worktree } from '@agor-live/client'; -import { CommentOutlined, DownOutlined } from '@ant-design/icons'; -import { Badge, Button, Collapse, List, Space, Typography, theme } from 'antd'; -import { useNavigate } from 'react-router-dom'; -import { mapToArray } from '@/utils/mapHelpers'; -import { getSessionDisplayTitle } from '@/utils/sessionTitle'; -import { BoardCollapse } from '../BoardCollapse'; - -const { Text } = Typography; - -interface MobileNavTreeProps { - boardById: Map; - worktreeById: Map; - sessionsByWorktree: Map; // O(1) worktree filtering - commentById: Map; - onNavigate?: () => void; -} - -export const MobileNavTree: React.FC = ({ - boardById, - worktreeById, - sessionsByWorktree, - commentById, - onNavigate, -}) => { - const navigate = useNavigate(); - const { token } = theme.useToken(); - - const handleSessionClick = (sessionId: string) => { - navigate(`/m/session/${sessionId}`); - onNavigate?.(); - }; - - const handleCommentsClick = (boardId: string, e: React.MouseEvent) => { - e.stopPropagation(); // Prevent board collapse toggle - navigate(`/m/comments/${boardId}`); - onNavigate?.(); - }; - - // Count active comments per board (unresolved) - const getActiveCommentCount = (boardId: string): number => { - return mapToArray(commentById).filter( - (c: BoardComment) => c.board_id === boardId && !c.resolved && !c.parent_comment_id - ).length; - }; - - // Group worktrees by board - const worktreesByBoard = {} as Record; - for (const worktree of worktreeById.values()) { - const boardId = worktree.board_id || 'unassigned'; - if (!worktreesByBoard[boardId]) { - worktreesByBoard[boardId] = []; - } - worktreesByBoard[boardId].push(worktree); - } - - // Sort sessions within each worktree by last_updated (most recent first) - // Convert Map to sorted Map for consistent rendering - const sortedSessionsByWorktree = new Map( - Array.from(sessionsByWorktree.entries()).map(([worktreeId, worktreeSessions]) => [ - worktreeId, - [...worktreeSessions].sort((a, b) => { - const aTime = new Date(a.last_updated).getTime(); - const bTime = new Date(b.last_updated).getTime(); - return bTime - aTime; // DESC (most recent first) - }), - ]) - ); - - // Get session title with mobile-friendly 50-char limit - const getSessionTitle = (session: Session): string => { - return getSessionDisplayTitle(session, { - fallbackChars: 50, - includeIdFallback: true, - }); - }; - - // Get session status icon - const getSessionStatusIcon = (session: Session): string => { - if (session.status === 'running') return '▶️'; - if (session.status === 'completed') return '✅'; - if (session.status === 'failed') return '❌'; - return '⏸️'; - }; - - const boards = mapToArray(boardById); - - return ( -
- { - const boardWorktrees = worktreesByBoard[board.board_id] || []; - const activeComments = getActiveCommentCount(board.board_id); - - return { - key: board.board_id, - board, - badge: ( - - - -
- ); -}; diff --git a/apps/agor-ui/src/components/mobile/MobilePromptInput.tsx b/apps/agor-ui/src/components/mobile/MobilePromptInput.tsx deleted file mode 100644 index 9bd06d916..000000000 --- a/apps/agor-ui/src/components/mobile/MobilePromptInput.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import type { AgorClient, SessionID, User } from '@agor-live/client'; -import { SendOutlined } from '@ant-design/icons'; -import { Button, theme } from 'antd'; -import { AutocompleteTextarea } from '../AutocompleteTextarea'; - -interface MobilePromptInputProps { - onSend: (prompt: string) => void; - disabled?: boolean; - placeholder?: string; - promptDraft?: string; // Draft prompt text for this session - onUpdateDraft?: (draft: string) => void; // Update draft callback - client: AgorClient | null; - sessionId: SessionID | null; - userById: Map; -} - -export const MobilePromptInput: React.FC = ({ - onSend, - disabled = false, - placeholder = 'Send a prompt...', - promptDraft = '', - onUpdateDraft, - client, - sessionId, - userById, -}) => { - const { token } = theme.useToken(); - - // Use prop-driven draft state instead of local state - const prompt = promptDraft; - const setPrompt = (value: string) => { - onUpdateDraft?.(value); - }; - - const handleSend = () => { - if (prompt.trim() && !disabled) { - onSend(prompt.trim()); - // Draft clearing is now handled by parent (App.tsx) - } - }; - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - handleSend(); - } - }; - - return ( -
-
- -
-
- ); -}; diff --git a/apps/agor-ui/src/components/mobile/PWAInstallBanner.tsx b/apps/agor-ui/src/components/mobile/PWAInstallBanner.tsx new file mode 100644 index 000000000..910a1ae8d --- /dev/null +++ b/apps/agor-ui/src/components/mobile/PWAInstallBanner.tsx @@ -0,0 +1,37 @@ +import { DownloadOutlined } from '@ant-design/icons'; +import { Alert, Button, Space, theme } from 'antd'; +import { useState } from 'react'; +import { usePWAInstall } from '../../hooks'; + +export const PWAInstallBanner: React.FC = () => { + const { token } = theme.useToken(); + const { canInstall, install } = usePWAInstall(); + const [dismissed, setDismissed] = useState(false); + + if (!canInstall || dismissed) { + return null; + } + + return ( + + Install Agor for a full-screen app experience and faster relaunch. + + + + + + } + /> + ); +}; diff --git a/apps/agor-ui/src/components/mobile/SessionPage.tsx b/apps/agor-ui/src/components/mobile/SessionPage.tsx deleted file mode 100644 index 048b463dd..000000000 --- a/apps/agor-ui/src/components/mobile/SessionPage.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import type { - AgorClient, - PermissionMode, - Repo, - Session, - SessionID, - User, - Worktree, -} from '@agor-live/client'; -import { getAssistantConfig, isAssistant, PermissionScope } from '@agor-live/client'; -import { Alert, Spin } from 'antd'; -import { useParams } from 'react-router-dom'; -import { getSessionDisplayTitle } from '../../utils/sessionTitle'; -import { ConversationView } from '../ConversationView'; -import { MobileHeader } from './MobileHeader'; -import { MobilePromptInput } from './MobilePromptInput'; - -interface SessionPageProps { - client: AgorClient | null; - sessionById: Map; // O(1) ID lookups - worktreeById: Map; - repoById: Map; - userById: Map; - currentUser?: User | null; - onSendPrompt?: (sessionId: string, prompt: string, permissionMode?: PermissionMode) => void; - onMenuClick?: () => void; - promptDrafts: Map; - onUpdateDraft: (sessionId: string, draft: string) => void; -} - -export const SessionPage: React.FC = ({ - client, - sessionById, - worktreeById, - repoById, - userById, - currentUser, - onSendPrompt, - onMenuClick, - promptDrafts, - onUpdateDraft, -}) => { - const { sessionId } = useParams<{ sessionId: string }>(); - - const session = sessionId ? sessionById.get(sessionId) : undefined; - const worktree = session?.worktree_id ? worktreeById.get(session.worktree_id) || null : null; - - if (!sessionId) { - return ( -
- -
- ); - } - - if (!session) { - return ( -
- -
- ); - } - - const handleSendPrompt = (prompt: string) => { - onSendPrompt?.(sessionId, prompt); - }; - - const handlePermissionDecision = async ( - _sessionId: string, - requestId: string, - taskId: string, - allow: boolean, - scope: PermissionScope - ) => { - if (!client) return; - - try { - await client.service(`sessions/${_sessionId}/permission-decision`).create({ - requestId, - taskId, - allow, - reason: allow ? 'Approved by user' : 'Denied by user', - remember: scope !== PermissionScope.ONCE, - scope, - decidedBy: currentUser?.user_id || 'anonymous', - }); - } catch (error) { - console.error('Failed to send permission decision:', error); - } - }; - - const handleInputResponse = async ( - _sessionId: string, - requestId: string, - taskId: string, - answers: Record, - annotations?: Record - ) => { - if (!client) return; - - try { - await client.service(`sessions/${_sessionId}/input-response`).create({ - requestId, - taskId, - answers, - annotations, - respondedBy: currentUser?.user_id || 'anonymous', - }); - } catch (error) { - console.error('Failed to send input response:', error); - } - }; - - return ( -
- -
- -
- sessionId && onUpdateDraft(sessionId, draft)} - client={client} - sessionId={(sessionId as SessionID) || null} - userById={userById} - /> -
- ); -}; diff --git a/apps/agor-ui/src/config/daemon.ts b/apps/agor-ui/src/config/daemon.ts index 4229d28ad..4dff36516 100644 --- a/apps/agor-ui/src/config/daemon.ts +++ b/apps/agor-ui/src/config/daemon.ts @@ -32,8 +32,17 @@ export function getDaemonUrl(): string { const daemonPort = import.meta.env.VITE_DAEMON_PORT || String(DAEMON.DEFAULT_PORT); if (typeof window !== 'undefined') { - // If served from /ui path, we're on the same host as daemon - // Use origin directly (handles Codespaces forwarded URLs correctly) + // Production builds: the daemon serves the UI on the same origin + // (typically under /ui/, but may also be reverse-proxied to /). Always + // use the current origin so that WebSocket connections stay same-origin. + // This avoids cross-origin WS blocks on phones/Brave/Firefox and works + // through HTTPS reverse proxies that forward both UI and /socket.io. + if (!import.meta.env.DEV) { + return window.location.origin; + } + + // Legacy heuristic for any non-standard build: if served from /ui, + // assume daemon is on same origin. if (window.location.pathname.startsWith('/ui')) { return window.location.origin; } diff --git a/apps/agor-ui/src/hooks/index.ts b/apps/agor-ui/src/hooks/index.ts index b1b2eb853..e8986cf89 100644 --- a/apps/agor-ui/src/hooks/index.ts +++ b/apps/agor-ui/src/hooks/index.ts @@ -11,9 +11,12 @@ export * from './useAgorData'; export * from './useAuth'; export * from './useAuthConfig'; export * from './useBoardActions'; +export * from './useIsCompactViewport'; export * from './useLocalStorage'; export * from './useMessages'; export * from './usePermissions'; +export * from './usePWAInstall'; +export * from './usePWAUpdate'; export * from './useRecentBoards'; export * from './useServicesConfig'; export * from './useSessionActions'; diff --git a/apps/agor-ui/src/hooks/useIsCompactViewport.ts b/apps/agor-ui/src/hooks/useIsCompactViewport.ts new file mode 100644 index 000000000..55f8c22dd --- /dev/null +++ b/apps/agor-ui/src/hooks/useIsCompactViewport.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; + +/** + * Reactive media-query hook for "compact" viewports — phones and small tablets. + * + * A single source of truth so the UI doesn't disagree with itself about whether + * to show the canvas (desktop) or the drawer-based browse layout (mobile). + * + * Threshold mirrors the device-detection breakpoint: <768px, plus an OR with + * the `pointer: coarse` query so phones in landscape (>768px) also opt in. + */ +const COMPACT_QUERY = '(max-width: 767.5px), (pointer: coarse) and (max-width: 1023.5px)'; + +export function useIsCompactViewport(): boolean { + const [compact, setCompact] = useState(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; + return window.matchMedia(COMPACT_QUERY).matches; + }); + + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; + const mq = window.matchMedia(COMPACT_QUERY); + const handler = (event: MediaQueryListEvent) => setCompact(event.matches); + setCompact(mq.matches); + if (typeof mq.addEventListener === 'function') { + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + } + // Safari < 14 fallback + mq.addListener(handler); + return () => mq.removeListener(handler); + }, []); + + return compact; +} diff --git a/apps/agor-ui/src/hooks/usePWAInstall.ts b/apps/agor-ui/src/hooks/usePWAInstall.ts new file mode 100644 index 000000000..656739b9f --- /dev/null +++ b/apps/agor-ui/src/hooks/usePWAInstall.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react'; + +interface BeforeInstallPromptEvent extends Event { + prompt: () => Promise; + userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>; +} + +export function usePWAInstall() { + const [deferredPrompt, setDeferredPrompt] = useState(null); + const [isInstalled, setIsInstalled] = useState(false); + + useEffect(() => { + const media = window.matchMedia('(display-mode: standalone)'); + const checkInstalled = () => setIsInstalled(media.matches); + + const onBeforeInstallPrompt = (event: Event) => { + event.preventDefault(); + setDeferredPrompt(event as BeforeInstallPromptEvent); + }; + + const onAppInstalled = () => { + setIsInstalled(true); + setDeferredPrompt(null); + }; + + checkInstalled(); + window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt); + window.addEventListener('appinstalled', onAppInstalled); + media.addEventListener('change', checkInstalled); + + return () => { + window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt); + window.removeEventListener('appinstalled', onAppInstalled); + media.removeEventListener('change', checkInstalled); + }; + }, []); + + const install = useCallback(async () => { + if (!deferredPrompt) return null; + + await deferredPrompt.prompt(); + const result = await deferredPrompt.userChoice; + + if (result.outcome === 'accepted') { + setDeferredPrompt(null); + } + + return result; + }, [deferredPrompt]); + + return { + canInstall: Boolean(deferredPrompt) && !isInstalled, + isInstalled, + install, + }; +} diff --git a/apps/agor-ui/src/hooks/usePWAUpdate.ts b/apps/agor-ui/src/hooks/usePWAUpdate.ts new file mode 100644 index 000000000..3b38fd782 --- /dev/null +++ b/apps/agor-ui/src/hooks/usePWAUpdate.ts @@ -0,0 +1,61 @@ +import { useEffect, useState } from 'react'; + +/** + * Watches the active service worker registration and exposes whether a new + * version is waiting to take over. Calling `applyUpdate` tells the waiting + * SW to skip waiting and reloads the page. + */ +export function usePWAUpdate() { + const [waitingWorker, setWaitingWorker] = useState(null); + + useEffect(() => { + if (!('serviceWorker' in navigator)) return; + + let cancelled = false; + + const trackRegistration = (registration: ServiceWorkerRegistration) => { + if (cancelled) return; + + if (registration.waiting) { + setWaitingWorker(registration.waiting); + } + + registration.addEventListener('updatefound', () => { + const installing = registration.installing; + if (!installing) return; + installing.addEventListener('statechange', () => { + if (installing.state === 'installed' && navigator.serviceWorker.controller) { + setWaitingWorker(installing); + } + }); + }); + }; + + navigator.serviceWorker + .getRegistration() + .then((reg) => reg && trackRegistration(reg)) + .catch(() => undefined); + + let reloaded = false; + const onControllerChange = () => { + if (reloaded) return; + reloaded = true; + window.location.reload(); + }; + navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); + + return () => { + cancelled = true; + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); + }; + }, []); + + const applyUpdate = () => { + waitingWorker?.postMessage('SKIP_WAITING'); + }; + + return { + updateReady: waitingWorker !== null, + applyUpdate, + }; +} diff --git a/apps/agor-ui/src/hooks/useSettingsRoute.ts b/apps/agor-ui/src/hooks/useSettingsRoute.ts index 8a6a987cf..a7498d23a 100644 --- a/apps/agor-ui/src/hooks/useSettingsRoute.ts +++ b/apps/agor-ui/src/hooks/useSettingsRoute.ts @@ -12,7 +12,9 @@ export const SETTINGS_SECTIONS = [ 'assistants', 'cards', 'artifacts', + 'personal-api-keys', 'mcp', + 'personal-api-keys', 'agentic-tools', 'gateway', 'users', diff --git a/apps/agor-ui/src/index.css b/apps/agor-ui/src/index.css index b0a5a3105..68837ab25 100644 --- a/apps/agor-ui/src/index.css +++ b/apps/agor-ui/src/index.css @@ -372,3 +372,67 @@ html.dark .dark\:block { html.dark .dark\:hidden { display: none; } + +/* ----------------------------------------------------------------- */ +/* Mobile / PWA responsive overrides */ +/* ----------------------------------------------------------------- */ + +/* Compact layout: phones in portrait + coarse-pointer tablets */ +@media (max-width: 767.5px), (pointer: coarse) and (max-width: 1023.5px) { + /* Tighten Ant Design header on phones — defaults to 64px which steals too + much vertical room. !important is needed to override antd's CSS-in-JS + which is injected with very high specificity. */ + .ant-layout-header { + height: 52px !important; + padding-inline: 12px !important; + line-height: 52px !important; + } + + /* Modals get full-width and rounded edges only on top */ + .ant-modal { + max-width: calc(100vw - 16px); + margin: 0 auto; + top: 8px; + } + .ant-modal-content { + border-radius: 12px; + } + + /* Drawer header padding: snug for phones */ + .ant-drawer .ant-drawer-header { + padding: 12px 16px; + } + + /* Hide pixel-resize handles between Panels (phones use full-screen drawers). + `!important` overrides inline styles set by react-resizable-panels. */ + .react-resizable-panels-handle { + display: none !important; + } + + /* Make code/terminal blocks scroll horizontally instead of breaking layout */ + pre, + code { + max-width: 100%; + } + + /* Buttons feel more tappable */ + .ant-btn { + min-height: 36px; + } + .ant-btn-sm { + min-height: 30px; + } + + /* Settings tabs become scrollable */ + .ant-tabs-nav { + overflow-x: auto; + } +} + +/* Standalone PWA: account for the iOS notch / home indicator */ +@media all and (display-mode: standalone) { + body { + user-select: none; + -webkit-user-select: none; + } +} diff --git a/apps/agor-ui/src/main.tsx b/apps/agor-ui/src/main.tsx index af8b980e0..dd824f5ba 100644 --- a/apps/agor-ui/src/main.tsx +++ b/apps/agor-ui/src/main.tsx @@ -33,6 +33,14 @@ if (import.meta.hot) { // Initialize Handlebars helpers initializeHandlebarsHelpers(); +if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker.register(`${import.meta.env.BASE_URL}sw.js`).catch((error) => { + console.warn('[PWA] Service worker registration failed:', error); + }); + }); +} + createRoot(document.getElementById('root')!).render( // Temporarily disable StrictMode to avoid double socket connections in dev // TODO: Make useAgorClient StrictMode-compatible by handling double-mount properly diff --git a/apps/agor-ui/src/pwa-base-path.test.ts b/apps/agor-ui/src/pwa-base-path.test.ts new file mode 100644 index 000000000..3c6d53374 --- /dev/null +++ b/apps/agor-ui/src/pwa-base-path.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const appRoot = path.resolve(import.meta.dirname, '..'); +const manifestPath = path.resolve(appRoot, 'public/manifest.webmanifest'); +const serviceWorkerPath = path.resolve(appRoot, 'public/sw.js'); +const indexHtmlPath = path.resolve(appRoot, 'index.html'); + +describe('PWA base-path compatibility', () => { + it('uses relative manifest URLs for /ui deployments', () => { + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { + start_url: string; + scope: string; + icons: Array<{ src: string }>; + }; + + expect(manifest.start_url).toBe('./'); + expect(manifest.scope).toBe('./'); + expect(manifest.icons.every((icon) => icon.src.startsWith('./'))).toBe(true); + }); + + it('resolves manifest link from Vite BASE_URL', () => { + const html = readFileSync(indexHtmlPath, 'utf8'); + expect(html).toContain('href="%BASE_URL%manifest.webmanifest"'); + }); + + it('uses runtime service worker scope for app-shell cache and navigation fallback', () => { + const serviceWorker = readFileSync(serviceWorkerPath, 'utf8'); + + // SW derives base path from registration scope (not hardcoded) + expect(serviceWorker).toContain('new URL(self.registration.scope)'); + // Navigation fallback uses dynamic basePath — look for the pattern without + // triggering biome's noTemplateCurlyInString rule by splitting the literal. + expect(serviceWorker).toContain('basePath' + '}index.html`'); + // Old hardcoded fallback must be gone + expect(serviceWorker).not.toContain("caches.match('/index.html')"); + }); + + it('service worker handles NO_CACHE bypass for API routes', () => { + const serviceWorker = readFileSync(serviceWorkerPath, 'utf8'); + expect(serviceWorker).toContain('NO_CACHE_PREFIXES'); + expect(serviceWorker).toContain('isNoCacheRequest'); + }); + + it('service worker handles SKIP_WAITING message for updates', () => { + const serviceWorker = readFileSync(serviceWorkerPath, 'utf8'); + expect(serviceWorker).toContain('SKIP_WAITING'); + }); +}); diff --git a/apps/agor-ui/src/utils/deviceDetection.ts b/apps/agor-ui/src/utils/deviceDetection.ts index 6b3a598b3..65041344a 100644 --- a/apps/agor-ui/src/utils/deviceDetection.ts +++ b/apps/agor-ui/src/utils/deviceDetection.ts @@ -1,5 +1,7 @@ /** - * Device detection utilities for mobile site routing + * Device detection utilities for responsive UX affordances. + * + * Note: these helpers must never be used to control route ownership. */ /** @@ -26,9 +28,9 @@ export function isMobileDevice(): boolean { } /** - * Check if we're currently on a mobile route + * Check if viewport should be treated as touch-first for UI affordances. */ -export function isOnMobileRoute(): boolean { +export function isCompactViewport(): boolean { if (typeof window === 'undefined') return false; - return window.location.pathname.includes('/m'); + return window.innerWidth < 768; } diff --git a/apps/agor-ui/vite.config.ts b/apps/agor-ui/vite.config.ts index 718c9fb65..39deb9025 100644 --- a/apps/agor-ui/vite.config.ts +++ b/apps/agor-ui/vite.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@agor/core/types': path.resolve(__dirname, '../../packages/core/src/types/index.ts'), }, }, diff --git a/apps/agor-ui/vitest.config.ts b/apps/agor-ui/vitest.config.ts index 886471dda..7668750b1 100644 --- a/apps/agor-ui/vitest.config.ts +++ b/apps/agor-ui/vitest.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(__dirname, './src'), + '@agor/core/types': path.resolve(__dirname, '../../packages/core/src/types/index.ts'), }, }, test: { diff --git a/context/concepts/agor-mcp-server.md b/context/concepts/agor-mcp-server.md index d6a4549cf..38fe8ff84 100644 --- a/context/concepts/agor-mcp-server.md +++ b/context/concepts/agor-mcp-server.md @@ -7,7 +7,7 @@ ## Overview -Agor exposes itself as a **Model Context Protocol server** so agents can introspect worktrees, sessions, boards, and users without hard-coded CLI calls. The daemon mounts a JSON-RPC endpoint at `POST /mcp` that authenticates with the current session's MCP token and routes requests through Feathers services. +Agor exposes itself as a **Model Context Protocol server** so agents can introspect worktrees, sessions, boards, and users without hard-coded CLI calls. The daemon mounts MCP at `/mcp` (POST/GET/DELETE): POST for JSON-RPC calls, GET for SSE streams, and DELETE for session termination. It authenticates with session tokens or personal API keys and routes requests through Feathers services. The built-in toolset mirrors Agor's primitives: @@ -30,8 +30,11 @@ The built-in toolset mirrors Agor's primitives: 1. Start the daemon (`pnpm dev` in `apps/agor-daemon`). 2. In the UI, open Session Settings → MCP Tokens → "Generate MCP Token". -3. Configure your agent (Claude Desktop, Cursor MCP, etc.) to hit `http://localhost:3030/mcp` with that token in `sessionToken` metadata. -4. Call tools like: +3. Configure your agent (Claude Desktop, Cursor MCP, Hermes, etc.) to hit `http://localhost:3030/mcp` with one of: + - session token in `sessionToken` metadata (session-scoped), or + - personal API key in `Authorization: Bearer agor_sk_...` / `X-API-Key` (long-lived). +4. If using API key auth, pass a session context for "current session" tools via `?sessionId=` or `X-Agor-Session-Id: `. +5. Call tools like: - `agor_repos_create_remote` to clone new repositories - `agor_worktrees_create` to create worktrees - `agor_boards_update` to create zones and organize boards diff --git a/context/projects/agor-ios-pwa-rewrite-spec.md b/context/projects/agor-ios-pwa-rewrite-spec.md new file mode 100644 index 000000000..fda2eb7f2 --- /dev/null +++ b/context/projects/agor-ios-pwa-rewrite-spec.md @@ -0,0 +1,132 @@ +# Agor iOS → Agor PWA Rewrite Specification + +## Status + +- **Authoring date:** April 2, 2026 +- **Target:** Replace platform-specific iOS client with a standards-based Progressive Web App built on `apps/agor-ui`. +- **Source baseline:** `apps/agor-ios` from commit `2d193f2d8597a74f2cbf2cc673f5f976bef9c79b` in `maroun2/agor`. + +## Goals + +1. Preserve all high-value iOS workflows (navigation, chat, approval flows, file browsing, session management). +2. Reuse and extend the existing Agor React UI so behavior stays aligned with desktop/web releases. +3. Deliver installable app behavior on iOS, Android, macOS, Windows, and Linux without maintaining separate native codebases. +4. Keep Feathers REST + WebSocket APIs unchanged. + +## Non-goals + +- No backend API redesign for the migration. +- No attempt to fully replicate native-only device integrations (for example, deep iOS-only system APIs). +- No parallel long-term maintenance of a separate SwiftUI frontend. + +## Feature Parity Matrix (iOS baseline → PWA target) + +### 1) Navigation & Information Architecture + +- **iOS baseline:** Sidebar tree (board → worktree → session), “Important Sessions”, “Needs Attention”. +- **PWA target:** + - Keep worktree-centric navigation model from current Agor UI. + - Mobile route `/m` remains primary compact mode. + - Add fast filters for running/attention/favorites in mobile nav drawer. + +### 2) Chat + Streaming + +- **iOS baseline:** Markdown rendering, tool blocks, thinking blocks, task grouping, pagination. +- **PWA target:** + - Reuse `ConversationView` and existing rich block renderers. + - Keep streaming-first behavior and promote collapsible long output defaults on mobile. + - Ensure prompt drafts persist across session switches. + +### 3) Permission & Input Requests + +- **iOS baseline:** Inline approval/deny and inline question responses with attention affordance. +- **PWA target:** + - Keep existing inline blocks in conversation timeline. + - Add mobile sticky “needs input/permission” shortcut that scrolls to pending block. + +### 4) Session Operations + +- **iOS baseline:** Archive, reset, run-state iconography. +- **PWA target:** + - Expose archive/reset/favorite actions in mobile row context actions. + - Keep status pills/icons shared with desktop UI. + +### 5) File Browser + +- **iOS baseline:** Virtual tree + text/image preview. +- **PWA target:** + - Use existing file browsing APIs and renderers. + - Add mobile-first file navigation path from session toolbar and worktree actions. + +### 6) Notifications & Recovery + +- **iOS baseline:** Local notifications for completion/attention, reconnect UX. +- **PWA target:** + - Web Notifications API (opt-in) for completion and attention events. + - Service worker for app-shell caching + resumable launch. + - Reconnect banner and refresh pass on app foreground/focus. + +## PWA Technical Requirements + +1. **Installability** + - Web app manifest with app identity, icons, theme/background colors, standalone display. + - `beforeinstallprompt` capture and user-facing install CTA. + - iOS “Add to Home Screen” guidance in Settings/About when prompt event is unavailable. + +2. **Offline/Resilience** + - Service worker app-shell caching (`index.html`, static assets, manifest). + - Navigation fallback to cached shell when offline. + - Network-first strategy for API requests and live data. + +3. **Mobile UX Performance** + - Initial interaction under 3 seconds on mid-tier mobile devices (warm daemon). + - Keep heavy list rendering virtualized where possible. + - Minimize layout shift on reconnect/update states. + +4. **Security** + - Require HTTPS in production (service workers + install + notifications). + - Preserve token handling and auth behavior already used by web client. + +## Implementation Plan + +### Phase 0 — Foundation (done in this change) + +- Add manifest and icon assets. +- Add service worker registration and shell cache behavior. +- Add mobile install CTA banner in the PWA UI. + +### Phase 1 — Feature Completion + +- Add quick “Important” and “Needs Attention” sections to mobile nav. +- Add mobile session context actions (favorite/archive/reset). +- Add notification permission UX and cross-session completion toasts. + +### Phase 2 — iOS Decommission + +- Freeze `apps/agor-ios` to archived status. +- Update docs to position `apps/agor-ui` PWA as canonical mobile app. +- Remove native release pipeline after a stabilization window. + +## Acceptance Criteria + +- Agor installs as a standalone app on iOS Safari (A2HS), Android Chrome, and desktop Chromium. +- Mobile user can: + - browse board/worktree/session hierarchy, + - stream and send prompts, + - approve permissions and answer questions, + - open file browser, + - recover from connection interruptions. +- No server API changes required for parity. + +## Risks + +1. **iOS PWA limitations** (install prompt/event and background behavior vary by Safari version). +2. **Notification consistency** across browsers. +3. **Large conversation rendering cost** on lower-end devices. + +## Mitigations + +- Provide browser-specific install guidance fallback. +- Keep in-app reconnect and attention UX strong even when background notifications are limited. +- Use collapsible blocks and pagination defaults aggressively on mobile. + diff --git a/flake.nix b/flake.nix new file mode 100644 index 000000000..6031c5d0d --- /dev/null +++ b/flake.nix @@ -0,0 +1,151 @@ +{ + description = "Agor build/run/publish workflow"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + + sharedRuntimeInputs = with pkgs; [ + bash + coreutils + findutils + gnugrep + gnused + jq + nodejs_22 + pnpm + ]; + + buildScript = pkgs.writeShellApplication { + name = "build-agor-live"; + runtimeInputs = sharedRuntimeInputs; + text = '' + set -euo pipefail + + ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + PKG_DIR="$ROOT/packages/agor-live" + + if [ ! -d "$PKG_DIR" ]; then + echo "❌ Could not find packages/agor-live from: $ROOT" + exit 1 + fi + + echo "📦 Building agor-live bundle..." + (cd "$PKG_DIR" && ./build.sh) + ''; + }; + + runScript = pkgs.writeShellApplication { + name = "run-agor-live"; + runtimeInputs = sharedRuntimeInputs; + text = '' + set -euo pipefail + + ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + PKG_DIR="$ROOT/packages/agor-live" + + if [ ! -d "$PKG_DIR" ]; then + echo "❌ Could not find packages/agor-live from: $ROOT" + exit 1 + fi + + # Ensure dist artifacts are up-to-date before running. + (cd "$PKG_DIR" && ./build.sh) + + if [ ! -f "$PKG_DIR/bin/agor.js" ]; then + echo "❌ Missing agor executable at $PKG_DIR/bin/agor.js" + exit 1 + fi + + echo "▶️ Running agor-live wrapper..." + cd "$PKG_DIR" + node ./bin/agor.js "$@" + ''; + }; + + publishScript = pkgs.writeShellApplication { + name = "publish-agor-live"; + runtimeInputs = sharedRuntimeInputs; + text = '' + set -euo pipefail + + ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" + PKG_DIR="$ROOT/packages/agor-live" + + if [ ! -d "$PKG_DIR" ]; then + echo "❌ Could not find packages/agor-live from: $ROOT" + exit 1 + fi + + if [ -z "${NPM_TOKEN:-}" ]; then + echo "❌ NPM_TOKEN is not set." + echo "Export your token first, e.g.:" + echo " export NPM_TOKEN=..." + exit 1 + fi + + echo "📦 Building agor-live bundle..." + (cd "$PKG_DIR" && ./build.sh) + + TMP_NPMRC="$(mktemp)" + cleanup() { + rm -f "$TMP_NPMRC" + } + trap cleanup EXIT + + cat > "$TMP_NPMRC" < { }); }); + describe('Response content mapping', () => { + it('should treat reasoning-only responses as regular text blocks', () => { + const tool = new OpenCodeTool( + { enabled: true, serverUrl: 'http://localhost:4096' }, + mockMessagesService + ); + + const result = (tool as any).buildContentBlocksFromParts([ + { + type: 'reasoning', + text: 'Final answer from model', + }, + ]); + + expect(result.contentBlocks).toEqual([ + { + type: 'text', + text: 'Final answer from model', + }, + ]); + }); + + it('should keep reasoning as thinking when text blocks exist', () => { + const tool = new OpenCodeTool( + { enabled: true, serverUrl: 'http://localhost:4096' }, + mockMessagesService + ); + + const result = (tool as any).buildContentBlocksFromParts([ + { + type: 'reasoning', + text: 'Internal reasoning', + }, + { + type: 'text', + text: 'User-visible answer', + }, + ]); + + expect(result.contentBlocks).toEqual([ + { + type: 'thinking', + text: 'Internal reasoning', + }, + { + type: 'text', + text: 'User-visible answer', + }, + ]); + }); + + it('should prefer text parts for display text and fall back to reasoning', () => { + const tool = new OpenCodeTool( + { enabled: true, serverUrl: 'http://localhost:4096' }, + mockMessagesService + ); + + const withText = (tool as any).extractDisplayTextFromParts([ + { type: 'reasoning', text: 'Reasoning text' }, + { type: 'text', text: 'Final response text' }, + ]); + expect(withText).toBe('Final response text'); + + const reasoningOnly = (tool as any).extractDisplayTextFromParts([ + { type: 'reasoning', text: 'Reasoning as fallback output' }, + ]); + expect(reasoningOnly).toBe('Reasoning as fallback output'); + }); + }); + describe('createSession with workingDirectory', () => { it('should use directory-scoped client when workingDirectory is provided', async () => { const tool = new OpenCodeTool( diff --git a/packages/executor/src/sdk-handlers/opencode/opencode-tool.ts b/packages/executor/src/sdk-handlers/opencode/opencode-tool.ts index ddaab3a66..9f65abd6a 100644 --- a/packages/executor/src/sdk-handlers/opencode/opencode-tool.ts +++ b/packages/executor/src/sdk-handlers/opencode/opencode-tool.ts @@ -86,6 +86,28 @@ export class OpenCodeTool implements ITool { private sessionMCPRepo?: SessionMCPServerRepository; private mcpServerRepo?: MCPServerRepository; + /** + * Extract user-facing response text from OpenCode parts. + * Prefers explicit text parts and falls back to reasoning text when no text parts exist. + */ + private extractDisplayTextFromParts(parts: Array<{ type: string; text?: string }>): string { + const textParts = parts + .filter((part) => part.type === 'text' && typeof part.text === 'string' && part.text.trim()) + .map((part) => part.text as string); + + if (textParts.length > 0) { + return textParts.join('\n'); + } + + const reasoningParts = parts + .filter( + (part) => part.type === 'reasoning' && typeof part.text === 'string' && part.text.trim() + ) + .map((part) => part.text as string); + + return reasoningParts.join('\n'); + } + constructor( config: OpenCodeConfig, messagesService?: MessagesService, @@ -277,6 +299,81 @@ export class OpenCodeTool implements ITool { this.injectedMcpHash.set(sessionId, configHash); } + /** + * Build canonical Agor message content blocks from OpenCode parts. + * + * Behavior: + * - If OpenCode emitted regular text parts, keep reasoning as `thinking`. + * - If OpenCode emitted only reasoning text (no text parts), treat reasoning as user-visible `text` + * to avoid rendering a "thought-only" assistant response. + */ + private buildContentBlocksFromParts( + parts: Array<{ + type: string; + text?: string; + tool?: string; + callID?: string; + id?: string; + state?: { input?: Record; status?: string; output?: unknown }; + }> + ): { + contentBlocks: Array<{ + type: 'text' | 'thinking' | 'tool_use' | 'tool_result'; + [key: string]: unknown; + }>; + toolUses: Array<{ id: string; name: string; input: Record }>; + } { + const contentBlocks: Array<{ + type: 'text' | 'thinking' | 'tool_use' | 'tool_result'; + [key: string]: unknown; + }> = []; + const toolUses: Array<{ id: string; name: string; input: Record }> = []; + const hasRenderableText = parts.some( + (part) => part.type === 'text' && typeof part.text === 'string' && part.text.trim() + ); + + for (const part of parts) { + if (part.type === 'reasoning' && part.text) { + contentBlocks.push({ + type: hasRenderableText ? 'thinking' : 'text', + text: part.text, + }); + } else if (part.type === 'text' && part.text) { + contentBlocks.push({ + type: 'text', + text: part.text, + }); + } else if (part.type === 'tool') { + const toolName = part.tool || 'unknown'; + const toolInput = part.state?.input || {}; + const toolCallId = part.callID || part.id || generateId(); + + contentBlocks.push({ + type: 'tool_use', + id: toolCallId, + name: toolName, + input: toolInput, + }); + + toolUses.push({ + id: toolCallId, + name: toolName, + input: toolInput, + }); + + if (part.state?.status === 'completed' && part.state.output) { + contentBlocks.push({ + type: 'tool_result', + tool_use_id: toolCallId, + content: part.state.output, + }); + } + } + } + + return { contentBlocks, toolUses }; + } + /** * Get tool capabilities */ @@ -699,20 +796,13 @@ export class OpenCodeTool implements ITool { // Extract final text from parts (or use error message if error occurred) let responseText = ''; - const textParts: string[] = []; if (hasError) { // Use the error message as the response text responseText = `❌ **OpenCode Error**\n\n${errorMessage}`; } else { - // Only extract text from parts if no error occurred + // Extract metadata from parts for (const part of response.data.parts || []) { - // Collect text from reasoning and text parts - // TextPart and ReasoningPart both have a .text property - if (part.type === 'reasoning' || part.type === 'text') { - textParts.push(part.text); - } - // Extract metadata from step-finish part if (part.type === 'step-finish') { metadata.cost = part.cost; @@ -728,7 +818,9 @@ export class OpenCodeTool implements ITool { } } - responseText = textParts.join('\n'); + responseText = this.extractDisplayTextFromParts( + (response.data.parts || []) as Array<{ type: string; text?: string }> + ); console.log('[OpenCodeTool] Final text length:', responseText.length); // Fallback: if no text found, return message @@ -746,13 +838,6 @@ export class OpenCodeTool implements ITool { // Handler should create user message first with index N, then pass N+1 here const assistantIndex = messageIndex ?? 0; - // Build content blocks from all parts - const contentBlocks: Array<{ - type: 'text' | 'thinking' | 'tool_use' | 'tool_result'; - [key: string]: unknown; - }> = []; - const toolUses: Array<{ id: string; name: string; input: Record }> = []; - // Process parts from final response (not from streaming cache) // The final response contains ALL parts, including ones that weren't streamed const finalParts = response.data.parts || []; @@ -762,59 +847,16 @@ export class OpenCodeTool implements ITool { 'parts in final response' ); console.log('[OpenCodeTool] Part types:', finalParts.map((p) => p.type).join(', ')); - for (const part of finalParts) { - console.log('[OpenCodeTool] Processing part type:', part.type); - - if (part.type === 'reasoning' && part.text) { - console.log('[OpenCodeTool] Adding reasoning block, text length:', part.text.length); - contentBlocks.push({ - type: 'thinking', - text: part.text, - }); - } else if (part.type === 'reasoning') { - console.log('[OpenCodeTool] Skipping reasoning part - no text field or empty text'); - } - - if (part.type === 'text' && part.text) { - contentBlocks.push({ - type: 'text', - text: part.text, - }); - } else if (part.type === 'tool') { - // Tool use block - extract tool info - // OpenCode structure: { tool: string, callID: string, state: { input: {...}, output: "..." } } - console.log('[OpenCodeTool] Processing tool part:', JSON.stringify(part, null, 2)); - - const toolName = part.tool || 'unknown'; - const toolInput = (part.state as { input?: Record })?.input || {}; - const toolCallId = part.callID || part.id; - - // Add tool_use block - contentBlocks.push({ - type: 'tool_use', - id: toolCallId, - name: toolName, - input: toolInput, - }); - - // Add to tool_uses array - toolUses.push({ - id: toolCallId, - name: toolName, - input: toolInput, - }); - - // If tool has completed with output, add tool_result block - const toolState = part.state as { status?: string; output?: unknown } | undefined; - if (toolState?.status === 'completed' && toolState.output) { - contentBlocks.push({ - type: 'tool_result', - tool_use_id: toolCallId, - content: toolState.output, - }); - } - } - } + const { contentBlocks, toolUses } = this.buildContentBlocksFromParts( + finalParts as Array<{ + type: string; + text?: string; + tool?: string; + callID?: string; + id?: string; + state?: { input?: Record; status?: string; output?: unknown }; + }> + ); // If no content blocks were created (error case), add the error text if (contentBlocks.length === 0 && responseText) { @@ -932,16 +974,10 @@ export class OpenCodeTool implements ITool { // Extract text and token/cost metadata from 'parts' array if (response.data.parts && Array.isArray(response.data.parts)) { - // Extract text from all parts that have text content (text, reasoning, etc.) - const textParts: string[] = []; - for (const part of response.data.parts) { - // TextPart and ReasoningPart both have a .text property - if (part.type === 'text' || part.type === 'reasoning') { - textParts.push(part.text); - } - } - responseText = textParts.join('\n'); - console.log('[OpenCodeTool] Extracted', textParts.length, 'text parts'); + responseText = this.extractDisplayTextFromParts( + response.data.parts as Array<{ type: string; text?: string }> + ); + console.log('[OpenCodeTool] Extracted display text length:', responseText.length); // Extract metadata from step-finish part const stepFinish = response.data.parts.find((part) => part.type === 'step-finish');