From 608a7b7845bc4b4c14d05c113789ec1328f80901 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 04:26:48 +0000 Subject: [PATCH] Replace ad-hoc parsing with Zod schemas; dedupe shared helpers Centralizes validation of disk + IPC payloads in Zod schemas, shares project/broker-event helpers between the main and renderer processes, and removes dead code and unsafe casts. Highlights: - src/shared/schemas/project.ts: single source of truth for Project / ProjectRoot / ProjectIntegration shape, used by both the main process store and the renderer project store (was duplicated ~110 lines x2). - src/main/schemas.ts: Zod schemas for StoredTokens, AuthMeta, UserInfo, broker connection.json, generated commit drafts, and the avatar cache manifest. Replaces `JSON.parse(...) as Record` plus hand-rolled type guards. - ui-store: validate localStorage theme/layout with z.enum instead of casting `as Theme | null` / `as TerminalLayout | null`. - git-store: narrow `pear.git.status` return type so the `as FileStatus[]` cast is gone. - preload: collapse 4 `as Promise` casts into one generic `invoke` helper; drop dead `broker:send-input` IPC handler / type / wrapper that no caller used. - broker.ts: replace two `(err as { status?: unknown }).status` casts with a tiny `getErrorStatus` helper; narrow BrokerEvent with `in` operator instead of accessing fields that don't exist on every variant. - shared/lib/broker-events.ts: single `compactBrokerEvent` / `normalizeEventTimestamp` for both main and renderer (was duplicated). - format.ts: extract `formatClockTime` / `formatRelativeShort` shared by ChatMessage and ThreadPanel. normalizeUserInfo fix (review feedback): Zod preserves keys with `undefined` values from the input candidate, so the previous `Object.keys(user).length > 0` check would always be true and an empty whoami payload would shadow previously cached fields via mergeUserInfo (clearing the cached avatar URL on every refresh). Now strips undefined entries before the emptiness check, preserving the prior semantics. Net effect on `tsc -b`: 5 baseline errors fewer than main, 0 new errors, 0 new `as any` / `@ts-ignore` / `@ts-expect-error`. `npm run build` succeeds. https://claude.ai/code/session_013TyabW37ec75uPpdFhYkS5 --- .gitignore | 1 + electron.vite.config.ts | 3 +- package-lock.json | 2 +- package.json | 1 + src/main/auth.ts | 129 ++++----- src/main/avatar-cache.ts | 26 +- src/main/broker.ts | 89 ++---- src/main/ipc-handlers.ts | 4 - src/main/schemas.ts | 103 +++++++ src/main/store.ts | 268 ++++-------------- src/preload/index.ts | 150 +++++----- .../src/components/chat/ChatMessage.tsx | 26 +- .../src/components/chat/ThreadPanel.tsx | 7 +- src/renderer/src/lib/format.ts | 16 ++ src/renderer/src/lib/ipc.ts | 12 +- src/renderer/src/stores/agent-store.ts | 27 +- src/renderer/src/stores/git-store.ts | 10 +- src/renderer/src/stores/project-store.ts | 195 +++---------- src/renderer/src/stores/ui-store.ts | 25 +- src/shared/lib/broker-events.ts | 31 ++ src/shared/schemas/project.ts | 160 +++++++++++ tsconfig.node.json | 1 + tsconfig.web.json | 7 +- 23 files changed, 620 insertions(+), 673 deletions(-) create mode 100644 src/main/schemas.ts create mode 100644 src/shared/lib/broker-events.ts create mode 100644 src/shared/schemas/project.ts diff --git a/.gitignore b/.gitignore index f9a295fa..f9e6d20d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ *.env .DS_Store /.agent-relay +*.tsbuildinfo diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ef90885e..c9da17bb 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -18,7 +18,8 @@ export default defineConfig({ renderer: { resolve: { alias: { - '@': resolve('src/renderer/src') + '@': resolve('src/renderer/src'), + '@shared': resolve('src/shared') } }, plugins: [react(), tailwindcss()] diff --git a/package-lock.json b/package-lock.json index 161ac7ff..0ef9b478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "react-dom": "^19.0.0", "shiki": "^4.0.2", "unidiff": "^1.0.4", + "zod": "^3.25.76", "zustand": "^5.0.0" }, "devDependencies": { @@ -1224,7 +1225,6 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 38960ff3..414993e7 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "react-dom": "^19.0.0", "shiki": "^4.0.2", "unidiff": "^1.0.4", + "zod": "^3.25.76", "zustand": "^5.0.0" }, "devDependencies": { diff --git a/src/main/auth.ts b/src/main/auth.ts index 4750c753..3d837341 100644 --- a/src/main/auth.ts +++ b/src/main/auth.ts @@ -4,27 +4,16 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs' import { join } from 'path' import { URL } from 'url' import { cacheAvatarFromUrl, cachedAvatarUrl, isRemoteAvatarUrl } from './avatar-cache' +import { + AuthMetaSchema, + StoredTokensSchema, + UserInfoSchema, + type StoredTokens, + type UserInfo +} from './schemas' const CLOUD_API_URL = process.env.RELAY_CLOUD_URL || 'https://agentrelay.dev/cloud' -interface StoredTokens { - accessToken: string - refreshToken: string - apiUrl: string - expiresAt?: string - user?: UserInfo -} - -interface UserInfo { - name?: string - email?: string - githubUsername?: string - avatarUrl?: string - cachedAvatarUrl?: string - organizationName?: string - projectName?: string -} - interface AuthStatus { loggedIn: boolean apiUrl?: string @@ -53,46 +42,9 @@ function hasStoredTokens(): boolean { } } -function normalizeUserInfo(value: unknown): UserInfo | undefined { - if (!isRecord(value)) return undefined - - const record = value - const githubRecord = firstObject(record, [ - 'github', - 'githubUser', - 'github_user', - 'githubProfile', - 'github_profile', - 'githubAccount', - 'github_account' - ]) - const user: UserInfo = {} - const githubUsername = - firstString(record, ['githubUsername', 'github_username', 'githubLogin', 'github_login']) || - firstString(githubRecord, ['githubUsername', 'github_username', 'username', 'login']) || - firstString(record, ['username', 'login']) - const avatarUrl = - firstString(record, ['githubAvatarUrl', 'github_avatar_url']) || - firstString(githubRecord, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']) || - firstString(record, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']) - const cachedAvatarUrl = firstString(record, ['cachedAvatarUrl', 'cached_avatar_url']) - - const name = firstString(record, ['name', 'displayName', 'display_name']) - const email = firstString(record, ['email']) - const organizationName = firstString(record, ['organizationName', 'organization_name']) - const projectName = firstString(record, ['projectName', 'project_name']) - - if (name) user.name = name - if (email) user.email = email - if (githubUsername) user.githubUsername = githubUsername - if (avatarUrl) user.avatarUrl = avatarUrl - if (cachedAvatarUrl) user.cachedAvatarUrl = cachedAvatarUrl - if (organizationName) user.organizationName = organizationName - if (projectName) user.projectName = projectName - - return Object.keys(user).length > 0 ? user : undefined -} - +// The cloud API has historically returned the same logical field under several keys +// (camelCase vs snake_case, sometimes nested inside a `github` block). We tolerate +// all variants when normalizing, then validate the final shape with UserInfoSchema. function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) } @@ -115,6 +67,48 @@ function firstObject(record: Record | undefined, keys: string[] return undefined } +const GITHUB_OBJECT_KEYS = [ + 'github', + 'githubUser', + 'github_user', + 'githubProfile', + 'github_profile', + 'githubAccount', + 'github_account' +] + +function normalizeUserInfo(value: unknown): UserInfo | undefined { + if (!isRecord(value)) return undefined + + const githubRecord = firstObject(value, GITHUB_OBJECT_KEYS) + const candidate = { + name: firstString(value, ['name', 'displayName', 'display_name']), + email: firstString(value, ['email']), + githubUsername: + firstString(value, ['githubUsername', 'github_username', 'githubLogin', 'github_login']) || + firstString(githubRecord, ['githubUsername', 'github_username', 'username', 'login']) || + firstString(value, ['username', 'login']), + avatarUrl: + firstString(value, ['githubAvatarUrl', 'github_avatar_url']) || + firstString(githubRecord, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']) || + firstString(value, ['avatarUrl', 'avatar_url', 'avatar', 'picture', 'image']), + cachedAvatarUrl: firstString(value, ['cachedAvatarUrl', 'cached_avatar_url']), + organizationName: firstString(value, ['organizationName', 'organization_name']), + projectName: firstString(value, ['projectName', 'project_name']) + } + + const parsed = UserInfoSchema.parse(candidate) + // Zod preserves keys with `undefined` values from the input, so we strip + // them before returning. Without this, an empty whoami payload would shadow + // previously cached fields during `mergeUserInfo` (e.g. clearing the cached + // avatar URL on every refresh). + const user: UserInfo = {} + for (const [key, value] of Object.entries(parsed) as Array<[keyof UserInfo, string | undefined]>) { + if (value !== undefined) user[key] = value + } + return Object.keys(user).length > 0 ? user : undefined +} + function mergeUserInfo(previous: UserInfo | undefined, next: UserInfo | undefined): UserInfo | undefined { const normalizedPrevious = normalizeUserInfo(previous) const normalizedNext = normalizeUserInfo(next) @@ -140,10 +134,11 @@ function saveAuthMeta(tokens: Pick): void { function loadAuthMeta(): Pick { try { - const record = JSON.parse(readFileSync(getAuthMetaPath(), 'utf8')) as Record + const parsed = AuthMetaSchema.safeParse(JSON.parse(readFileSync(getAuthMetaPath(), 'utf8'))) + if (!parsed.success) return { apiUrl: CLOUD_API_URL } return { - apiUrl: typeof record.apiUrl === 'string' ? record.apiUrl : CLOUD_API_URL, - user: normalizeUserInfo(record.user) + apiUrl: parsed.data.apiUrl?.trim() || CLOUD_API_URL, + user: parsed.data.user } } catch { return { apiUrl: CLOUD_API_URL } @@ -197,9 +192,10 @@ function loadTokens(): StoredTokens | null { try { const raw = readFileSync(getAuthPath()) const decrypted = safeStorage.decryptString(raw) - const tokens = JSON.parse(decrypted) as StoredTokens - saveAuthMeta(tokens) - return tokens + const parsed = StoredTokensSchema.safeParse(JSON.parse(decrypted)) + if (!parsed.success) return null + saveAuthMeta(parsed.data) + return parsed.data } catch { return null } @@ -224,14 +220,13 @@ async function fetchWhoami(apiUrl: string, accessToken: string): Promise -} - type AvatarIdentity = { sourceUrl?: string githubUsername?: string @@ -41,14 +32,11 @@ function ensureCacheDir(): void { function loadManifest(): AvatarCacheManifest { try { - const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as AvatarCacheManifest - if (manifest.version === 1 && manifest.avatars && typeof manifest.avatars === 'object') { - return manifest - } + const parsed = AvatarCacheManifestSchema.safeParse(JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'))) + if (parsed.success) return parsed.data } catch { // Cache metadata is best-effort; corrupt or missing metadata just starts fresh. } - return { version: 1, avatars: {} } } diff --git a/src/main/broker.ts b/src/main/broker.ts index 326b428e..1890dcc4 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -16,6 +16,12 @@ import { } from '@agent-relay/sdk' import { getAccessToken, getApiUrl } from './auth' import { assertDirectory } from './path-utils' +import { + BrokerConnectionFileSchema, + GeneratedCommitDraftSchema, + type GeneratedCommitDraft +} from './schemas' +import { compactBrokerEvent, normalizeEventTimestamp } from '../shared/lib/broker-events' function isShellLikeCommand(cli: string): boolean { const normalized = basename(cli).toLowerCase() @@ -87,10 +93,7 @@ export interface AttachTerminalResult { } } -export interface GeneratedCommitDraft { - title: string - body: string -} +export type { GeneratedCommitDraft } export interface BrokerRuntimeAutoFixResult { removed: string[] @@ -105,7 +108,6 @@ const COMMIT_DRAFT_MAX_DIFF_CHARS = 80_000 const COMMIT_DRAFT_TIMEOUT_MS = 180_000 const MAX_BROKER_EVENT_HISTORY = 3_000 const BROKER_EVENT_HISTORY_TTL_MS = 12 * 60 * 60 * 1_000 -const MAX_EVENT_TEXT_CHARS = 1_200 function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -115,44 +117,19 @@ function toErrorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err) } -function normalizeEventTimestamp(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { - return undefined - } - return value < 1_000_000_000_000 ? value * 1_000 : value -} - -function compactEventText(value: unknown): unknown { - if (typeof value !== 'string' || value.length <= MAX_EVENT_TEXT_CHARS) { - return value - } - return `${value.slice(0, MAX_EVENT_TEXT_CHARS)}...` -} - type BrokerEventRecordPayload = Record & { kind: string } -function compactBrokerEvent(event: BrokerEventRecordPayload): BrokerEventRecordPayload { - const compacted = { ...(event as Record) } - for (const key of ['body', 'chunk', 'message', 'reason', 'lastError']) { - if (key in compacted) { - compacted[key] = compactEventText(compacted[key]) - } - } - return compacted as BrokerEventRecordPayload +function getErrorStatus(err: unknown): unknown { + if (typeof err !== 'object' || err === null || !('status' in err)) return undefined + return (err as { status?: unknown }).status } function isUnsupportedInputStreamError(err: unknown): boolean { - const status = typeof err === 'object' && err !== null && 'status' in err - ? (err as { status?: unknown }).status - : undefined - return status === 404 || /\b404\b|not found|unsupported/i.test(toErrorMessage(err)) + return getErrorStatus(err) === 404 || /\b404\b|not found|unsupported/i.test(toErrorMessage(err)) } function isMissingAgentError(err: unknown): boolean { - const status = typeof err === 'object' && err !== null && 'status' in err - ? (err as { status?: unknown }).status - : undefined - return status === 404 || /agent_not_found|no worker named|not found/i.test(toErrorMessage(err)) + return getErrorStatus(err) === 404 || /agent_not_found|no worker named|not found/i.test(toErrorMessage(err)) } function withBrokerDetailsTimeout(promise: Promise, label: string): Promise { @@ -214,20 +191,21 @@ function getBrokerConnectionFileInfo( } try { - const connection = JSON.parse(readFileSync(connectionPath, 'utf-8')) as Record - const connectionUrl = normalizeBaseUrl(typeof connection.url === 'string' ? connection.url : undefined) - const connectionPid = typeof connection.pid === 'number' ? connection.pid : undefined - const connectionApiKey = typeof connection.api_key === 'string' && connection.api_key.trim().length > 0 - ? connection.api_key.trim() - : undefined + const parsed = BrokerConnectionFileSchema.safeParse( + JSON.parse(readFileSync(connectionPath, 'utf-8')) + ) + if (!parsed.success) { + return { path: connectionPath, status: 'invalid', hasApiKey: false } + } + const connectionUrl = normalizeBaseUrl(parsed.data.url) const sameUrl = !!baseUrl && connectionUrl === baseUrl - const samePid = !brokerPid || !connectionPid || connectionPid === brokerPid + const samePid = !brokerPid || !parsed.data.pid || parsed.data.pid === brokerPid return { path: connectionPath, status: sameUrl && samePid ? 'matches' : 'different', - hasApiKey: !!connectionApiKey, - apiKey: connectionApiKey + hasApiKey: !!parsed.data.apiKey, + apiKey: parsed.data.apiKey } } catch { return { path: connectionPath, status: 'invalid', hasApiKey: false } @@ -308,24 +286,12 @@ function getJsonObjectCandidates(text: string): string[] { return candidates } -function normalizeGeneratedCommitDraft(value: unknown): GeneratedCommitDraft | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const title = typeof record.title === 'string' ? record.title.trim() : '' - if (!title) return null - - return { - title, - body: typeof record.body === 'string' ? record.body.trim() : '' - } -} - function parseGeneratedCommitDraft(text: string): GeneratedCommitDraft { const candidates = getJsonObjectCandidates(text) for (const candidate of candidates.reverse()) { try { - const draft = normalizeGeneratedCommitDraft(JSON.parse(candidate)) - if (draft) return draft + const draft = GeneratedCommitDraftSchema.safeParse(JSON.parse(candidate)) + if (draft.success) return draft.data } catch { // Keep scanning terminal output; diffs can contain unrelated braces. } @@ -713,7 +679,8 @@ export class BrokerManager { } if (this.sessions.size === 1) { - return this.sessions.values().next().value + const onlySession = this.sessions.values().next().value + if (onlySession) return onlySession } throw new Error(`No relay workspace found for agent: ${name}`) @@ -736,9 +703,9 @@ export class BrokerManager { } else if ((event.kind === 'agent_exited' || event.kind === 'agent_released') && event.name) { this.closeInputStream(this.getInputStreamKey(projectId, event.name), 1000, 'agent closed') this.forgetAgentProject(event.name, projectId) - } else if (event.name) { + } else if ('name' in event && typeof event.name === 'string') { this.rememberAgentProject(event.name, projectId) - } else if (event.from) { + } else if ('from' in event && typeof event.from === 'string') { this.rememberAgentProject(event.from, projectId) } }) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 301b3a76..f228ed9e 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -175,10 +175,6 @@ export function registerIpcHandlers(): void { return brokerManager.connectCloud('cloud', win) }) - ipcMain.handle('broker:send-input', async (_, projectId: string | undefined, name: string, data: string) => { - await brokerManager.sendInput(projectId, name, data) - }) - ipcMain.on('broker:send-input-fast', (_, projectId: string | undefined, name: string, data: string) => { brokerManager.queueInput(projectId, name, data) }) diff --git a/src/main/schemas.ts b/src/main/schemas.ts new file mode 100644 index 00000000..5e26a859 --- /dev/null +++ b/src/main/schemas.ts @@ -0,0 +1,103 @@ +import { z } from 'zod' + +/** + * Schemas for data the main process loads from external sources: + * - JSON files on disk it wrote itself but that a user may have edited + * - JSON responses from cloud APIs + * + * All schemas accept unknown input and coerce / drop unrecognized fields. + */ + +const trimmedString = z + .string() + .transform((value) => value.trim()) + .pipe(z.string().min(1)) + +const optionalTrimmedString = z + .string() + .optional() + .transform((value) => value?.trim() || undefined) + +/* ----------------------------- Auth ----------------------------- */ + +export const UserInfoSchema = z + .object({ + name: optionalTrimmedString, + email: optionalTrimmedString, + githubUsername: optionalTrimmedString, + avatarUrl: optionalTrimmedString, + cachedAvatarUrl: optionalTrimmedString, + organizationName: optionalTrimmedString, + projectName: optionalTrimmedString + }) + .strip() + +export type UserInfo = z.infer + +export const StoredTokensSchema = z + .object({ + accessToken: trimmedString, + refreshToken: trimmedString, + apiUrl: trimmedString, + expiresAt: z.string().optional(), + user: UserInfoSchema.optional() + }) + .strip() + +export type StoredTokens = z.infer + +export const AuthMetaSchema = z + .object({ + apiUrl: z.string().optional(), + user: UserInfoSchema.optional() + }) + .strip() + +/* --------------------- Broker connection.json ------------------- */ + +export const BrokerConnectionFileSchema = z + .object({ + url: z.string().optional(), + pid: z.number().int().optional(), + api_key: z.string().optional() + }) + .passthrough() + .transform((value) => ({ + url: value.url?.trim() || undefined, + pid: value.pid, + apiKey: value.api_key?.trim() || undefined + })) + +/* ------------------ Generated commit draft JSON ----------------- */ + +export const GeneratedCommitDraftSchema = z + .object({ + title: trimmedString, + body: z.string().optional() + }) + .passthrough() + .transform((value) => ({ + title: value.title, + body: value.body?.trim() || '' + })) + +export type GeneratedCommitDraft = z.infer + +/* --------------------- Avatar cache manifest -------------------- */ + +export const AvatarCacheEntrySchema = z.object({ + key: z.string(), + sourceUrl: z.string(), + fileName: z.string(), + contentType: z.string(), + byteLength: z.number().int().nonnegative(), + updatedAt: z.string() +}) + +export const AvatarCacheManifestSchema = z.object({ + version: z.literal(1), + avatars: z.record(AvatarCacheEntrySchema) +}) + +export type AvatarCacheManifest = z.infer +export type AvatarCacheEntry = z.infer diff --git a/src/main/store.ts b/src/main/store.ts index 0719098c..b9d7d7e3 100644 --- a/src/main/store.ts +++ b/src/main/store.ts @@ -1,34 +1,25 @@ import { app } from 'electron' import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'fs' import { basename, join } from 'path' +import { z } from 'zod' +import { + PeopleListSchema, + ProjectIntegrationSchema, + ProjectRootSchema, + StoreDataSchema, + makeProjectSchema, + normalizeChannelName +} from '../shared/schemas/project' + +const ProjectSchema = makeProjectSchema(ProjectRootSchema) +const StoreSchema = StoreDataSchema(ProjectSchema) + +export type ProjectRoot = z.infer +export type ProjectIntegration = z.infer +export type Project = z.infer +type StoreData = z.infer -export interface ProjectRoot { - id: string - name: string - path: string -} - -export interface ProjectIntegration { - id: string - name: string - type: string -} - -export interface Project { - id: string - name: string - relayWorkspaceId: string - rootPath: string - roots: ProjectRoot[] - channels: string[] - channelPeople: Record - integrations: ProjectIntegration[] -} - -interface StoreData { - projects: Project[] - activeProjectId: string | null -} +const defaultData: StoreData = { projects: [], activeProjectId: null } const getStorePath = (): string => { const dir = join(app.getPath('userData'), 'config') @@ -36,158 +27,21 @@ const getStorePath = (): string => { return join(dir, 'projects.json') } -const defaultData: StoreData = { projects: [], activeProjectId: null } - function defaultRootName(path: string): string { return basename(path) || path } -function normalizeRoot(value: unknown): ProjectRoot | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const path = typeof record.path === 'string' ? record.path : null - if (!path) return null - - return { - id: typeof record.id === 'string' ? record.id : path, - name: typeof record.name === 'string' && record.name.trim() - ? record.name.trim() - : defaultRootName(path), - path - } -} - -function normalizeIntegration(value: unknown): ProjectIntegration | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const name = typeof record.name === 'string' ? record.name.trim() : '' - if (!name) return null - - return { - id: typeof record.id === 'string' ? record.id : crypto.randomUUID(), - name, - type: typeof record.type === 'string' && record.type.trim() ? record.type.trim() : 'custom' - } -} - -function normalizeChannelName(value: string): string { - return value - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') -} - -function normalizeChannels(value: unknown): string[] { - const channels = Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === 'string') - : [] - const deduped = Array.from(new Set(channels.map(normalizeChannelName).filter(Boolean))) - return deduped.length > 0 ? deduped : ['general'] -} - -function normalizePeopleList(value: unknown): string[] { - const people = Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === 'string') - : [] - const names = people.map((entry) => entry.trim()).filter(Boolean) - return Array.from(new Map(names.map((name) => [name.toLowerCase(), name])).values()) -} - -function normalizeChannelPeople(value: unknown, channels: string[]): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - - const channelSet = new Set(channels) - const result: Record = {} - for (const [rawChannelName, rawPeople] of Object.entries(value as Record)) { - const channelName = normalizeChannelName(rawChannelName) - if (!channelName || !channelSet.has(channelName)) continue - - const people = normalizePeopleList(rawPeople) - if (people.length > 0) { - result[channelName] = people - } - } - - return result -} - -function dedupeRoots(roots: ProjectRoot[]): ProjectRoot[] { - const seen = new Set() - const deduped: ProjectRoot[] = [] - - for (const root of roots) { - if (seen.has(root.path)) continue - seen.add(root.path) - deduped.push(root) - } - - return deduped -} - -function normalizeProject(value: unknown): Project | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const id = typeof record.id === 'string' ? record.id : null - const name = typeof record.name === 'string' ? record.name : null - const relayWorkspaceId = typeof record.relayWorkspaceId === 'string' && record.relayWorkspaceId.trim() - ? record.relayWorkspaceId.trim() - : id || '' - const rootPath = typeof record.rootPath === 'string' ? record.rootPath : null - const roots = Array.isArray(record.roots) - ? dedupeRoots(record.roots.map(normalizeRoot).filter((entry): entry is ProjectRoot => entry !== null)) - : [] - const primaryRootPath = rootPath || roots[0]?.path || null - const integrations = Array.isArray(record.integrations) - ? record.integrations - .map(normalizeIntegration) - .filter((entry): entry is ProjectIntegration => entry !== null) - : [] - - if (!id || !name || !primaryRootPath || roots.length === 0) return null - - const channels = normalizeChannels(record.channels) - - return { - id, - name, - relayWorkspaceId, - rootPath: primaryRootPath, - roots, - channels, - channelPeople: normalizeChannelPeople(record.channelPeople, channels), - integrations - } -} - -function normalizeStore(raw: unknown): StoreData { - if (!raw || typeof raw !== 'object') return { ...defaultData } - const record = raw as Record - const projects = Array.isArray(record.projects) - ? record.projects.map(normalizeProject).filter((entry): entry is Project => entry !== null) - : [] - const activeProjectId = - typeof record.activeProjectId === 'string' || record.activeProjectId === null - ? record.activeProjectId - : null - - return { - projects, - activeProjectId - } -} - -export function loadStore(): StoreData { +function loadStoreFromDisk(): StoreData { try { const raw = readFileSync(getStorePath(), 'utf-8') - return normalizeStore(JSON.parse(raw)) + return StoreSchema.parse(JSON.parse(raw)) } catch { return { ...defaultData } } } +export const loadStore = loadStoreFromDisk + export function saveStore(data: StoreData): void { const storePath = getStorePath() const tmpPath = storePath + '.tmp' @@ -197,17 +51,12 @@ export function saveStore(data: StoreData): void { export function addProject(name: string, rootPath: string): Project { const data = loadStore() - const root: ProjectRoot = { - id: crypto.randomUUID(), - name: defaultRootName(rootPath), - path: rootPath - } const project: Project = { id: crypto.randomUUID(), name, relayWorkspaceId: crypto.randomUUID(), rootPath, - roots: [root], + roots: [{ id: crypto.randomUUID(), name: defaultRootName(rootPath), path: rootPath }], channels: ['general'], channelPeople: {}, integrations: [] @@ -230,40 +79,44 @@ export function setActiveProject(id: string | null): void { saveStore(data) } +function withProject(data: StoreData, projectId: string): Project | undefined { + return data.projects.find((entry) => entry.id === projectId) +} + export function addProjectChannel(projectId: string, channelName: string): void { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) - const normalizedName = normalizeChannelName(channelName) - if (project && normalizedName && !project.channels.includes(normalizedName)) { - project.channels.push(normalizedName) + const project = withProject(data, projectId) + const normalized = normalizeChannelName(channelName) + if (project && normalized && !project.channels.includes(normalized)) { + project.channels.push(normalized) saveStore(data) } } export function removeProjectChannel(projectId: string, channelName: string): void { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) - const normalizedName = normalizeChannelName(channelName) + const project = withProject(data, projectId) + const normalized = normalizeChannelName(channelName) if (project) { - project.channels = project.channels.filter((channel) => channel !== normalizedName) - delete project.channelPeople[normalizedName] + project.channels = project.channels.filter((channel) => channel !== normalized) + delete project.channelPeople[normalized] saveStore(data) } } export function setProjectChannelPeople(projectId: string, channelName: string, people: string[]): string[] { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) - const normalizedName = normalizeChannelName(channelName) - if (!project || !normalizedName || !project.channels.includes(normalizedName)) { + const project = withProject(data, projectId) + const normalized = normalizeChannelName(channelName) + if (!project || !normalized || !project.channels.includes(normalized)) { return [] } - const normalizedPeople = normalizePeopleList(people) + const normalizedPeople = PeopleListSchema.parse(people) if (normalizedPeople.length > 0) { - project.channelPeople[normalizedName] = normalizedPeople + project.channelPeople[normalized] = normalizedPeople } else { - delete project.channelPeople[normalizedName] + delete project.channelPeople[normalized] } saveStore(data) return normalizedPeople @@ -271,22 +124,19 @@ export function setProjectChannelPeople(projectId: string, channelName: string, export function addProjectRoot(projectId: string, rootPath: string, name?: string): ProjectRoot { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) + const project = withProject(data, projectId) if (!project) { throw new Error('Project not found') } const existing = project.roots.find((root) => root.path === rootPath) - if (existing) { - return existing - } + if (existing) return existing const root: ProjectRoot = { id: crypto.randomUUID(), name: name?.trim() || defaultRootName(rootPath), path: rootPath } - project.roots.push(root) saveStore(data) return root @@ -294,7 +144,7 @@ export function addProjectRoot(projectId: string, rootPath: string, name?: strin export function removeProjectRoot(projectId: string, rootId: string): void { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) + const project = withProject(data, projectId) if (!project) return if (project.roots.length <= 1) { @@ -314,21 +164,12 @@ export function addProjectIntegration( type = 'custom' ): ProjectIntegration { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) + const project = withProject(data, projectId) if (!project) { throw new Error('Project not found') } - const integration: ProjectIntegration = { - id: crypto.randomUUID(), - name: name.trim(), - type: type.trim() || 'custom' - } - - if (!integration.name) { - throw new Error('Integration name is required') - } - + const integration = ProjectIntegrationSchema.parse({ name, type }) project.integrations.push(integration) saveStore(data) return integration @@ -336,7 +177,7 @@ export function addProjectIntegration( export function removeProjectIntegration(projectId: string, integrationId: string): void { const data = loadStore() - const project = data.projects.find((entry) => entry.id === projectId) + const project = withProject(data, projectId) if (!project) return project.integrations = project.integrations.filter((integration) => integration.id !== integrationId) @@ -346,12 +187,13 @@ export function removeProjectIntegration(projectId: string, integrationId: strin export function updateProject(id: string, update: Partial): void { const data = loadStore() const idx = data.projects.findIndex((project) => project.id === id) - if (idx !== -1) { - const next = { ...data.projects[idx] } - if (typeof update.name === 'string' && update.name.trim()) { - next.name = update.name.trim() - } - data.projects[idx] = next - saveStore(data) + if (idx === -1) return + + const next = { ...data.projects[idx] } + if (typeof update.name === 'string' && update.name.trim()) { + next.name = update.name.trim() } + data.projects[idx] = next + saveStore(data) } + diff --git a/src/preload/index.ts b/src/preload/index.ts index 36ae5842..5a514f46 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2,59 +2,56 @@ import { contextBridge, ipcRenderer } from 'electron' export type ViewMode = 'terminal' | 'chat' | 'graph' | 'project-settings' | 'broker-details' | 'source-control' -type AuthUser = { - name?: string - email?: string - githubUsername?: string - username?: string - avatarUrl?: string - cachedAvatarUrl?: string - organizationName?: string - projectName?: string +type TerminalAttachMode = 'view' | 'drive' | 'passthrough' + +// Thin generic wrappers so each handler binds an IPC channel + return type without +// repeating the `as Promise` cast on every call site. +function invoke(channel: string, ...args: unknown[]): Promise { + return ipcRenderer.invoke(channel, ...args) as Promise } -type AuthStatus = { - loggedIn: boolean - apiUrl?: string - user?: AuthUser +function subscribe(channel: string, callback: (payload: T) => void): () => void { + const handler = (_: unknown, payload: T): void => callback(payload) + ipcRenderer.on(channel, handler) + return () => ipcRenderer.removeListener(channel, handler) } const api = { project: { - list: () => ipcRenderer.invoke('project:list'), - add: (name: string, rootPath?: string) => ipcRenderer.invoke('project:add', name, rootPath), - remove: (id: string) => ipcRenderer.invoke('project:remove', id), - setActive: (id: string | null) => ipcRenderer.invoke('project:set-active', id), + list: () => invoke<{ projects: unknown[]; activeId: string | null }>('project:list'), + add: (name: string, rootPath?: string) => invoke('project:add', name, rootPath), + remove: (id: string) => invoke('project:remove', id), + setActive: (id: string | null) => invoke('project:set-active', id), update: (id: string, update: Record) => - ipcRenderer.invoke('project:update', id, update), + invoke('project:update', id, update), addChannel: (projectId: string, name: string) => - ipcRenderer.invoke('project:add-channel', projectId, name), + invoke('project:add-channel', projectId, name), removeChannel: (projectId: string, name: string) => - ipcRenderer.invoke('project:remove-channel', projectId, name), + invoke('project:remove-channel', projectId, name), setChannelPeople: (projectId: string, channelName: string, people: string[]) => - ipcRenderer.invoke('project:set-channel-people', projectId, channelName, people), + invoke('project:set-channel-people', projectId, channelName, people), addRoot: (projectId: string, name?: string, rootPath?: string) => - ipcRenderer.invoke('project:add-root', projectId, name, rootPath), + invoke('project:add-root', projectId, name, rootPath), removeRoot: (projectId: string, rootId: string) => - ipcRenderer.invoke('project:remove-root', projectId, rootId), + invoke('project:remove-root', projectId, rootId), addIntegration: (projectId: string, name: string, type?: string) => - ipcRenderer.invoke('project:add-integration', projectId, name, type), + invoke('project:add-integration', projectId, name, type), removeIntegration: (projectId: string, integrationId: string) => - ipcRenderer.invoke('project:remove-integration', projectId, integrationId) + invoke('project:remove-integration', projectId, integrationId) }, broker: { start: (projectId: string, cwd: string, name: string, channels?: string[]) => - ipcRenderer.invoke('broker:start', projectId, cwd, name, channels) as Promise, + invoke('broker:start', projectId, cwd, name, channels), syncChannels: (projectId: string, channels: string[]) => - ipcRenderer.invoke('broker:sync-channels', projectId, channels), + invoke('broker:sync-channels', projectId, channels), autoFixRuntime: ( projectId: string, cwd: string, name: string, channels?: string[], errorMessage?: string - ) => ipcRenderer.invoke('broker:auto-fix-runtime', projectId, cwd, name, channels, errorMessage) as Promise<{ removed: string[] }>, - connectCloud: () => ipcRenderer.invoke('broker:connect-cloud') as Promise, + ) => invoke<{ removed: string[] }>('broker:auto-fix-runtime', projectId, cwd, name, channels, errorMessage), + connectCloud: () => invoke('broker:connect-cloud'), spawnAgent: (projectId: string, input: { name: string cli: string @@ -62,88 +59,77 @@ const api = { task?: string channels?: string[] cwd?: string - }) => ipcRenderer.invoke('broker:spawn-agent', projectId, input), + }) => invoke<{ name: string; runtime: string }>('broker:spawn-agent', projectId, input), attachTerminal: (input: { projectId?: string name: string rows?: number cols?: number - mode?: 'view' | 'drive' | 'passthrough' - }) => ipcRenderer.invoke('broker:attach-terminal', input), - sendInput: (projectId: string | undefined, name: string, data: string) => - ipcRenderer.invoke('broker:send-input', projectId, name, data), + mode?: TerminalAttachMode + }) => invoke('broker:attach-terminal', input), sendInputFast: (projectId: string | undefined, name: string, data: string) => ipcRenderer.send('broker:send-input-fast', projectId, name, data), - setTerminalMode: (projectId: string | undefined, name: string, mode: 'view' | 'drive' | 'passthrough') => - ipcRenderer.invoke('broker:set-terminal-mode', projectId, name, mode), + setTerminalMode: (projectId: string | undefined, name: string, mode: TerminalAttachMode) => + invoke('broker:set-terminal-mode', projectId, name, mode), getPending: (projectId: string | undefined, name: string) => - ipcRenderer.invoke('broker:get-pending', projectId, name), + invoke('broker:get-pending', projectId, name), flushPending: (projectId: string | undefined, name: string) => - ipcRenderer.invoke('broker:flush-pending', projectId, name), + invoke<{ flushed: number }>('broker:flush-pending', projectId, name), resizePty: (projectId: string | undefined, name: string, rows: number, cols: number) => - ipcRenderer.invoke('broker:resize-pty', projectId, name, rows, cols), + invoke('broker:resize-pty', projectId, name, rows, cols), sendMessage: (projectId: string | undefined, input: { to: string; text: string; from?: string }) => - ipcRenderer.invoke('broker:send-message', projectId, input), + invoke('broker:send-message', projectId, input), subscribeAgentChannel: (projectId: string | undefined, name: string, channel: string) => - ipcRenderer.invoke('broker:subscribe-agent-channel', projectId, name, channel), + invoke('broker:subscribe-agent-channel', projectId, name, channel), unsubscribeAgentChannel: (projectId: string | undefined, name: string, channel: string) => - ipcRenderer.invoke('broker:unsubscribe-agent-channel', projectId, name, channel), + invoke('broker:unsubscribe-agent-channel', projectId, name, channel), releaseAgent: (projectId: string | undefined, name: string) => - ipcRenderer.invoke('broker:release-agent', projectId, name), - listAgents: (projectId?: string) => ipcRenderer.invoke('broker:list-agents', projectId), - listDetails: () => ipcRenderer.invoke('broker:list-details'), - listEvents: () => ipcRenderer.invoke('broker:list-events'), - shutdown: () => ipcRenderer.invoke('broker:shutdown'), - onEvent: (callback: (event: unknown) => void) => { - const handler = (_: unknown, event: unknown): void => callback(event) - ipcRenderer.on('broker:event', handler) - return () => ipcRenderer.removeListener('broker:event', handler) - }, - onStatus: (callback: (status: { projectId?: string; status: string; error?: string }) => void) => { - const handler = (_: unknown, status: { projectId?: string; status: string; error?: string }): void => - callback(status) - ipcRenderer.on('broker:status', handler) - return () => ipcRenderer.removeListener('broker:status', handler) - } + invoke('broker:release-agent', projectId, name), + listAgents: (projectId?: string) => invoke('broker:list-agents', projectId), + listDetails: () => invoke('broker:list-details'), + listEvents: () => invoke('broker:list-events'), + shutdown: () => invoke('broker:shutdown'), + onEvent: (callback: (event: unknown) => void) => subscribe('broker:event', callback), + onStatus: (callback: (status: { projectId?: string; status: string; error?: string }) => void) => + subscribe<{ projectId?: string; status: string; error?: string }>('broker:status', callback) }, git: { - status: (path: string) => ipcRenderer.invoke('git:status', path), - diff: (path: string, file?: string) => ipcRenderer.invoke('git:diff', path, file), + status: (path: string) => invoke('git:status', path), + diff: (path: string, file?: string) => invoke('git:diff', path, file), fileContent: (path: string, file: string, revision?: string) => - ipcRenderer.invoke('git:file-content', path, file, revision), - summary: (path: string) => ipcRenderer.invoke('git:summary', path), - branches: (root: string) => ipcRenderer.invoke('git:branches', root), - branchDetails: (root: string) => ipcRenderer.invoke('git:branch-details', root), + invoke('git:file-content', path, file, revision), + summary: (path: string) => invoke('git:summary', path), + branches: (root: string) => invoke('git:branches', root), + branchDetails: (root: string) => invoke('git:branch-details', root), checkoutBranch: (root: string, branch: string, options?: { stashChanges?: boolean }) => - ipcRenderer.invoke('git:checkout-branch', root, branch, options), - branchSyncStatus: (root: string) => ipcRenderer.invoke('git:branch-sync-status', root), - fetchRemote: (root: string) => ipcRenderer.invoke('git:fetch-remote', root), - pullCurrentBranch: (root: string) => ipcRenderer.invoke('git:pull-current-branch', root), - pushCurrentBranch: (root: string) => ipcRenderer.invoke('git:push-current-branch', root), - history: (path: string, limit?: number) => ipcRenderer.invoke('git:history', path, limit), - show: (path: string, hash: string, file?: string) => ipcRenderer.invoke('git:show', path, hash, file), - discardFiles: (path: string, files: string[]) => - ipcRenderer.invoke('git:discard-files', path, files), + invoke('git:checkout-branch', root, branch, options), + branchSyncStatus: (root: string) => invoke('git:branch-sync-status', root), + fetchRemote: (root: string) => invoke('git:fetch-remote', root), + pullCurrentBranch: (root: string) => invoke('git:pull-current-branch', root), + pushCurrentBranch: (root: string) => invoke('git:push-current-branch', root), + history: (path: string, limit?: number) => invoke('git:history', path, limit), + show: (path: string, hash: string, file?: string) => invoke('git:show', path, hash, file), + discardFiles: (path: string, files: string[]) => invoke('git:discard-files', path, files), addGitignorePatterns: (path: string, patterns: string[]) => - ipcRenderer.invoke('git:add-gitignore-patterns', path, patterns), + invoke('git:add-gitignore-patterns', path, patterns), commitSelection: (path: string, input: { title: string body?: string wholeFiles: string[] patch?: string - }) => ipcRenderer.invoke('git:commit-selection', path, input), + }) => invoke<{ hash: string }>('git:commit-selection', path, input), generateCommitMessage: (path: string, input: { wholeFiles: string[]; patch?: string }) => - ipcRenderer.invoke('git:generate-commit-message', path, input) + invoke('git:generate-commit-message', path, input) }, fs: { - listDir: (dirPath: string) => ipcRenderer.invoke('fs:list-dir', dirPath), - readPreview: (filePath: string) => ipcRenderer.invoke('fs:read-preview', filePath), - revealPath: (filePath: string) => ipcRenderer.invoke('fs:reveal-path', filePath) + listDir: (dirPath: string) => invoke('fs:list-dir', dirPath), + readPreview: (filePath: string) => invoke('fs:read-preview', filePath), + revealPath: (filePath: string) => invoke('fs:reveal-path', filePath) }, auth: { - login: () => ipcRenderer.invoke('auth:login') as Promise, - logout: () => ipcRenderer.invoke('auth:logout'), - status: () => ipcRenderer.invoke('auth:status') as Promise + login: () => invoke('auth:login'), + logout: () => invoke('auth:logout'), + status: () => invoke('auth:status') }, onMenu: (channel: string, callback: (...args: unknown[]) => void) => { const handler = (_: unknown, ...args: unknown[]): void => callback(...args) diff --git a/src/renderer/src/components/chat/ChatMessage.tsx b/src/renderer/src/components/chat/ChatMessage.tsx index 6e2a1074..a2255018 100644 --- a/src/renderer/src/components/chat/ChatMessage.tsx +++ b/src/renderer/src/components/chat/ChatMessage.tsx @@ -4,6 +4,7 @@ import { MessageCircle, SmilePlus } from 'lucide-react' import { AgentHarnessIcon } from '@/components/common/AgentIcons' import type { AuthUser } from '@/lib/ipc' import { renderChatMessageBody } from '@/lib/chat-formatting' +import { formatClockTime, formatRelativeShort } from '@/lib/format' import { useAgentStore } from '@/stores/agent-store' import type { ChatMessage as ChatMessageType, @@ -43,25 +44,6 @@ function getAgentColor(name: string): string { return agentColors[Math.abs(hash) % agentColors.length] } -function formatTime(ts: number): string { - return new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) -} - -function formatReplyTime(timestamp: number): string { - const now = Date.now() - const ageMs = now - timestamp - if (ageMs >= 0 && ageMs < 60_000) return 'just now' - if (ageMs >= 0 && ageMs < 3_600_000) { - const minutes = Math.max(1, Math.floor(ageMs / 60_000)) - return `${minutes}m ago` - } - if (ageMs >= 0 && ageMs < 86_400_000) { - const hours = Math.max(1, Math.floor(ageMs / 3_600_000)) - return `${hours}h ago` - } - return new Date(timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' }) -} - function participantKey(participant: Pick): string { return `${participant.projectId || 'unknown'}:${participant.isHuman ? 'human' : participant.from}` } @@ -257,7 +239,7 @@ function ThreadSummary({ {lastReply && ( - {formatReplyTime(lastReply.timestamp)} + {formatRelativeShort(lastReply.timestamp)} )} @@ -289,7 +271,7 @@ export function ChatMessage({
{message.body} - {formatTime(message.timestamp)} + {formatClockTime(message.timestamp)}
) @@ -329,7 +311,7 @@ export function ChatMessage({ {message.isHuman ? 'You' : message.from} - {formatTime(message.timestamp)} + {formatClockTime(message.timestamp)} {showRoute && message.to && !message.isHuman && ( diff --git a/src/renderer/src/components/chat/ThreadPanel.tsx b/src/renderer/src/components/chat/ThreadPanel.tsx index 42b5bb34..88c72532 100644 --- a/src/renderer/src/components/chat/ThreadPanel.tsx +++ b/src/renderer/src/components/chat/ThreadPanel.tsx @@ -4,6 +4,7 @@ import { X } from 'lucide-react' import { AgentHarnessIcon } from '@/components/common/AgentIcons' import type { AuthUser } from '@/lib/ipc' import { renderChatMessageBody } from '@/lib/chat-formatting' +import { formatClockTime } from '@/lib/format' import { useAgentStore } from '@/stores/agent-store' import type { ChatMessage as ChatMessageType, @@ -21,10 +22,6 @@ interface ThreadPanelProps { onReply: (messageId: string, body: string) => void } -function formatTime(timestamp: number): string { - return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) -} - function ReplyAvatar({ reply, authUser @@ -71,7 +68,7 @@ function ThreadReplyRow({ {reply.isHuman ? 'You' : reply.from} - {formatTime(reply.timestamp)} + {formatClockTime(reply.timestamp)}
{renderChatMessageBody(reply.body)} diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index 9f2bab88..0cc1a5a7 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -6,3 +6,19 @@ const compactCountFormatter = new Intl.NumberFormat('en-US', { export function formatGitDiffLineCount(count: number): string { return compactCountFormatter.format(count).toLowerCase() } + +export function formatClockTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) +} + +const MINUTE_MS = 60_000 +const HOUR_MS = 3_600_000 +const DAY_MS = 86_400_000 + +export function formatRelativeShort(timestamp: number, now = Date.now()): string { + const ageMs = now - timestamp + if (ageMs >= 0 && ageMs < MINUTE_MS) return 'just now' + if (ageMs >= 0 && ageMs < HOUR_MS) return `${Math.max(1, Math.floor(ageMs / MINUTE_MS))}m ago` + if (ageMs >= 0 && ageMs < DAY_MS) return `${Math.max(1, Math.floor(ageMs / HOUR_MS))}h ago` + return new Date(timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' }) +} diff --git a/src/renderer/src/lib/ipc.ts b/src/renderer/src/lib/ipc.ts index 9f9399b9..9343a094 100644 --- a/src/renderer/src/lib/ipc.ts +++ b/src/renderer/src/lib/ipc.ts @@ -95,6 +95,15 @@ export interface BrokerEventRecord { } } +export type GitFileStatusKind = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked' + +export interface GitFileStatus { + path: string + oldPath?: string + status: GitFileStatusKind + staged: boolean +} + export interface GitSummary { branch: string additions: number @@ -233,7 +242,6 @@ export interface PearAPI { screen: string } }> - sendInput: (projectId: string | undefined, name: string, data: string) => Promise<{ name: string; bytes_written: number }> sendInputFast: (projectId: string | undefined, name: string, data: string) => void setTerminalMode: (projectId: string | undefined, name: string, mode: TerminalAttachMode) => Promise<{ name: string @@ -256,7 +264,7 @@ export interface PearAPI { onStatus: (callback: (status: { projectId?: string; status: string; error?: string }) => void) => () => void } git: { - status: (path: string) => Promise<{ path: string; oldPath?: string; status: string; staged: boolean }[]> + status: (path: string) => Promise diff: (path: string, file?: string) => Promise fileContent: (path: string, file: string, revision?: string) => Promise summary: (path: string) => Promise diff --git a/src/renderer/src/stores/agent-store.ts b/src/renderer/src/stores/agent-store.ts index 6b3c9553..ff2ba061 100644 --- a/src/renderer/src/stores/agent-store.ts +++ b/src/renderer/src/stores/agent-store.ts @@ -8,6 +8,10 @@ import type { TerminalAttachMode } from '@/lib/ipc' import { normalizeChannelName, useProjectStore } from '@/stores/project-store' +import { + compactBrokerEvent as compactBrokerEventPayload, + normalizeEventTimestamp +} from '@shared/lib/broker-events' export interface Agent { name: string @@ -75,7 +79,6 @@ const MAX_PTY_BUFFER_CHUNKS = 10_000 const MAX_BROKER_ERRORS = 12 const MAX_BROKER_EVENTS = 3_000 const BROKER_EVENT_RETENTION_MS = 12 * 60 * 60 * 1_000 -const MAX_BROKER_EVENT_TEXT_CHARS = 1_200 const HUMAN_SENDER_NAME = 'human' const SYSTEM_NOTICE_SENDER_NAME = 'system' const HUMAN_MESSAGE_DEDUPE_WINDOW_MS = 10_000 @@ -216,28 +219,8 @@ function clearPendingDeliveries(agent: Agent, eventId?: string): Agent { } } -function normalizeEventTimestamp(value: unknown): number | undefined { - if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { - return undefined - } - return value < 1_000_000_000_000 ? value * 1_000 : value -} - -function compactBrokerEventText(value: unknown): unknown { - if (typeof value !== 'string' || value.length <= MAX_BROKER_EVENT_TEXT_CHARS) { - return value - } - return `${value.slice(0, MAX_BROKER_EVENT_TEXT_CHARS)}...` -} - function compactBrokerEvent(event: Record): BrokerEventRecord['event'] { - const compacted = { ...event } - for (const key of ['body', 'chunk', 'message', 'reason', 'lastError']) { - if (key in compacted) { - compacted[key] = compactBrokerEventText(compacted[key]) - } - } - return compacted as BrokerEventRecord['event'] + return compactBrokerEventPayload(event) as BrokerEventRecord['event'] } function pruneBrokerEvents(events: BrokerEventRecord[], now = Date.now()): BrokerEventRecord[] { diff --git a/src/renderer/src/stores/git-store.ts b/src/renderer/src/stores/git-store.ts index 91c53d88..1a01a2e0 100644 --- a/src/renderer/src/stores/git-store.ts +++ b/src/renderer/src/stores/git-store.ts @@ -3,16 +3,12 @@ import { pear, type GitCommitDraft, type GitCommitSelectionInput, + type GitFileStatus, type GitHistoryCommit, type GitSummary as IpcGitSummary } from '@/lib/ipc' -export interface FileStatus { - path: string - oldPath?: string - status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked' - staged: boolean -} +export type FileStatus = GitFileStatus export interface ProjectFileStatus extends FileStatus { rootPath: string @@ -129,7 +125,7 @@ export const useGitStore = create((set, get) => ({ fetchStatus: async (path) => { try { - const files = (await pear.git.status(path)) as FileStatus[] + const files = await pear.git.status(path) if (!sameFileStatuses(get().files, files)) set({ files }) } catch { if (get().files.length > 0) set({ files: [] }) diff --git a/src/renderer/src/stores/project-store.ts b/src/renderer/src/stores/project-store.ts index 9722706a..26d8752a 100644 --- a/src/renderer/src/stores/project-store.ts +++ b/src/renderer/src/stores/project-store.ts @@ -1,29 +1,47 @@ import { create } from 'zustand' +import { z } from 'zod' +import { + ProjectIntegrationSchema, + ProjectRootSchema, + makeProjectSchema, + normalizeChannelName +} from '@shared/schemas/project' import { pear } from '@/lib/ipc' -export interface ProjectRoot { - id: string - name: string - path: string - pathExists: boolean +// Renderer enriches each root with `pathExists` populated by the main process. +const RendererProjectRootSchema = ProjectRootSchema.and( + z.object({ pathExists: z.boolean().optional() }) +).transform((value) => ({ + id: value.id, + name: value.name, + path: value.path, + pathExists: typeof value.pathExists === 'boolean' ? value.pathExists : true +})) + +const RendererProjectSchema = makeProjectSchema(RendererProjectRootSchema).transform((project) => ({ + ...project, + rootPathExists: project.roots.some((root) => root.path === project.rootPath && root.pathExists) +})) + +export type ProjectRoot = z.infer +export type ProjectIntegration = z.infer +export type Project = z.infer + +export { normalizeChannelName } + +function parseProject(value: unknown): Project | null { + const result = RendererProjectSchema.safeParse(value) + return result.success ? result.data : null } -export interface ProjectIntegration { - id: string - name: string - type: string +function parseIntegration(value: unknown): ProjectIntegration | null { + const result = ProjectIntegrationSchema.safeParse(value) + return result.success ? result.data : null } -export interface Project { - id: string - name: string - relayWorkspaceId: string - rootPath: string - rootPathExists: boolean - roots: ProjectRoot[] - channels: string[] - channelPeople: Record - integrations: ProjectIntegration[] +function parseRoot(value: unknown): ProjectRoot | null { + const result = RendererProjectRootSchema.safeParse(value) + return result.success ? result.data : null } function getBrokerChannels(project: Project | undefined): string[] { @@ -38,137 +56,11 @@ function getRelayWorkspaceName(project: Project): string { return `pear-${project.relayWorkspaceId}` } -function defaultRootName(path: string): string { - return path.split(/[\\/]/).filter(Boolean).pop() || path -} - -function normalizeRoot(value: unknown): ProjectRoot | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const path = typeof record.path === 'string' ? record.path : null - if (!path) return null - - return { - id: typeof record.id === 'string' ? record.id : path, - name: typeof record.name === 'string' && record.name.trim() - ? record.name.trim() - : defaultRootName(path), - path, - pathExists: typeof record.pathExists === 'boolean' ? record.pathExists : true - } -} - -function normalizeIntegration(value: unknown): ProjectIntegration | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const name = typeof record.name === 'string' ? record.name.trim() : '' - if (!name) return null - - return { - id: typeof record.id === 'string' ? record.id : crypto.randomUUID(), - name, - type: typeof record.type === 'string' && record.type.trim() ? record.type.trim() : 'custom' - } -} - -export function normalizeChannelName(value: string): string { - return value - .normalize('NFKD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') -} - -function normalizeChannels(value: unknown): string[] { - const channels = Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === 'string') - : [] - const deduped = Array.from(new Set(channels.map(normalizeChannelName).filter(Boolean))) - return deduped.length > 0 ? deduped : ['general'] -} - -function normalizePeopleList(value: unknown): string[] { - const people = Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === 'string') - : [] - const names = people.map((entry) => entry.trim()).filter(Boolean) - return Array.from(new Map(names.map((name) => [name.toLowerCase(), name])).values()) -} - -function normalizeChannelPeople(value: unknown, channels: string[]): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {} - - const channelSet = new Set(channels) - const result: Record = {} - for (const [rawChannelName, rawPeople] of Object.entries(value as Record)) { - const channelName = normalizeChannelName(rawChannelName) - if (!channelName || !channelSet.has(channelName)) continue - - const people = normalizePeopleList(rawPeople) - if (people.length > 0) { - result[channelName] = people - } - } - - return result -} - -function dedupeRoots(roots: ProjectRoot[]): ProjectRoot[] { - const seen = new Set() - const deduped: ProjectRoot[] = [] - - for (const root of roots) { - if (seen.has(root.path)) continue - seen.add(root.path) - deduped.push(root) - } - - return deduped -} - function getDefaultRoot(project: Project | undefined): ProjectRoot | undefined { if (!project) return undefined return project.roots.find((root) => root.pathExists) || project.roots[0] } -function normalizeProject(value: unknown): Project | null { - if (!value || typeof value !== 'object') return null - const record = value as Record - const id = typeof record.id === 'string' ? record.id : null - const name = typeof record.name === 'string' ? record.name : null - const relayWorkspaceId = typeof record.relayWorkspaceId === 'string' && record.relayWorkspaceId.trim() - ? record.relayWorkspaceId.trim() - : id || '' - const rootPath = typeof record.rootPath === 'string' ? record.rootPath : null - const roots = Array.isArray(record.roots) - ? dedupeRoots(record.roots.map(normalizeRoot).filter((entry): entry is ProjectRoot => entry !== null)) - : [] - const primaryRoot = rootPath || roots[0]?.path || null - const integrations = Array.isArray(record.integrations) - ? record.integrations - .map(normalizeIntegration) - .filter((entry): entry is ProjectIntegration => entry !== null) - : [] - - if (!id || !name || !primaryRoot || roots.length === 0) return null - - const channels = normalizeChannels(record.channels) - - return { - id, - name, - relayWorkspaceId, - rootPath: primaryRoot, - rootPathExists: roots.some((root) => root.path === primaryRoot && root.pathExists), - roots, - channels, - channelPeople: normalizeChannelPeople(record.channelPeople, channels), - integrations - } -} - interface ProjectState { projects: Project[] activeProjectId: string | null @@ -214,9 +106,10 @@ export const useProjectStore = create((set, get) => ({ load: async () => { set({ loading: true }) const data = await pear.project.list() - const projects = (data.projects as unknown[]) - .map(normalizeProject) - .filter((project): project is Project => project !== null) + const projects = data.projects.flatMap((raw) => { + const project = parseProject(raw) + return project ? [project] : [] + }) const activeProjectId = projects.some((project) => project.id === data.activeId) ? data.activeId : projects.find((project) => project.roots.some((root) => root.pathExists))?.id || null @@ -268,7 +161,7 @@ export const useProjectStore = create((set, get) => ({ }, addProject: async (name, rootPath) => { - const project = (await pear.project.add(name, rootPath)) as Project | null + const project = parseProject(await pear.project.add(name, rootPath)) if (project) { await pear.project.setActive(project.id) await get().load() @@ -311,7 +204,7 @@ export const useProjectStore = create((set, get) => ({ addRoot: async (name, rootPath) => { const { activeProjectId } = get() if (!activeProjectId) return null - const root = normalizeRoot(await pear.project.addRoot(activeProjectId, name, rootPath)) + const root = parseRoot(await pear.project.addRoot(activeProjectId, name, rootPath)) await get().load() if (root) { get().setActiveRoot(root.id) @@ -470,7 +363,7 @@ export const useProjectStore = create((set, get) => ({ addIntegration: async (name, type = 'custom') => { const { activeProjectId } = get() if (!activeProjectId || !name.trim()) return null - const integration = normalizeIntegration( + const integration = parseIntegration( await pear.project.addIntegration(activeProjectId, name.trim(), type) ) if (!integration) return null diff --git a/src/renderer/src/stores/ui-store.ts b/src/renderer/src/stores/ui-store.ts index b8d59cde..c68a56d8 100644 --- a/src/renderer/src/stores/ui-store.ts +++ b/src/renderer/src/stores/ui-store.ts @@ -1,4 +1,5 @@ import { create } from 'zustand' +import { z } from 'zod' import { getDirectMessageRoomId, getDirectMessageRoomTitle, @@ -7,8 +8,10 @@ import { export type ViewMode = 'terminal' | 'chat' | 'graph' | 'project-settings' | 'broker-details' | 'source-control' export type DialogType = 'add-project' | 'spawn-agent' | 'add-channel' | 'command-menu' | null -export type Theme = 'dark' | 'light' -export type TerminalLayout = 'tabs' | 'horizontal-split' +const ThemeSchema = z.enum(['dark', 'light']) +const TerminalLayoutSchema = z.enum(['tabs', 'horizontal-split']) +export type Theme = z.infer +export type TerminalLayout = z.infer export type AppTabKind = 'agents' | 'channel' | 'dm' | 'project-settings' | 'broker-details' | 'source-control' export interface AppTab { @@ -61,18 +64,14 @@ function applyTheme(theme: Theme): void { localStorage.setItem('pear-theme', theme) } -const savedTheme = (typeof localStorage !== 'undefined' - ? localStorage.getItem('pear-theme') - : null) as Theme | null - -const initialTheme: Theme = savedTheme || 'dark' - -const savedTerminalLayout = (typeof localStorage !== 'undefined' - ? localStorage.getItem('pear-terminal-layout') - : null) as TerminalLayout | null +function readStored(key: string, schema: z.ZodType, fallback: T): T { + if (typeof localStorage === 'undefined') return fallback + const parsed = schema.safeParse(localStorage.getItem(key)) + return parsed.success ? parsed.data : fallback +} -const initialTerminalLayout: TerminalLayout = - savedTerminalLayout === 'tabs' ? savedTerminalLayout : 'horizontal-split' +const initialTheme = readStored('pear-theme', ThemeSchema, 'dark') +const initialTerminalLayout = readStored('pear-terminal-layout', TerminalLayoutSchema, 'horizontal-split') // Apply on load if (typeof document !== 'undefined') { diff --git a/src/shared/lib/broker-events.ts b/src/shared/lib/broker-events.ts new file mode 100644 index 00000000..71db21ea --- /dev/null +++ b/src/shared/lib/broker-events.ts @@ -0,0 +1,31 @@ +/** + * Shared helpers for sanitizing BrokerEvent payloads before storing them in + * memory or shipping them to the renderer. Used by both the main process + * (`src/main/broker.ts`) and the renderer agent store + * (`src/renderer/src/stores/agent-store.ts`). + */ + +const MAX_EVENT_TEXT_CHARS = 1_200 +const TRUNCATABLE_EVENT_KEYS = ['body', 'chunk', 'message', 'reason', 'lastError'] as const + +export function normalizeEventTimestamp(value: unknown): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + return undefined + } + return value < 1_000_000_000_000 ? value * 1_000 : value +} + +function truncate(value: unknown): unknown { + if (typeof value !== 'string' || value.length <= MAX_EVENT_TEXT_CHARS) return value + return `${value.slice(0, MAX_EVENT_TEXT_CHARS)}...` +} + +export function compactBrokerEvent>(event: T): T { + const compacted = { ...event } + for (const key of TRUNCATABLE_EVENT_KEYS) { + if (key in compacted) { + compacted[key as keyof T] = truncate(compacted[key as keyof T]) as T[keyof T] + } + } + return compacted +} diff --git a/src/shared/schemas/project.ts b/src/shared/schemas/project.ts new file mode 100644 index 00000000..b9428c72 --- /dev/null +++ b/src/shared/schemas/project.ts @@ -0,0 +1,160 @@ +import { z } from 'zod' + +export function normalizeChannelName(value: string): string { + return value + .normalize('NFKD') + .replace(/[̀-ͯ]/g, '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} + +function defaultRootName(path: string): string { + return path.split(/[\\/]/).filter(Boolean).pop() || path +} + +const trimmedString = z.string().transform((value) => value.trim()) + +const stringArray = z + .unknown() + .transform((value): string[] => + Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : [] + ) + +const channelsSchema = stringArray.transform((values) => { + const deduped = Array.from(new Set(values.map(normalizeChannelName).filter(Boolean))) + return deduped.length > 0 ? deduped : ['general'] +}) + +const peopleListSchema = stringArray.transform((values) => { + const trimmed = values.map((entry) => entry.trim()).filter(Boolean) + return Array.from(new Map(trimmed.map((name) => [name.toLowerCase(), name])).values()) +}) + +const channelPeopleSchema = z + .unknown() + .transform((value): Record => + value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} + ) + +function buildChannelPeople(raw: Record, channels: string[]): Record { + const channelSet = new Set(channels) + const result: Record = {} + for (const [rawChannel, rawPeople] of Object.entries(raw)) { + const channel = normalizeChannelName(rawChannel) + if (!channel || !channelSet.has(channel)) continue + const people = peopleListSchema.parse(rawPeople) + if (people.length > 0) result[channel] = people + } + return result +} + +function dedupeRoots(roots: T[]): T[] { + const seen = new Set() + const out: T[] = [] + for (const root of roots) { + if (seen.has(root.path)) continue + seen.add(root.path) + out.push(root) + } + return out +} + +export const ProjectRootSchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + path: z.string().min(1) + }) + .passthrough() + .transform((value) => ({ + id: value.id?.trim() || value.path, + name: value.name?.trim() || defaultRootName(value.path), + path: value.path + })) + +export const ProjectIntegrationSchema = z + .object({ + id: z.string().optional(), + name: trimmedString, + type: z.string().optional() + }) + .passthrough() + .refine((value) => value.name.length > 0, { message: 'integration name is required' }) + .transform((value) => ({ + id: value.id?.trim() || crypto.randomUUID(), + name: value.name, + type: value.type?.trim() || 'custom' + })) + +/** + * Build a project schema parameterized by the per-process root shape. + * + * Main stores `{ id, name, path }` on disk. Renderer adds `pathExists` + * computed at IPC time. + */ +export function makeProjectSchema( + rootSchema: z.ZodType +) { + return z + .object({ + id: z.string().min(1), + name: z.string().min(1), + relayWorkspaceId: z.string().optional(), + rootPath: z.string().optional(), + roots: z.array(z.unknown()).optional(), + channels: z.unknown().optional(), + channelPeople: z.unknown().optional(), + integrations: z.array(z.unknown()).optional() + }) + .passthrough() + .transform((value, ctx) => { + const roots = dedupeRoots( + (value.roots ?? []).flatMap((entry) => { + const parsed = rootSchema.safeParse(entry) + return parsed.success ? [parsed.data] : [] + }) + ) + const rootPath = value.rootPath?.trim() || roots[0]?.path || '' + if (!rootPath || roots.length === 0) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'project has no usable root' }) + return z.NEVER + } + const channels = channelsSchema.parse(value.channels) + const integrations = (value.integrations ?? []).flatMap((entry) => { + const parsed = ProjectIntegrationSchema.safeParse(entry) + return parsed.success ? [parsed.data] : [] + }) + const channelPeople = buildChannelPeople(channelPeopleSchema.parse(value.channelPeople), channels) + return { + id: value.id, + name: value.name, + relayWorkspaceId: value.relayWorkspaceId?.trim() || value.id, + rootPath, + roots, + channels, + channelPeople, + integrations + } + }) +} + +export const StoreDataSchema =

(projectSchema: z.ZodType) => + z + .object({ + projects: z.array(z.unknown()).optional(), + activeProjectId: z.union([z.string(), z.null()]).optional() + }) + .passthrough() + .transform((value) => ({ + projects: (value.projects ?? []).flatMap((entry) => { + const parsed = projectSchema.safeParse(entry) + return parsed.success ? [parsed.data] : [] + }), + activeProjectId: typeof value.activeProjectId === 'string' ? value.activeProjectId : null + })) + +export const PeopleListSchema = peopleListSchema diff --git a/tsconfig.node.json b/tsconfig.node.json index 3df47fd1..da2aac2c 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -16,6 +16,7 @@ "include": [ "src/main/**/*", "src/preload/**/*", + "src/shared/**/*", "electron.vite.config.ts" ] } diff --git a/tsconfig.web.json b/tsconfig.web.json index f5d45970..91bc7796 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -8,15 +8,16 @@ "allowSyntheticDefaultImports": true, "esModuleInterop": true, "outDir": "./out", - "rootDir": "./src/renderer/src", + "rootDir": "./src", "strict": true, "skipLibCheck": true, "resolveJsonModule": true, "isolatedModules": true, "declaration": true, "paths": { - "@/*": ["./src/renderer/src/*"] + "@/*": ["./src/renderer/src/*"], + "@shared/*": ["./src/shared/*"] } }, - "include": ["src/renderer/src/**/*"] + "include": ["src/renderer/src/**/*", "src/shared/**/*"] }