diff --git a/apps/server/src/agents/adapters/codex-agent.ts b/apps/server/src/agents/adapters/codex-agent.ts index bada278a..74a8d6c3 100644 --- a/apps/server/src/agents/adapters/codex-agent.ts +++ b/apps/server/src/agents/adapters/codex-agent.ts @@ -39,10 +39,14 @@ import { type UserInputResponsePayload, type UserInputRequestId, } from "@farfield/protocol"; +import { readFile, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { z } from "zod"; import { logger } from "../../logger.js"; import type { AgentAdapter, + AgentArchiveThreadInput, AgentCapabilities, AgentCreateThreadInput, AgentCreateThreadResult, @@ -135,6 +139,19 @@ const APP_SERVER_THREAD_REFRESH_DEBOUNCE_MS = 120; const IPC_THREAD_REFRESH_DEBOUNCE_MS = 1_500; const THREAD_REFRESH_RETRY_DELAY_MS = 600; const CONNECTION_CHECK_MIN_INTERVAL_MS = 2_000; +const SESSION_INDEX_PATH = join(homedir(), ".codex", "session_index.jsonl"); + +const SessionIndexEntrySchema = z + .object({ + id: z.string().min(1), + thread_name: z.string(), + }) + .passthrough(); + +interface SessionIndexCache { + mtimeMs: number; + titlesByThreadId: Map; +} type ThreadActionRoute = | { @@ -200,6 +217,7 @@ export class CodexAgentAdapter implements AgentAdapter { >(); private readonly streamPatchSyncDisabledThreadIds = new Set(); private readonly threadTitleById = new Map(); + private sessionIndexCache: SessionIndexCache | null = null; private readonly canonicalThreadStateErrorById = new Map< string, AgentThreadLiveState["liveStateError"] @@ -484,8 +502,14 @@ export class CodexAgentAdapter implements AgentAdapter { ), ); + const indexedTitles = await this.readSessionIndexTitles(); const data = result.data.map((thread) => { - const title = this.resolveThreadTitle(thread.id, thread.title); + const directTitle = getThreadListItemTitle(thread); + const title = this.resolveThreadTitle( + thread.id, + directTitle, + indexedTitles.get(thread.id), + ); const snapshot = this.canonicalThreadStateById.get(thread.id); const isGenerating = snapshot ? isThreadStateGenerating(snapshot) @@ -558,7 +582,7 @@ export class CodexAgentAdapter implements AgentAdapter { ephemeral: input.ephemeral ?? false, }), ); - this.setThreadTitle(result.thread.id, result.thread.title); + this.setThreadTitle(result.thread.id, getThreadListItemTitle(result.thread)); return { threadId: result.thread.id, @@ -799,6 +823,22 @@ export class CodexAgentAdapter implements AgentAdapter { await this.runThreadOperationWithResumeRetry(input.threadId, interruptTurn); } + public async archiveThread(input: AgentArchiveThreadInput): Promise { + this.ensureCodexAvailable(); + await this.runAppServerCall(() => + this.appClient.archiveThread({ threadId: input.threadId }), + ); + this.notifyThreadStateChanged(input.threadId); + } + + public async unarchiveThread(input: AgentArchiveThreadInput): Promise { + this.ensureCodexAvailable(); + await this.runAppServerCall(() => + this.appClient.unarchiveThread({ threadId: input.threadId }), + ); + this.notifyThreadStateChanged(input.threadId); + } + public async listModels(limit: number) { this.ensureCodexAvailable(); return this.runAppServerCall(() => this.appClient.listModels(limit)); @@ -2764,11 +2804,16 @@ export class CodexAgentAdapter implements AgentAdapter { private resolveThreadTitle( threadId: string, directTitle: string | null | undefined, + indexedTitle: string | undefined, ): string | null | undefined { if (directTitle !== undefined) { return directTitle; } + if (indexedTitle !== undefined) { + return indexedTitle; + } + if (this.threadTitleById.has(threadId)) { return this.threadTitleById.get(threadId); } @@ -2781,6 +2826,53 @@ export class CodexAgentAdapter implements AgentAdapter { return snapshot.title; } + private async readSessionIndexTitles(): Promise> { + let stats: Awaited>; + try { + stats = await stat(SESSION_INDEX_PATH); + } catch { + return new Map(); + } + + if ( + this.sessionIndexCache && + this.sessionIndexCache.mtimeMs === stats.mtimeMs + ) { + return this.sessionIndexCache.titlesByThreadId; + } + + const titlesByThreadId = new Map(); + try { + const content = await readFile(SESSION_INDEX_PATH, "utf8"); + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const parsed = SessionIndexEntrySchema.safeParse(JSON.parse(trimmed)); + if (!parsed.success) { + continue; + } + const title = parsed.data.thread_name.trim(); + if (!title) { + continue; + } + titlesByThreadId.set(parsed.data.id, title); + } + } catch (error) { + logger.debug( + { error: toErrorMessage(error) }, + "codex-session-index-title-read-failed", + ); + } + + this.sessionIndexCache = { + mtimeMs: stats.mtimeMs, + titlesByThreadId, + }; + return titlesByThreadId; + } + private setThreadTitle( threadId: string, title: string | null | undefined, @@ -2815,6 +2907,25 @@ function toErrorMessage(error: Error | string | unknown): string { return String(error); } +function getThreadListItemTitle(thread: unknown): string | null | undefined { + if (!thread || typeof thread !== "object") { + return undefined; + } + + const record = thread as Record; + const title = record["title"]; + if (title === null || typeof title === "string") { + return title; + } + + const name = record["name"]; + if (name === null || typeof name === "string") { + return name; + } + + return undefined; +} + const INVALID_REQUEST_ERROR_CODE = -32600; export function isInvalidRequestAppServerRpcError( diff --git a/apps/server/src/agents/types.ts b/apps/server/src/agents/types.ts index a01de2fb..6bea688f 100644 --- a/apps/server/src/agents/types.ts +++ b/apps/server/src/agents/types.ts @@ -106,6 +106,10 @@ export interface AgentInterruptInput { ownerClientId?: string; } +export interface AgentArchiveThreadInput { + threadId: string; +} + export interface AgentThreadLiveState { ownerClientId: string | null; conversationState: AppServerReadThreadResponse["thread"] | null; @@ -157,6 +161,8 @@ export interface AgentAdapter { submitUserInput?( input: AgentSubmitUserInputInput, ): Promise<{ ownerClientId: string; requestId: UserInputRequestId }>; + archiveThread?(input: AgentArchiveThreadInput): Promise; + unarchiveThread?(input: AgentArchiveThreadInput): Promise; readLiveState?(threadId: string): Promise; readStreamEvents?( threadId: string, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 82e6ddfe..726eb0b5 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,7 +3,7 @@ import type { Socket } from "node:net"; import path from "node:path"; import fs from "node:fs"; import os from "node:os"; -import { randomUUID } from "node:crypto"; +import { randomUUID, timingSafeEqual } from "node:crypto"; import { execFileSync } from "node:child_process"; import { getCodexServerNotificationMethodMapping, @@ -62,6 +62,11 @@ import { const HOST = process.env["HOST"] ?? "127.0.0.1"; const PORT = Number(process.env["PORT"] ?? 4311); +const ACCESS_KEY = ( + process.env["FARFIELD_ACCESS_KEY"] ?? + process.env["FARFIELD_AUTH_KEY"] ?? + "" +).trim(); const HISTORY_LIMIT = 2_000; const USER_AGENT = "farfield/0.2.5"; const IPC_RECONNECT_DELAY_MS = 1_000; @@ -231,12 +236,79 @@ function jsonResponse( "Content-Type": "application/json; charset=utf-8", "Content-Length": encoded.length, "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "content-type", + "Access-Control-Allow-Headers": + "authorization, content-type, x-farfield-access-key", "Access-Control-Allow-Methods": "GET,POST,OPTIONS", }); res.end(encoded); } +function accessKeyEnabled(): boolean { + return ACCESS_KEY.length > 0; +} + +function accessKeyMatches(value: string | null | undefined): boolean { + if (!accessKeyEnabled() || typeof value !== "string") { + return false; + } + const candidate = value.trim(); + if (!candidate) { + return false; + } + const expectedBuffer = Buffer.from(ACCESS_KEY, "utf8"); + const candidateBuffer = Buffer.from(candidate, "utf8"); + if (expectedBuffer.length !== candidateBuffer.length) { + return false; + } + return timingSafeEqual(expectedBuffer, candidateBuffer); +} + +function readRequestAccessKey(req: IncomingMessage): string | null { + const headerValue = req.headers["x-farfield-access-key"]; + if (typeof headerValue === "string" && headerValue.trim()) { + return headerValue; + } + if (Array.isArray(headerValue)) { + const first = headerValue.find((value) => value.trim().length > 0); + if (first) { + return first; + } + } + + const authorization = req.headers.authorization; + if (typeof authorization === "string") { + const match = authorization.match(/^Bearer\s+(.+)$/i); + if (match?.[1]) { + return match[1]; + } + } + + return null; +} + +function requestHasValidAccessKey(req: IncomingMessage): boolean { + if (!accessKeyEnabled()) { + return true; + } + return accessKeyMatches(readRequestAccessKey(req)); +} + +function writeAccessKeyFailure( + res: ServerResponse, + code: "accessKeyRequired" | "accessKeyInvalid", +): void { + jsonResponse(res, 401, { + ok: false, + error: { + code, + message: + code === "accessKeyRequired" + ? "Access key required" + : "Invalid access key", + }, + }); +} + async function readJsonBody(req: IncomingMessage): Promise { const chunks: Buffer[] = []; @@ -569,6 +641,11 @@ interface RateLimitsCacheEntry { const sidebarCacheByKey = new Map(); const sidebarInFlightByKey = new Map>(); + +function invalidateSidebarCache(): void { + sidebarCacheByKey.clear(); + sidebarInFlightByKey.clear(); +} let rateLimitsCacheEntry: RateLimitsCacheEntry | null = null; let rateLimitsInFlight: Promise | null = null; @@ -782,7 +859,7 @@ async function buildRealtimeCoreState() { const [sidebar, rateLimits, features] = await Promise.all([ timeServerOperation("realtimeCoreSidebarList", () => listUnifiedSidebarThreadsShared({ - limit: 80, + limit: 50, archived: false, all: false, maxPages: 1, @@ -1327,6 +1404,22 @@ const server = http.createServer(async (req, res) => { const pathname = url.pathname; const segments = pathname.split("/").filter(Boolean); + if (req.method === "GET" && pathname === "/api/auth/status") { + jsonResponse(res, 200, { + ok: true, + enabled: accessKeyEnabled(), + }); + return; + } + + if (segments[0] === "api" && !requestHasValidAccessKey(req)) { + writeAccessKeyFailure( + res, + readRequestAccessKey(req) ? "accessKeyInvalid" : "accessKeyRequired", + ); + return; + } + if (req.method === "GET" && pathname === "/api/health") { jsonResponse(res, 200, { ok: true, @@ -1456,6 +1549,151 @@ const server = http.createServer(async (req, res) => { return; } + if ( + req.method === "POST" && + segments[0] === "api" && + segments[1] === "unified" && + segments[2] === "thread" && + segments[3] && + (segments[4] === "archive" || segments[4] === "unarchive") + ) { + const threadId = decodeURIComponent(segments[3]); + const action = segments[4]; + const rawProvider = url.searchParams.get("provider"); + const providerFromQuery = parseUnifiedProviderId(rawProvider); + if (rawProvider !== null && providerFromQuery === null) { + jsonResponse(res, 400, { + ok: false, + error: { + code: "invalidProvider", + message: `Provider ${rawProvider} is not supported`, + details: { + provider: rawProvider, + }, + }, + }); + return; + } + + const knownProviders = threadIndex.providers(threadId); + const resolvedProvider = threadIndex.resolve(threadId); + let provider = providerFromQuery ?? resolvedProvider; + + if (!provider && knownProviders.length > 1) { + jsonResponse(res, 409, { + ok: false, + error: { + code: "threadProviderAmbiguous", + message: `Thread ${threadId} exists in multiple providers; provider query is required`, + details: { + threadId, + providers: knownProviders, + }, + }, + }); + return; + } + + if (!provider) { + const discoveredMatches = await discoverUnifiedThreads({ + threadId, + includeTurns: false, + }); + if (discoveredMatches.length > 1) { + jsonResponse(res, 409, { + ok: false, + error: { + code: "threadProviderAmbiguous", + message: `Thread ${threadId} exists in multiple providers; provider query is required`, + details: { + threadId, + providers: discoveredMatches.map((match) => match.provider), + }, + }, + }); + return; + } + provider = discoveredMatches[0]?.provider ?? null; + } + + if (!provider) { + jsonResponse(res, 404, { + ok: false, + error: { + code: "threadNotFound", + message: `Thread ${threadId} is not registered`, + details: { + threadId, + }, + }, + }); + return; + } + + const providerAdapter = registry.getAdapter(provider); + if (!providerAdapter || !providerAdapter.isEnabled()) { + jsonResponse(res, 503, { + ok: false, + error: { + code: "providerDisabled", + message: `Provider ${provider} is not available`, + details: { + provider, + }, + }, + }); + return; + } + + const operation = + action === "archive" + ? providerAdapter.archiveThread + : providerAdapter.unarchiveThread; + if (!operation) { + jsonResponse(res, 501, { + ok: false, + error: { + code: "threadArchiveUnsupported", + message: `Provider ${provider} does not support ${action}`, + details: { + provider, + threadId, + action, + }, + }, + }); + return; + } + + try { + await operation.call(providerAdapter, { threadId }); + threadIndex.register(threadId, provider); + invalidateSidebarCache(); + queueCoreDelta?.(); + jsonResponse(res, 200, { + ok: true, + threadId, + provider, + archived: action === "archive", + }); + } catch (error) { + const message = toErrorMessage(error); + jsonResponse(res, 500, { + ok: false, + error: { + code: "threadArchiveFailed", + message, + details: { + provider, + threadId, + action, + }, + }, + }); + } + return; + } + if ( req.method === "GET" && segments[0] === "api" && @@ -1824,6 +2062,31 @@ const io = new SocketServer(server, { }, }); +io.use((socket, next) => { + if (!accessKeyEnabled()) { + next(); + return; + } + + const authAccessKey = socket.handshake.auth?.["accessKey"]; + const headerAccessKey = socket.handshake.headers["x-farfield-access-key"]; + const accessKey = + typeof authAccessKey === "string" + ? authAccessKey + : typeof headerAccessKey === "string" + ? headerAccessKey + : Array.isArray(headerAccessKey) + ? headerAccessKey[0] + : null; + + if (accessKeyMatches(accessKey)) { + next(); + return; + } + + next(new Error(accessKey ? "accessKeyInvalid" : "accessKeyRequired")); +}); + const realtimeCoordinator = new RealtimeCoordinator({ io, buildCoreState: () => buildRealtimeCoreState(), diff --git a/apps/server/src/unified/adapter.ts b/apps/server/src/unified/adapter.ts index 3b091c87..5814af54 100644 --- a/apps/server/src/unified/adapter.ts +++ b/apps/server/src/unified/adapter.ts @@ -1390,7 +1390,11 @@ function mapTurnItem( ...(typeof item.willRetry === "boolean" ? { willRetry: item.willRetry } : {}), - ...(item.errorInfo !== undefined ? { errorInfo: item.errorInfo } : {}), + ...(item.errorInfo !== undefined + ? { + errorInfo: jsonValueFromString(JSON.stringify(item.errorInfo)), + } + : {}), ...(item.additionalDetails !== undefined ? { additionalDetails: jsonValueFromString( diff --git a/apps/web/public/maskable-512.png b/apps/web/public/maskable-512.png new file mode 100644 index 00000000..5585772a Binary files /dev/null and b/apps/web/public/maskable-512.png differ diff --git a/apps/web/public/pwa-192.png b/apps/web/public/pwa-192.png new file mode 100644 index 00000000..907facd3 Binary files /dev/null and b/apps/web/public/pwa-192.png differ diff --git a/apps/web/public/pwa-512.png b/apps/web/public/pwa-512.png new file mode 100644 index 00000000..5585772a Binary files /dev/null and b/apps/web/public/pwa-512.png differ diff --git a/apps/web/public/pwa-icon.svg b/apps/web/public/pwa-icon.svg new file mode 100644 index 00000000..d1f18b61 --- /dev/null +++ b/apps/web/public/pwa-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index df332842..21bb86df 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,20 +9,27 @@ import { } from "react"; import { Activity, + Archive, + ArchiveRestore, Bug, Circle, CircleDot, Folder, FolderOpen, + GitBranch, Github, GripVertical, + KeyRound, Loader2, Menu, + Monitor, Moon, Palette, PanelLeft, Plus, RefreshCw, + Search, + Server, Sun, X, } from "lucide-react"; @@ -39,6 +46,7 @@ import { getPendingApprovalRequests, getPendingThreadRequests, getPendingUserInputRequests, + getServerAccessKey, getSavedServerBaseUrl, getServerBaseUrl, getStreamEvents, @@ -54,16 +62,25 @@ import { markTrace, sendMessage, setCollaborationMode, + setThreadArchived, startTrace, stopTrace, submitUserInput, setServerBaseUrl, + setServerAccessKey, type AgentId, } from "@/lib/api"; import { createUnifiedRealtimeSocket, type UnifiedRealtimeSocket, } from "@/lib/realtime-socket"; +import { + inferServerProfileName, + readServerProfiles, + removeServerProfile, + upsertServerProfile, + type ServerProfile, +} from "@/lib/server-profiles"; import { groupColors, readCollapseMap, @@ -120,6 +137,7 @@ import { z } from "zod"; type Health = Awaited>; type SidebarThreadsResponse = Awaited>; type ModesResponse = Awaited>; +type SettingsPanel = "connection" | "profiles"; type ModelsResponse = Awaited>; type LiveStateResponse = Awaited>; type StreamEventsResponse = Awaited>; @@ -134,6 +152,7 @@ type PendingApprovalRequest = ReturnType[numb type PendingThreadRequest = ReturnType[number]; type PendingRequestId = PendingRequest["id"]; type Thread = SidebarThreadsResponse["rows"][number]; +type SidebarArchiveMode = "active" | "archived"; type ThreadListProviderErrors = SidebarThreadsResponse["errors"]; type AgentDescriptor = AgentsResponse["agents"][number]; type ConversationTurn = NonNullable< @@ -327,6 +346,32 @@ function threadLabel(thread: Thread): string { return text; } +function normalizeSidebarSearch(value: string): string { + return value.trim().toLowerCase(); +} + +function threadMatchesSidebarSearch(thread: Thread, query: string): boolean { + const normalizedQuery = normalizeSidebarSearch(query); + if (!normalizedQuery) { + return true; + } + + const cwd = typeof thread.cwd === "string" ? thread.cwd : ""; + const haystack = [ + threadLabel(thread), + thread.preview, + cwd, + cwd ? basenameFromPath(cwd) : "", + thread.id, + ] + .join("\n") + .toLowerCase(); + + return normalizedQuery + .split(/\s+/) + .every((term) => term.length === 0 || haystack.includes(term)); +} + function threadRecencyTimestamp(thread: Thread): number { if (typeof thread.updatedAt === "number") { return normalizeUnixTimestampSeconds(thread.updatedAt); @@ -1127,7 +1172,7 @@ function buildReadThreadSyncSignature( } function basenameFromPath(value: string): string { - const normalized = value.replaceAll("\\", "/").replace(/\/+$/, ""); + const normalized = normalizeProjectPath(value); if (!normalized) { return value; } @@ -1135,6 +1180,74 @@ function basenameFromPath(value: string): string { return parts[parts.length - 1] ?? normalized; } +function normalizeProjectPath(value: string): string { + return value.replaceAll("\\", "/").replace(/\/+$/, ""); +} + +function formatProjectPath(value: string): string { + return normalizeProjectPath(value).replace(/^\/Users\/[^/]+(?=\/|$)/, "~"); +} + +const CODEX_PROJECT_SECTION_LABEL = "项目"; +const CODEX_PROJECTLESS_SECTION_LABEL = "对话"; + +function isCodexProjectlessPath(value: string): boolean { + const normalized = normalizeProjectPath(value); + const marker = "/Documents/Codex/"; + const markerIndex = normalized.indexOf(marker); + if (markerIndex === -1) { + return false; + } + + const suffix = normalized.slice(markerIndex + marker.length); + return /^\d{4}-\d{2}-\d{2}(?:\/[^/]+|-.+)/.test(suffix); +} + +function getCodexWorktreeInfo( + value: string, +): { id: string; projectName: string } | null { + const normalized = normalizeProjectPath(value); + const match = normalized.match(/\/\.codex\/worktrees\/([^/]+)\/([^/]+)$/); + if (!match) { + return null; + } + return { id: match[1] ?? "", projectName: match[2] ?? "" }; +} + +function isCodexWorktreePath(value: string): boolean { + return getCodexWorktreeInfo(value) !== null; +} + +function projectGroupKey( + projectPath: string, + worktreeProjectNames: ReadonlySet, +): string { + const projectName = basenameFromPath(projectPath); + if (worktreeProjectNames.has(projectName)) { + return `project-family:${projectName}`; + } + return `project:${projectPath}`; +} + +function pushProjectPath(target: string[], value: string | null): void { + if (!value || target.includes(value)) { + return; + } + target.push(value); +} + +function choosePrimaryProjectPath(paths: readonly string[]): string | null { + const sortedPaths = [...paths].sort((a, b) => { + const aIsWorktree = isCodexWorktreePath(a) ? 1 : 0; + const bIsWorktree = isCodexWorktreePath(b) ? 1 : 0; + if (aIsWorktree !== bIsWorktree) { + return aIsWorktree - bIsWorktree; + } + return a.localeCompare(b); + }); + return sortedPaths[0] ?? null; +} + function normalizeManualGroupOrder( manualOrder: readonly string[], autoSortedKeys: readonly string[], @@ -1342,6 +1455,10 @@ export function App(): React.JSX.Element { () => getSavedServerBaseUrl() !== null, [], ); + const initialServerAccessKey = useMemo( + () => getServerAccessKey(initialServerBaseUrl), + [initialServerBaseUrl], + ); const initialSnapshot = ENABLE_VIEW_SNAPSHOT_CACHE ? appViewSnapshotCache : null; @@ -1412,19 +1529,35 @@ export function App(): React.JSX.Element { useState(initialServerBaseUrl); const [serverBaseUrlDraft, setServerBaseUrlDraft] = useState(initialServerBaseUrl); + const [serverAccessKey, setServerAccessKeyState] = + useState(initialServerAccessKey); + const [serverAccessKeyDraft, setServerAccessKeyDraft] = + useState(initialServerAccessKey); + const [accessKeyErrorMessage, setAccessKeyErrorMessage] = useState(""); const [hasSavedServerTarget, setHasSavedServerTarget] = useState( initialHasSavedServerBaseUrl, ); + const [serverProfiles, setServerProfiles] = useState(() => + readServerProfiles(), + ); + const [serverProfileNameDraft, setServerProfileNameDraft] = useState(() => + inferServerProfileName(initialServerBaseUrl), + ); const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false); + const [activeSettingsPanel, setActiveSettingsPanel] = + useState("connection"); /* UI state */ const [activeTab, setActiveTab] = useState<"chat" | "debug">( initialTab, ); + const [sidebarArchiveMode, setSidebarArchiveMode] = + useState("active"); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarDragOffset, setMobileSidebarDragOffset] = useState< number | null >(null); + const [sidebarSearchQuery, setSidebarSearchQuery] = useState(""); const [desktopSidebarOpen, setDesktopSidebarOpen] = useState(true); const [isChatAtBottom, setIsChatAtBottom] = useState(true); const [visibleChatItemLimit, setVisibleChatItemLimit] = useState( @@ -1528,12 +1661,39 @@ export function App(): React.JSX.Element { ); const selectedAgentLabel = selectedAgentDescriptor?.label ?? "Agent"; const reversedHistory = useMemo(() => history.slice().reverse(), [history]); - const hasServerBaseUrlDraftChanges = - serverBaseUrlDraft.trim() !== serverBaseUrl; + const showAccessKeyPrompt = accessKeyErrorMessage.length > 0; + const activeServerProfile = useMemo( + () => + serverProfiles.find((profile) => profile.baseUrl === serverBaseUrl) ?? null, + [serverBaseUrl, serverProfiles], + ); + const activeServerLabel = + activeServerProfile?.name ?? inferServerProfileName(serverBaseUrl); + const activeSettingsPanelCopy = + activeSettingsPanel === "connection" + ? { + title: "Connection", + description: "Add or update a Mac connection.", + } + : { + title: "Profiles", + description: "Switch between saved Mac connections.", + }; const unifiedWebSocketUrl = useMemo( () => getUnifiedWebSocketUrl(serverBaseUrl), [serverBaseUrl], ); + const normalizedSidebarSearchQuery = normalizeSidebarSearch(sidebarSearchQuery); + const isSidebarSearchActive = normalizedSidebarSearchQuery.length > 0; + const visibleSidebarThreads = useMemo( + () => + normalizedSidebarSearchQuery + ? threads.filter((thread) => + threadMatchesSidebarSearch(thread, normalizedSidebarSearchQuery), + ) + : threads, + [normalizedSidebarSearchQuery, threads], + ); const upsertSidebarThread = useCallback((threadSummary: Thread) => { setThreads((previousThreads) => { const nextThreads = (() => { @@ -1565,21 +1725,56 @@ export function App(): React.JSX.Element { key: string; label: string; projectPath: string | null; + projectPaths: string[]; latestUpdatedAt: number; preferredAgentId: AgentId | null; threads: Thread[]; userColor: string | null; }; const groups = new Map(); + const allProjectPaths: string[] = []; - for (const thread of threads) { + for (const thread of visibleSidebarThreads) { + if (typeof thread.cwd === "string" && thread.cwd.trim()) { + const projectPath = normalizeProjectPath(thread.cwd.trim()); + if (!isCodexProjectlessPath(projectPath)) { + allProjectPaths.push(projectPath); + } + } + } + for (const descriptor of agentDescriptors) { + for (const directory of descriptor.projectDirectories) { + if (directory.trim()) { + const projectPath = normalizeProjectPath(directory.trim()); + if (!isCodexProjectlessPath(projectPath)) { + allProjectPaths.push(projectPath); + } + } + } + } + + const worktreeProjectNames = new Set(); + for (const projectPath of allProjectPaths) { + if (isCodexWorktreePath(projectPath)) { + worktreeProjectNames.add(basenameFromPath(projectPath)); + } + } + + for (const thread of visibleSidebarThreads) { const cwd = typeof thread.cwd === "string" && thread.cwd.trim() - ? thread.cwd.trim() + ? normalizeProjectPath(thread.cwd.trim()) : null; const projectPath = cwd; - const key = projectPath ? `project:${projectPath}` : "project:unknown"; + if (projectPath && isCodexProjectlessPath(projectPath)) { + continue; + } + const key = projectPath + ? projectGroupKey(projectPath, worktreeProjectNames) + : "project:unknown"; const label = projectPath ? basenameFromPath(projectPath) : "Unknown"; + const groupProjectPath = projectPath; + const groupProjectPaths = groupProjectPath ? [groupProjectPath] : []; const updatedAt = threadRecencyTimestamp(thread); const threadAgentId = thread.provider; const projectColor = projectColors[key] ?? null; @@ -1587,6 +1782,8 @@ export function App(): React.JSX.Element { const existing = groups.get(key); if (existing) { existing.threads.push(thread); + pushProjectPath(existing.projectPaths, groupProjectPath); + existing.projectPath = choosePrimaryProjectPath(existing.projectPaths); if (!existing.preferredAgentId) { existing.preferredAgentId = threadAgentId; } @@ -1597,7 +1794,8 @@ export function App(): React.JSX.Element { groups.set(key, { key, label, - projectPath, + projectPath: groupProjectPath, + projectPaths: groupProjectPaths, latestUpdatedAt: updatedAt, preferredAgentId: threadAgentId, threads: [thread], @@ -1612,19 +1810,20 @@ export function App(): React.JSX.Element { if (!normalized) { continue; } - const key = `project:${normalized}`; - if (groups.has(key)) { + const projectPath = normalizeProjectPath(normalized); + if (isCodexProjectlessPath(projectPath)) { continue; } - groups.set(key, { - key, - label: basenameFromPath(normalized), - projectPath: normalized, - latestUpdatedAt: 0, - preferredAgentId: descriptor.id, - threads: [], - userColor: projectColors[key] ?? null, - }); + const key = projectGroupKey(projectPath, worktreeProjectNames); + const existing = groups.get(key); + if (!existing) { + continue; + } + pushProjectPath(existing.projectPaths, projectPath); + existing.projectPath = choosePrimaryProjectPath(existing.projectPaths); + if (!existing.preferredAgentId) { + existing.preferredAgentId = descriptor.id; + } } } @@ -1646,7 +1845,18 @@ export function App(): React.JSX.Element { ); return allGroups; - }, [agentDescriptors, projectColors, sidebarOrder, threads]); + }, [agentDescriptors, projectColors, sidebarOrder, visibleSidebarThreads]); + const conversationThreads = useMemo( + () => + visibleSidebarThreads + .filter((thread) => + typeof thread.cwd === "string" && thread.cwd.trim() + ? isCodexProjectlessPath(thread.cwd.trim()) + : false, + ) + .sort(compareThreadsByRecency), + [visibleSidebarThreads], + ); const activeLiveState = useMemo( () => (liveState?.threadId === selectedThreadId ? liveState : null), [liveState, selectedThreadId], @@ -1813,6 +2023,19 @@ export function App(): React.JSX.Element { return getLatestTokenUsageFromStreamEvents(streamEvents, selectedThreadId); }, [conversationState?.latestTokenUsageInfo, selectedThreadId, streamEvents]); + const selectedThreadProjectPath = useMemo(() => { + const cwd = selectedThread?.cwd; + return typeof cwd === "string" && cwd.trim() + ? normalizeProjectPath(cwd.trim()) + : null; + }, [selectedThread?.cwd]); + const selectedThreadWorktreeInfo = useMemo( + () => + selectedThreadProjectPath + ? getCodexWorktreeInfo(selectedThreadProjectPath) + : null, + [selectedThreadProjectPath], + ); const planModeOption = useMemo( () => modes.find((mode) => isPlanModeOption(mode)) ?? null, @@ -2113,7 +2336,7 @@ export function App(): React.JSX.Element { health?.state.ipcInitialized === false : !openCodeConnected; /* Data loading */ - const loadCoreData = useCallback(async () => { + const loadCoreData = useCallback(async (options?: { archiveMode?: SidebarArchiveMode }) => { setIsCoreLoading(true); try { const shouldLoadDebugData = activeTabRef.current === "debug"; @@ -2136,11 +2359,13 @@ export function App(): React.JSX.Element { const rateLimitsPromise = DISABLE_RATE_LIMITS ? Promise.resolve(null) : getAccountRateLimits().catch(() => null); + const requestedArchiveMode = options?.archiveMode ?? sidebarArchiveMode; + const isArchivedView = requestedArchiveMode === "archived"; const sidebarPromise = listSidebarThreads({ - limit: 80, - archived: false, - all: false, - maxPages: 1, + limit: isArchivedView ? 120 : 50, + archived: isArchivedView, + all: isArchivedView, + maxPages: isArchivedView ? 20 : 1, }); const tracePromise = shouldLoadDebugData ? getTraceStatus() @@ -2176,8 +2401,7 @@ export function App(): React.JSX.Element { continue; } const shouldKeepThread = - optimisticSelectedThreadIdsRef.current.has(thread.id) || - thread.id === selectedThreadIdRef.current; + optimisticSelectedThreadIdsRef.current.has(thread.id); if (!shouldKeepThread) { continue; } @@ -2454,7 +2678,7 @@ export function App(): React.JSX.Element { } finally { setIsCoreLoading(false); } - }, [agentDescriptors, selectedAgentId]); + }, [agentDescriptors, selectedAgentId, sidebarArchiveMode]); const loadSelectedThread = useCallback( async ( @@ -2571,7 +2795,6 @@ export function App(): React.JSX.Element { const nextIsGenerating = live.conversationState ? isThreadGeneratingState(live.conversationState) : isThreadGeneratingState(read.thread); - const selectedSummary = buildThreadSummaryFromReadThread(read.thread); let sawThread = false; const nextThreads = previousThreads.map((threadSummary) => { if (threadSummary.id !== read.thread.id) { @@ -2604,10 +2827,6 @@ export function App(): React.JSX.Element { ...(nextTitle !== undefined ? { title: nextTitle } : {}), }; }); - if (!sawThread) { - nextThreads.push(selectedSummary); - } - const sortedThreads = sortThreadsByRecency(nextThreads); const nextSignature = buildThreadsSignature(sortedThreads); if (signaturesMatch(threadsSignatureRef.current, nextSignature)) { @@ -2718,36 +2937,187 @@ export function App(): React.JSX.Element { } }, [loadCoreData, loadSelectedThread]); - const saveServerTarget = useCallback(async () => { - try { + const switchSidebarArchiveMode = useCallback( + (mode: SidebarArchiveMode) => { + if (mode === sidebarArchiveMode) { + return; + } + setSidebarArchiveMode(mode); + threadsSignatureRef.current = []; + setThreads([]); + setSidebarCollapsedGroups({}); + void loadCoreData({ archiveMode: mode }); + }, + [loadCoreData, sidebarArchiveMode], + ); + + const runSetThreadArchived = useCallback( + async (thread: Thread, archived: boolean) => { + const movesOutOfCurrentView = + (sidebarArchiveMode === "active" && archived) || + (sidebarArchiveMode === "archived" && !archived); + setIsBusy(true); + try { + setError(""); + await setThreadArchived({ + threadId: thread.id, + provider: thread.provider, + archived, + }); + threadsSignatureRef.current = []; + setThreads((previousThreads) => + movesOutOfCurrentView + ? previousThreads.filter((entry) => entry.id !== thread.id) + : previousThreads, + ); + if (movesOutOfCurrentView && selectedThreadIdRef.current === thread.id) { + selectedThreadIdRef.current = null; + setSelectedThreadId(null); + setLiveState(null); + setReadThreadState(null); + setStreamEvents([]); + } + await loadCoreData({ archiveMode: sidebarArchiveMode }); + } catch (e) { + setError(toErrorMessage(e)); + } finally { + setIsBusy(false); + } + }, + [loadCoreData, sidebarArchiveMode], + ); + + const clearServerScopedState = useCallback(() => { + selectedThreadIdRef.current = null; + setSelectedThreadId(null); + setThreads([]); + setThreadListErrors({ codex: null, opencode: null }); + setLiveState(null); + setReadThreadState(null); + setStreamEvents([]); + setSelectedRequestId(null); + setAnswerDraft({}); + setAgentDescriptors([]); + setHistory([]); + setHistoryDetail(null); + setSelectedHistoryId(""); + agentCacheRef.current = null; + providerCatalogCacheRef.current.clear(); + realtimeSocketRef.current?.disconnect(); + realtimeSocketRef.current = null; + }, []); + + const switchServerTarget = useCallback( + async (baseUrl: string, options?: { saved: boolean }) => { setError(""); - const normalizedBaseUrl = setServerBaseUrl(serverBaseUrlDraft); + const normalizedBaseUrl = setServerBaseUrl(baseUrl); + const nextAccessKey = getServerAccessKey(normalizedBaseUrl); setServerBaseUrlState(normalizedBaseUrl); setServerBaseUrlDraft(normalizedBaseUrl); - setHasSavedServerTarget(true); - agentCacheRef.current = null; - providerCatalogCacheRef.current.clear(); - await refreshAll(); + setServerAccessKeyState(nextAccessKey); + setServerAccessKeyDraft(nextAccessKey); + setAccessKeyErrorMessage(""); + setServerProfileNameDraft(inferServerProfileName(normalizedBaseUrl)); + setHasSavedServerTarget(options?.saved ?? true); + clearServerScopedState(); + await loadCoreData(); + }, + [clearServerScopedState, loadCoreData], + ); + + const saveServerTarget = useCallback(async () => { + try { + setError(""); + const profileName = + serverProfileNameDraft.trim() || + inferServerProfileName(serverBaseUrlDraft); + const nextProfiles = upsertServerProfile({ + name: profileName, + baseUrl: serverBaseUrlDraft, + }); + const savedAccessKey = setServerAccessKey( + serverBaseUrlDraft, + serverAccessKeyDraft, + ); + setServerProfiles(nextProfiles); + setServerAccessKeyState(savedAccessKey); + setServerAccessKeyDraft(savedAccessKey); + setAccessKeyErrorMessage(""); + await switchServerTarget(serverBaseUrlDraft, { saved: true }); + setServerProfileNameDraft(profileName); + } catch (e) { + setError(toErrorMessage(e)); + } + }, [ + serverAccessKeyDraft, + serverBaseUrlDraft, + serverProfileNameDraft, + switchServerTarget, + ]); + + const saveAccessKeyAndRetry = useCallback(async () => { + try { + setError(""); + const savedAccessKey = setServerAccessKey( + serverBaseUrl, + serverAccessKeyDraft, + ); + setServerAccessKeyState(savedAccessKey); + setServerAccessKeyDraft(savedAccessKey); + setAccessKeyErrorMessage(""); + clearServerScopedState(); + await loadCoreData(); + if (selectedThreadIdRef.current) { + await loadSelectedThread(selectedThreadIdRef.current, { + includeTurns: true, + includeStreamEvents: activeTabRef.current === "debug", + }); + } } catch (e) { setError(toErrorMessage(e)); } - }, [refreshAll, serverBaseUrlDraft]); + }, [ + clearServerScopedState, + loadCoreData, + loadSelectedThread, + serverAccessKeyDraft, + serverBaseUrl, + ]); + + const applyServerProfile = useCallback( + async (profile: ServerProfile) => { + try { + await switchServerTarget(profile.baseUrl, { saved: true }); + } catch (e) { + setError(toErrorMessage(e)); + } + }, + [switchServerTarget], + ); + + const deleteServerProfile = useCallback((profileId: string) => { + setServerProfiles(removeServerProfile(profileId)); + }, []); const useDefaultServerTarget = useCallback(async () => { try { setError(""); clearServerBaseUrl(); const defaultBaseUrl = getDefaultServerBaseUrl(); + const defaultAccessKey = getServerAccessKey(defaultBaseUrl); setServerBaseUrlState(defaultBaseUrl); setServerBaseUrlDraft(defaultBaseUrl); + setServerAccessKeyState(defaultAccessKey); + setServerAccessKeyDraft(defaultAccessKey); + setAccessKeyErrorMessage(""); + setServerProfileNameDraft(inferServerProfileName(defaultBaseUrl)); setHasSavedServerTarget(false); - agentCacheRef.current = null; - providerCatalogCacheRef.current.clear(); - await refreshAll(); + clearServerScopedState(); + await loadCoreData(); } catch (e) { setError(toErrorMessage(e)); } - }, [refreshAll]); + }, [clearServerScopedState, loadCoreData]); const applyRealtimeCoreState = useCallback( (coreState: UnifiedRealtimeCoreState) => { @@ -2782,8 +3152,7 @@ export function App(): React.JSX.Element { continue; } const shouldKeepThread = - optimisticSelectedThreadIdsRef.current.has(thread.id) || - thread.id === selectedThreadIdRef.current; + optimisticSelectedThreadIdsRef.current.has(thread.id); if (!shouldKeepThread) { continue; } @@ -3189,10 +3558,26 @@ export function App(): React.JSX.Element { }).catch((e) => setError(toErrorMessage(e))); }, [selectedThreadId]); + useEffect(() => { + const onAccessKeyError = (event: Event) => { + const detail = (event as CustomEvent<{ message?: string }>).detail; + const message = detail?.message ?? "Access key required"; + setAccessKeyErrorMessage(message); + setError(message); + }; + + window.addEventListener("farfield:access-key-error", onAccessKeyError); + return () => { + window.removeEventListener("farfield:access-key-error", onAccessKeyError); + }; + }, []); + useEffect(() => { const socket = createUnifiedRealtimeSocket({ socketUrl: unifiedWebSocketUrl, + accessKey: serverAccessKey, onConnect: () => { + setAccessKeyErrorMessage(""); socket.send({ kind: "hello", selectedThreadId: selectedThreadIdRef.current, @@ -3202,6 +3587,10 @@ export function App(): React.JSX.Element { onDisconnect: () => { // No-op. Socket.IO handles reconnect. }, + onAuthError: (message) => { + setAccessKeyErrorMessage(message); + setError(message); + }, onProtocolError: (message) => { setError(message); }, @@ -3248,7 +3637,7 @@ export function App(): React.JSX.Element { window.removeEventListener("pageshow", onPageShow); disconnectSocket(); }; - }, [handleRealtimeMessage, unifiedWebSocketUrl]); + }, [handleRealtimeMessage, serverAccessKey, unifiedWebSocketUrl]); useEffect(() => { if (!activeRequest) { @@ -3919,6 +4308,120 @@ export function App(): React.JSX.Element { [availableAgentIds, createNewThread], ); + const renderSidebarThreadRow = useCallback( + (thread: Thread, colorAccent: string | null = null): React.JSX.Element => { + const isSelected = thread.id === selectedThreadId; + const threadIsGenerating = + Boolean(thread.isGenerating) || (isSelected && isGenerating); + const waitingOnApproval = + isSelected && selectedThreadWaitingState + ? selectedThreadWaitingState.waitingOnApproval + : Boolean(thread.waitingOnApproval); + const waitingOnUserInput = + isSelected && selectedThreadWaitingState + ? selectedThreadWaitingState.waitingOnUserInput + : Boolean(thread.waitingOnUserInput); + const hasWaitingIndicator = waitingOnApproval || waitingOnUserInput; + const archiveAction = + sidebarArchiveMode === "active" + ? { + label: "Archive thread", + archived: true, + icon: , + } + : { + label: "Restore thread", + archived: false, + icon: , + }; + + return ( +
+ + {archiveAction && ( + + )} +
+ ); + }, + [ + agentsById, + closeMobileSidebar, + isBusy, + isGenerating, + runSetThreadArchived, + selectedThreadId, + selectedThreadWaitingState, + showProviderIcons, + sidebarArchiveMode, + ], + ); + const beginOpenSidebarSwipe = useCallback( (event: React.TouchEvent) => { if (mobileSidebarOpen) { @@ -4050,9 +4553,33 @@ export function App(): React.JSX.Element { aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 -bottom-3 bg-gradient-to-b from-sidebar from-58% via-sidebar/88 via-80% to-transparent to-100%" /> -
+
Farfield -
+
+ {viewport === "desktop" && ( setDesktopSidebarOpen(false)} @@ -4073,9 +4600,43 @@ export function App(): React.JSX.Element {
+
+
+ + setSidebarSearchQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape" && sidebarSearchQuery) { + event.preventDefault(); + setSidebarSearchQuery(""); + } + }} + placeholder="Search threads" + aria-label="Search threads" + className="h-8 rounded-lg bg-background/70 pl-8 pr-8 text-xs shadow-none" + /> + {sidebarSearchQuery && ( + + )} +
+
+
- {threads.length === 0 && ( + {visibleSidebarThreads.length === 0 && (
{isCoreLoading ? ( @@ -4117,12 +4678,18 @@ export function App(): React.JSX.Element {
+ ) : isSidebarSearchActive ? ( + "No matching threads" ) : ( - "No threads" + sidebarArchiveMode === "archived" + ? "No archived threads" + : "No active threads" )}
{!isCoreLoading && !sidebarProviderConnectionState && + !isSidebarSearchActive && + sidebarArchiveMode === "active" && availableAgentIds.length > 0 && (availableAgentIds.length === 1 ? ( - ); - })} + {group.threads.map((thread) => + renderSidebarThreadRow(thread, colorAccent), + )}
)}
); })} + {conversationThreads.length > 0 && ( +
+
+ {CODEX_PROJECTLESS_SECTION_LABEL} +
+
+ {conversationThreads.map((thread) => + renderSidebarThreadRow(thread, null), + )} +
+
+ )}
@@ -4790,6 +5309,71 @@ export function App(): React.JSX.Element {
+ {serverAccessKey && ( + + + + + + Access key saved for this server + + + )} + {selectedThreadProjectPath && ( + + + + + +
+
+
+ Project +
+
+ {basenameFromPath(selectedThreadProjectPath)} +
+
+
+ {formatProjectPath(selectedThreadProjectPath)} +
+ {selectedThreadWorktreeInfo ? ( +
+ + Codex worktree + + + {selectedThreadWorktreeInfo.id} + +
+ ) : ( +
+ Main project directory +
+ )} +
+
+
+ )} {showUsageBadges && rateLimits && (() => { const windows: Array<{ label: string; @@ -4943,9 +5527,62 @@ export function App(): React.JSX.Element { : "flex-1 min-h-0 flex flex-col" } > - {/* Error bar */} + {/* Access key prompt */} + + {showAccessKeyPrompt && ( + +
+
+ + + +
+
+ {accessKeyErrorMessage || "Access key required"} +
+
+ Enter the key for {activeServerLabel} to reconnect. +
+
+
+
+ + setServerAccessKeyDraft(event.target.value) + } + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void saveAccessKeyAndRetry(); + } + }} + placeholder="Access key" + className="h-9 min-w-0 flex-1 bg-background text-base text-foreground shadow-none md:text-sm" + /> + +
+
+
+ )} +
- {error && ( + {error && error !== accessKeyErrorMessage && ( setIsSettingsModalOpen(false)} > event.stopPropagation()} - className="w-full max-w-xl rounded-xl border border-border bg-background shadow-2xl overflow-hidden" + className="flex h-full w-full flex-col overflow-hidden border-border bg-background shadow-2xl md:grid md:h-[min(88vh,840px)] md:max-w-5xl md:grid-cols-[240px_minmax(0,1fr)] md:rounded-2xl md:border" > -
-
-
Settings
-
- Configure how this frontend connects to your server. -
-
- -
- -
-
- -
- Use your Tailscale HTTPS URL. + + +
+
+
+
+ {activeSettingsPanelCopy.title} +
+
+ {activeSettingsPanelCopy.description} +
+
-
- Active: {serverBaseUrl} -
-
- Mode:{" "} - {hasSavedServerTarget - ? "Saved server target" - : "Automatic server target"} +
+
+ {activeSettingsPanel === "connection" && ( +
+
+

Connection

+

+ Save a Mac URL with its own local access key. +

+
+ +
+
+
+ +
+ Use the Tailscale HTTPS address for this Mac. +
+
+ setServerBaseUrlDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void saveServerTarget(); + } + }} + placeholder="https://your-mac.tailnet.ts.net" + className="h-9 text-base md:text-sm" + /> +
+ +
+
+ +
+ Stored separately for this server URL. +
+
+ + setServerAccessKeyDraft(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void saveServerTarget(); + } + }} + placeholder="Leave empty for no key" + className="h-9 text-base md:text-sm" + /> +
+ +
+
+ +
+ Used in the Profiles list. +
+
+ + setServerProfileNameDraft(e.target.value) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void saveServerTarget(); + } + }} + placeholder="MacBook Pro" + className="h-9 text-base md:text-sm" + /> +
+ +
+
+
Current target
+
+ {serverBaseUrl} +
+
+
+ + +
+
+
+
+ )} + + {activeSettingsPanel === "profiles" && ( +
+
+

Profiles

+

+ Keep one saved connection for each Mac. +

+
+ +
+
+ {serverProfiles.length > 0 ? ( + serverProfiles.map((profile) => { + const isActive = profile.baseUrl === serverBaseUrl; + const hasProfileAccessKey = + getServerAccessKey(profile.baseUrl).length > 0; + return ( +
+ + +
+ ); + }) + ) : ( +
+ No saved profiles yet. +
+ )} +
+
+
+ )} + +
-
+
)} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 3fad0c19..bc937264 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -31,10 +31,13 @@ import { buildServerUrl, buildServerWebSocketUrl, clearStoredServerTarget, + clearStoredServerAccessKey, getDefaultServerBaseUrl as getDefaultStoredServerBaseUrl, parseServerBaseUrl, + readStoredServerAccessKey, readStoredServerTarget, resolveServerBaseUrl, + saveServerAccessKey, saveServerBaseUrl, } from "./server-target"; @@ -385,6 +388,15 @@ const ReadThreadResponseSchema = z }) .strict(); +const ArchiveThreadResponseSchema = z + .object({ + ok: z.literal(true), + threadId: z.string(), + provider: UnifiedProviderIdSchema, + archived: z.boolean(), + }) + .passthrough(); + const ModelsResponseSchema = z .object({ data: z.array(UnifiedModelSchema), @@ -461,6 +473,21 @@ export function clearServerBaseUrl(): void { clearStoredServerTarget(); } +export function getServerAccessKey(baseUrlOverride?: string): string { + return readStoredServerAccessKey(baseUrlOverride); +} + +export function setServerAccessKey( + baseUrl: string, + accessKey: string, +): string { + return saveServerAccessKey(baseUrl, accessKey).accessKey; +} + +export function clearServerAccessKey(baseUrl: string): void { + clearStoredServerAccessKey(baseUrl); +} + export function normalizeServerBaseUrl(value: string): string { return parseServerBaseUrl(value); } @@ -473,7 +500,15 @@ async function requestJson( path: string, init?: RequestInit, ): Promise<{ response: Response; payload: JsonValue }> { - const response = await fetch(buildServerUrl(path), init); + const headers = new Headers(init?.headers); + const accessKey = readStoredServerAccessKey(); + if (accessKey) { + headers.set("X-Farfield-Access-Key", accessKey); + } + const response = await fetch(buildServerUrl(path), { + ...init, + headers, + }); const payload = JsonValueSchema.parse(await response.json()); return { response, @@ -523,6 +558,26 @@ function buildApiRequestError(payload: JsonValue): ApiRequestError { }); } +function notifyAccessKeyError(error: ApiRequestError): void { + if ( + error.code !== "accessKeyRequired" && + error.code !== "accessKeyInvalid" + ) { + return; + } + if (typeof window === "undefined") { + return; + } + window.dispatchEvent( + new CustomEvent("farfield:access-key-error", { + detail: { + code: error.code, + message: error.message, + }, + }), + ); +} + async function requestEnvelope( path: string, schema: z.ZodType, @@ -531,12 +586,16 @@ async function requestEnvelope( const { response, payload } = await requestJson(path, init); if (!response.ok) { - throw buildApiRequestError(payload); + const error = buildApiRequestError(payload); + notifyAccessKeyError(error); + throw error; } const envelope = ApiEnvelopeSchema.parse(payload); if (!envelope.ok) { - throw buildApiRequestError(payload); + const error = buildApiRequestError(payload); + notifyAccessKeyError(error); + throw error; } return schema.parse(payload); @@ -555,7 +614,9 @@ async function runUnifiedCommand( }); if (!response.ok) { - throw buildApiRequestError(payload); + const error = buildApiRequestError(payload); + notifyAccessKeyError(error); + throw error; } const commandResponse = UnifiedCommandResponseSchema.parse(payload); @@ -765,6 +826,26 @@ export async function readThread( }); } +export async function setThreadArchived(input: { + threadId: string; + provider?: AgentId; + archived: boolean; +}): Promise> { + const params = new URLSearchParams(); + if (typeof input.provider === "string") { + params.set("provider", input.provider); + } + const query = params.toString(); + const action = input.archived ? "archive" : "unarchive"; + return requestEnvelope( + `/api/unified/thread/${encodeURIComponent(input.threadId)}/${action}${query.length > 0 ? `?${query}` : ""}`, + ArchiveThreadResponseSchema, + { + method: "POST", + }, + ); +} + export async function createThread(input?: { agentId?: AgentId; cwd?: string; diff --git a/apps/web/src/lib/realtime-socket.ts b/apps/web/src/lib/realtime-socket.ts index 34bd6ae3..cd45e91e 100644 --- a/apps/web/src/lib/realtime-socket.ts +++ b/apps/web/src/lib/realtime-socket.ts @@ -18,9 +18,11 @@ export interface UnifiedRealtimeSocket { export function createUnifiedRealtimeSocket(input: { socketUrl: string; + accessKey?: string; onMessage: (message: UnifiedRealtimeServerMessage) => void; onConnect?: () => void; onDisconnect?: () => void; + onAuthError?: (message: string) => void; onProtocolError?: (message: string) => void; }): UnifiedRealtimeSocket { const parsedSocketUrl = new URL(input.socketUrl); @@ -33,6 +35,7 @@ export function createUnifiedRealtimeSocket(input: { reconnection: true, reconnectionDelay: 1_000, reconnectionDelayMax: 10_000, + ...(input.accessKey ? { auth: { accessKey: input.accessKey } } : {}), }, ); @@ -44,6 +47,19 @@ export function createUnifiedRealtimeSocket(input: { input.onDisconnect?.(); }); + socket.on("connect_error", (error) => { + if ( + error.message === "accessKeyRequired" || + error.message === "accessKeyInvalid" + ) { + input.onAuthError?.( + error.message === "accessKeyRequired" + ? "Access key required" + : "Invalid access key", + ); + } + }); + socket.on(REALTIME_SERVER_EVENT, (payload: JsonValue) => { const parsed = UnifiedRealtimeServerMessageSchema.safeParse(payload); if (!parsed.success) { diff --git a/apps/web/src/lib/server-profiles.ts b/apps/web/src/lib/server-profiles.ts new file mode 100644 index 00000000..9c8153c1 --- /dev/null +++ b/apps/web/src/lib/server-profiles.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; +import { parseServerBaseUrl } from "@/lib/server-target"; + +const STORAGE_KEY = "farfield.server-profiles.v1"; + +const ServerProfileSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + baseUrl: z.string().min(1), + }) + .strict(); + +const ServerProfilesSchema = z.array(ServerProfileSchema); + +export type ServerProfile = z.infer; + +function slugify(value: string): string { + const slug = value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return slug || "server"; +} + +export function inferServerProfileName(baseUrl: string): string { + try { + const url = new URL(baseUrl); + const host = url.hostname.replace(/\.tail[0-9a-z-]+\.ts\.net$/i, ""); + return host || url.hostname || "Server"; + } catch { + return "Server"; + } +} + +function makeProfileId(name: string, baseUrl: string): string { + return `${slugify(name)}-${slugify(baseUrl)}`.slice(0, 96); +} + +function parseStoredProfiles(raw: string): ServerProfile[] { + const parsed = JSON.parse(raw); + const result = ServerProfilesSchema.safeParse(parsed); + return result.success ? result.data : []; +} + +export function readServerProfiles(): ServerProfile[] { + if (typeof window === "undefined") { + return []; + } + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + try { + return parseStoredProfiles(raw); + } catch { + return []; + } +} + +function writeServerProfiles(profiles: ServerProfile[]): ServerProfile[] { + if (typeof window !== "undefined") { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(profiles)); + } + return profiles; +} + +export function upsertServerProfile(input: { + name: string; + baseUrl: string; +}): ServerProfile[] { + const name = input.name.trim() || inferServerProfileName(input.baseUrl); + const baseUrl = parseServerBaseUrl(input.baseUrl); + const current = readServerProfiles(); + const existingIndex = current.findIndex( + (profile) => profile.baseUrl === baseUrl || profile.name === name, + ); + const existing = existingIndex >= 0 ? current[existingIndex] : null; + const nextProfile: ServerProfile = { + id: existing?.id ?? makeProfileId(name, baseUrl), + name, + baseUrl, + }; + const next = + existingIndex >= 0 + ? current.map((profile, index) => + index === existingIndex ? nextProfile : profile, + ) + : [...current, nextProfile]; + return writeServerProfiles(next); +} + +export function removeServerProfile(profileId: string): ServerProfile[] { + return writeServerProfiles( + readServerProfiles().filter((profile) => profile.id !== profileId), + ); +} diff --git a/apps/web/src/lib/server-target.ts b/apps/web/src/lib/server-target.ts index 749b8f7a..86e41bdd 100644 --- a/apps/web/src/lib/server-target.ts +++ b/apps/web/src/lib/server-target.ts @@ -1,6 +1,7 @@ import { z } from "zod"; const STORAGE_KEY = "farfield.server-target.v1"; +const ACCESS_KEYS_STORAGE_KEY = "farfield.server-access-keys.v1"; const DEFAULT_SERVER_PORT = 4311; const ServerProtocolSchema = z.enum(["http:", "https:"]); @@ -67,6 +68,8 @@ const StoredServerTargetTextSchema = z.string().transform((raw, ctx) => { } }); +const StoredServerAccessKeysSchema = z.record(z.string(), z.string()); + const ApiPathSchema = z .string() .min(1, "API path is required") @@ -74,6 +77,32 @@ const ApiPathSchema = z export type StoredServerTarget = z.infer; +function readStoredAccessKeyMap(): Record { + if (typeof window === "undefined") { + return {}; + } + + const raw = window.localStorage.getItem(ACCESS_KEYS_STORAGE_KEY); + if (!raw) { + return {}; + } + + try { + const parsed = JSON.parse(raw); + const result = StoredServerAccessKeysSchema.safeParse(parsed); + return result.success ? result.data : {}; + } catch { + return {}; + } +} + +function writeStoredAccessKeyMap(value: Record): void { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem(ACCESS_KEYS_STORAGE_KEY, JSON.stringify(value)); +} + function isLocalHost(hostname: string): boolean { return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; } @@ -131,6 +160,37 @@ export function clearStoredServerTarget(): void { window.localStorage.removeItem(STORAGE_KEY); } +export function readStoredServerAccessKey(baseUrlOverride?: string): string { + const baseUrl = + typeof baseUrlOverride === "string" + ? parseServerBaseUrl(baseUrlOverride) + : resolveServerBaseUrl(); + return readStoredAccessKeyMap()[baseUrl] ?? ""; +} + +export function saveServerAccessKey( + baseUrl: string, + accessKey: string, +): { baseUrl: string; accessKey: string } { + const parsedBaseUrl = parseServerBaseUrl(baseUrl); + const trimmedAccessKey = accessKey.trim(); + const accessKeys = readStoredAccessKeyMap(); + if (trimmedAccessKey) { + accessKeys[parsedBaseUrl] = trimmedAccessKey; + } else { + delete accessKeys[parsedBaseUrl]; + } + writeStoredAccessKeyMap(accessKeys); + return { + baseUrl: parsedBaseUrl, + accessKey: trimmedAccessKey, + }; +} + +export function clearStoredServerAccessKey(baseUrl: string): void { + saveServerAccessKey(baseUrl, ""); +} + export function resolveServerBaseUrl(): string { const stored = readStoredServerTarget(); if (stored) { diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d3e21c4b..3ae6a799 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -85,9 +85,28 @@ export default defineConfig(({ command }) => { name: "Farfield", short_name: "Farfield", start_url: "/", + scope: "/", display: "standalone", theme_color: "#0a0a0b", - background_color: "#0a0a0b" + background_color: "#0a0a0b", + icons: [ + { + src: "/pwa-192.png", + sizes: "192x192", + type: "image/png" + }, + { + src: "/pwa-512.png", + sizes: "512x512", + type: "image/png" + }, + { + src: "/maskable-512.png", + sizes: "512x512", + type: "image/png", + purpose: "maskable" + } + ] }, workbox: { globPatterns: ["**/*.{js,css,html,svg,png,woff2}"] diff --git a/packages/codex-api/src/app-server-client.ts b/packages/codex-api/src/app-server-client.ts index 7629d54c..a98ef247 100644 --- a/packages/codex-api/src/app-server-client.ts +++ b/packages/codex-api/src/app-server-client.ts @@ -75,6 +75,14 @@ export interface SteerTurnOptions { input: TurnStartParams["input"]; } +export interface ArchiveThreadOptions { + threadId: string; +} + +export interface UnarchiveThreadOptions { + threadId: string; +} + const AppServerResumeThreadRequestSchema = z .object({ threadId: z.string().min(1), @@ -161,6 +169,18 @@ export class AppServerClient { return parseWithSchema(AppServerListThreadsResponseSchema, result, "AppServerListThreadsResponse"); } + public async archiveThread(options: ArchiveThreadOptions): Promise { + await this.transport.request("thread/archive", { + threadId: options.threadId + }); + } + + public async unarchiveThread(options: UnarchiveThreadOptions): Promise { + await this.transport.request("thread/unarchive", { + threadId: options.threadId + }); + } + public async listLoadedThreads( options: ListLoadedThreadsOptions = {} ): Promise { diff --git a/packages/codex-protocol/src/thread.ts b/packages/codex-protocol/src/thread.ts index 9c3cd1ba..c92a88ce 100644 --- a/packages/codex-protocol/src/thread.ts +++ b/packages/codex-protocol/src/thread.ts @@ -45,7 +45,17 @@ export const InputImagePartSchema = z }) .passthrough(); -export const InputPartSchema = z.union([InputTextPartSchema, InputImagePartSchema]); +export const GenericInputPartSchema = z + .object({ + type: NonEmptyStringSchema + }) + .passthrough(); + +export const InputPartSchema = z.union([ + InputTextPartSchema, + InputImagePartSchema, + GenericInputPartSchema +]); export const TurnStartParamsSchema = z .object({ @@ -141,7 +151,7 @@ export const ErrorItemSchema = z type: z.literal("error"), message: z.string(), willRetry: z.boolean().optional(), - errorInfo: z.union([z.string(), z.null()]).optional(), + errorInfo: z.union([JsonValueSchema, z.null()]).optional(), additionalDetails: z.union([JsonValueSchema, z.null()]).optional() }) .passthrough(); diff --git a/packages/unified-surface/src/index.ts b/packages/unified-surface/src/index.ts index 986f8670..ac5b285a 100644 --- a/packages/unified-surface/src/index.ts +++ b/packages/unified-surface/src/index.ts @@ -555,7 +555,7 @@ const UnifiedErrorItemSchema = z type: z.literal("error"), message: z.string(), willRetry: z.boolean().optional(), - errorInfo: NullableStringSchema.optional(), + errorInfo: z.union([JsonValueSchema, z.null()]).optional(), additionalDetails: z.union([JsonValueSchema, z.null()]).optional() }) .strict();