diff --git a/.gitignore b/.gitignore index d5f0f61a..cd5fe6ad 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,6 @@ docs/* # Local scratch space (UI preview harnesses, throwaway scripts) /.tmp/ +GEMINI.md +.idea/ +CLAUDE.md diff --git a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt index c7e7e3e3..4d7e810f 100644 --- a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt +++ b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt @@ -60,7 +60,7 @@ private const val SLOW_REQUEST_SECONDS = 130L /** [json] is the error reply's body, for routes that explain a refusal (e.g. close warnings). */ class ApiException(message: String, val status: Int = 0, val json: JSONObject? = null) : IOException(message) -data class MobileProject(val id: String, val name: String, val agentName: String?) +data class MobileProject(val id: String, val name: String, val agentName: String?, val path: String? = null) /** What another saved computer reported in the last poll, under the name the phone shows for it. */ data class ComputerSnapshot(val label: String, val agents: List, val usage: List) @@ -322,10 +322,22 @@ class RemoteClient( val list = JSONArray(apiRaw("GET", "/api/mobile/projects", null, pairedTokenOrThrow())) return List(list.length()) { i -> val p = list.getJSONObject(i) - MobileProject(p.getString("id"), p.getString("name"), p.optString("agentName").ifEmpty { null }) + MobileProject( + p.getString("id"), + p.getString("name"), + p.optString("agentName").ifEmpty { null }, + p.optString("path").ifEmpty { null }, + ) } } + /** Create a new git project folder on the desktop and add it; returns the project. */ + suspend fun createProject(name: String, parentDir: String): MobileProject { + val body = JSONObject().put("name", name).put("parentDir", parentDir) + val p = api("POST", "/api/mobile/projects", body, pairedTokenOrThrow()) + return MobileProject(p.getString("id"), p.getString("name"), null, p.optString("path").ifEmpty { null }) + } + /** * Agents and models a paired phone may start tasks with. Empty from a desktop that predates * the route: it refuses unknown paths for a paired token with 403. Tasks then use its diff --git a/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt index 8e520960..0bcaa76e 100644 --- a/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt +++ b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt @@ -10,17 +10,20 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button @@ -74,6 +77,9 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - var prompt by rememberSaveable { mutableStateOf("") } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + var addingProject by rememberSaveable { mutableStateOf(false) } + var newProjectName by rememberSaveable { mutableStateOf("") } + var newProjectParent by rememberSaveable { mutableStateOf("") } // Same default title as the phone web UI: the start of the prompt. val title = name.trim().ifEmpty { prompt.trim().replace(Regex("\\s+"), " ").take(80) } @@ -141,6 +147,12 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - .padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { + Text( + "PROJECT", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = AppTheme.extra.textMuted, + ) val loaded = projects when { loaded == null -> Text("Loading projects…", color = AppTheme.extra.textMuted) @@ -194,6 +206,61 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - } } } + if (addingProject) { + OutlinedTextField( + value = newProjectName, + onValueChange = { newProjectName = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("New project name") }, + singleLine = true, + enabled = !busy, + shape = MaterialTheme.shapes.large, + ) + OutlinedTextField( + value = newProjectParent, + onValueChange = { newProjectParent = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Folder on your computer") }, + supportingText = { Text("A new git repository is created inside it.") }, + singleLine = true, + enabled = !busy, + shape = MaterialTheme.shapes.large, + ) + Button( + modifier = Modifier.fillMaxWidth(), + enabled = !busy && newProjectName.isNotBlank() && newProjectParent.isNotBlank(), + shape = MaterialTheme.shapes.large, + onClick = { + busy = true + error = null + scope.launch { + try { + val created = client.createProject(newProjectName.trim(), newProjectParent.trim()) + projects = (projects ?: emptyList()) + created + projectId = created.id + newProjectName = "" + addingProject = false + } catch (e: ApiException) { + failed(e) + } finally { + busy = false + } + } + }, + ) { Text(if (busy) "Creating…" else "Create project", fontWeight = FontWeight.Bold) } + } else if (projects != null) { + TextButton( + onClick = { + if (newProjectParent.isEmpty()) { + // Suggest the folder the existing projects already live in. + newProjectParent = projects?.firstNotNullOfOrNull { it.path } + ?.substringBeforeLast('/', "").orEmpty() + } + addingProject = true + }, + enabled = !busy, + ) { Text("New project…") } + } OutlinedTextField( value = prompt, onValueChange = { prompt = it }, diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index ae79e1ae..b94edce9 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -86,8 +86,9 @@ "GetRemoteStatus": "get_remote_status", "GeneratePairingPin": "generate_pairing_pin", "Remote_GetProjectsRequest": "remote_get_projects_request", - "Remote_CreateTaskRequest": "remote_create_task_request", "Remote_GetAgentsRequest": "remote_get_agents_request", + "Remote_CreateTaskRequest": "remote_create_task_request", + "Remote_AddProjectRequest": "remote_add_project_request", "MCP_ReadMindMapRequest": "mcp_read_mindmap_request", "MCP_ReadReasoningRequest": "mcp_read_reasoning_request", "MCP_UpdateReasoningRequest": "mcp_update_reasoning_request", @@ -106,6 +107,7 @@ "Remote_GetDiffRequest": "remote_get_diff_request", "Remote_GetMergeReadinessRequest": "remote_get_merge_readiness_request", "Remote_MergeTaskRequest": "remote_merge_task_request", + "Remote_GetUsageRequest": "remote_get_usage_request", "Remote_GetFixCiPromptRequest": "remote_get_fix_ci_prompt_request", "Remote_SendFixCiPromptRequest": "remote_send_fix_ci_prompt_request", "Remote_UpdateTaskStatus": "remote_update_task_status", diff --git a/electron/ipc/register-wiring.test.ts b/electron/ipc/register-wiring.test.ts index 27f2b797..c06f1fde 100644 --- a/electron/ipc/register-wiring.test.ts +++ b/electron/ipc/register-wiring.test.ts @@ -87,6 +87,7 @@ const NOT_HANDLED_HERE: readonly string[] = [ IPC.NotificationFailed, IPC.PlanContent, IPC.PrChecksUpdate, + IPC.Remote_AddProjectRequest, IPC.Remote_CloseTaskRequest, IPC.Remote_CreateTaskRequest, IPC.Remote_GetAgentsRequest, @@ -97,6 +98,7 @@ const NOT_HANDLED_HERE: readonly string[] = [ IPC.Remote_GetFixCiPromptRequest, IPC.Remote_SendFixCiPromptRequest, IPC.Remote_MergeTaskRequest, + IPC.Remote_GetUsageRequest, IPC.Remote_SetNotesRequest, IPC.SuperProductivityOpenTaskRequested, IPC.UpdateStatusChanged, diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 00d3955f..096fef6b 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -73,6 +73,7 @@ import { readCoverageSummary } from './coverage.js'; import { loadEslintQualityFindings } from './eslint-quality-findings.js'; import { buildVerifyEnv, validateVerifyCommand, verificationRunner } from './verify.js'; import { scanEvidence } from './evidence-scan.js'; +import { createProjectFolder } from '../remote/project-create.js'; import { startRemoteServer, getMCPLogs, @@ -88,6 +89,7 @@ import type { RemoteTaskContext, RemoteTaskDiff, } from '../remote/protocol.js'; +import type { UsageProvider, UsageState } from './shared-types.js'; import { atomicWriteFileSync } from '../mcp/atomic.js'; import { getUserDataDir } from '../user-data-dir.js'; import { @@ -1801,6 +1803,13 @@ export function registerAllHandlers(win: BrowserWindow): void { getAgentChoices: () => callRenderer(IPC.Remote_GetAgentsRequest, {}), createTaskFromMobile: (req: MobileTaskRequest) => callRenderer<{ taskId: string }>(IPC.Remote_CreateTaskRequest, req), + createProjectFromMobile: async (req: { parentDir: string; name: string }) => { + const projectPath = await createProjectFolder(req.parentDir, req.name); + return callRenderer(IPC.Remote_AddProjectRequest, { + name: path.basename(projectPath), + path: projectPath, + }); + }, getTaskNotes: (taskId: string) => callRenderer<{ notes: string }>(IPC.Remote_GetNotesRequest, { taskId }).then((r) => r.notes), setTaskNotes: (taskId: string, notes: string) => @@ -1821,6 +1830,7 @@ export function registerAllHandlers(win: BrowserWindow): void { ), mergeTaskFromMobile: (req: { taskId: string; squash: boolean; cleanup: boolean }) => callRenderer<{ ok: boolean }>(IPC.Remote_MergeTaskRequest, req).then(() => {}), + getUsage: () => callRenderer>(IPC.Remote_GetUsageRequest, {}), getTaskAttention: (taskId: string): RemoteAttentionState => taskAttention.get(taskId) ?? 'idle', getTaskContext: (taskId: string) => taskContext.get(taskId), getCollapsedTaskIds: (): string[] => { diff --git a/electron/ipc/shared-types.ts b/electron/ipc/shared-types.ts index cabbaf11..748185bd 100644 --- a/electron/ipc/shared-types.ts +++ b/electron/ipc/shared-types.ts @@ -361,6 +361,18 @@ export type UsageResult = /** Transient failure — the renderer keeps its last good snapshot. */ | { status: 'error'; message: string }; +/** The renderer's view of one provider's usage, also served to the phone. */ +export interface UsageState { + fiveHour: UsageWindow | null; + sevenDay: UsageWindow | null; + creditUsage?: CreditUsage | null; + /** When the current windows were fetched; null until the first success. */ + fetchedAt: number | null; + /** `unavailable` means no subscription login — the bar hides. `error` keeps the last snapshot. */ + status: 'idle' | 'ok' | 'error' | 'unavailable'; + error: string | null; +} + /** One OS process in the resources panel. */ export interface ResourceProcess { pid: number; diff --git a/electron/preload.cjs b/electron/preload.cjs index 4592e4dc..b0c6f26c 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -93,8 +93,9 @@ const ALLOWED_CHANNELS = new Set([ 'get_remote_status', 'generate_pairing_pin', 'remote_get_projects_request', - 'remote_create_task_request', 'remote_get_agents_request', + 'remote_create_task_request', + 'remote_add_project_request', 'mcp_read_mindmap_request', 'mcp_read_reasoning_request', 'mcp_update_reasoning_request', @@ -113,6 +114,7 @@ const ALLOWED_CHANNELS = new Set([ 'remote_get_diff_request', 'remote_get_merge_readiness_request', 'remote_merge_task_request', + 'remote_get_usage_request', 'remote_get_fix_ci_prompt_request', 'remote_send_fix_ci_prompt_request', 'remote_update_task_status', diff --git a/electron/remote/notes-route.test.ts b/electron/remote/notes-route.test.ts index 22a950f2..722c748d 100644 --- a/electron/remote/notes-route.test.ts +++ b/electron/remote/notes-route.test.ts @@ -246,3 +246,41 @@ describe('notes route without a renderer bridge', () => { expect(res.status).toBe(503); }); }); + +describe('GET /api/mobile/usage', () => { + const usage = { + claude: { + fiveHour: { usedPercent: 40, resetsAt: null }, + sevenDay: null, + fetchedAt: 1, + status: 'ok' as const, + error: null, + }, + codex: { + fiveHour: null, + sevenDay: null, + fetchedAt: null, + status: 'idle' as const, + error: null, + }, + }; + const getUsage = vi.fn(async () => usage); + beforeEach(() => start({ getUsage })); + + it('returns the desktop snapshot for mobile and paired tokens', async () => { + for (const token of [mobileToken, await pair()]) { + const res = await request('GET', '/api/mobile/usage', { token }); + expect(res.status).toBe(200); + expect(res.json).toEqual(usage); + } + }); + + it('rejects agent tokens and non-GET methods', async () => { + getUsage.mockClear(); + for (const token of [coordinatorToken, subtaskToken]) { + expect((await request('GET', '/api/mobile/usage', { token })).status).toBe(403); + } + expect((await request('POST', '/api/mobile/usage', { token: mobileToken })).status).toBe(405); + expect(getUsage).not.toHaveBeenCalled(); + }); +}); diff --git a/electron/remote/pairing.test.ts b/electron/remote/pairing.test.ts index 665c7b39..9e35b801 100644 --- a/electron/remote/pairing.test.ts +++ b/electron/remote/pairing.test.ts @@ -111,6 +111,7 @@ async function startServer(enableRemembered = true) { beforeEach(async () => { createTaskFromMobile.mockClear(); getProjects.mockClear(); + getAgentChoices.mockClear(); credentialsDir = mkdtempSync(join(tmpdir(), 'phone-pairing-')); await startServer(); }); @@ -316,6 +317,7 @@ describe('paired-mobile routes', () => { projectId: 'proj-1', name: 'Fix bug', prompt: 'Investigate the crash', + agentId: 'claude', }); expect(res.status).toBe(201); expect(await res.json()).toEqual({ taskId: 'task-123' }); @@ -323,6 +325,7 @@ describe('paired-mobile routes', () => { projectId: 'proj-1', name: 'Fix bug', prompt: 'Investigate the crash', + agentId: 'claude', }); }); diff --git a/electron/remote/project-create.test.ts b/electron/remote/project-create.test.ts new file mode 100644 index 00000000..83defa15 --- /dev/null +++ b/electron/remote/project-create.test.ts @@ -0,0 +1,30 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createProjectFolder } from './project-create.js'; + +describe('createProjectFolder', () => { + let parent: string; + beforeEach(() => { + parent = fs.mkdtempSync(path.join(os.tmpdir(), 'pc-newproj-')); + }); + afterEach(() => { + fs.rmSync(parent, { recursive: true, force: true }); + }); + + it('creates a git repository in a new folder', async () => { + const created = await createProjectFolder(parent, ' demo '); + expect(created).toBe(path.join(parent, 'demo')); + expect(fs.existsSync(path.join(created, '.git'))).toBe(true); + }); + + it('refuses an existing folder, a missing parent and path-like names', async () => { + fs.mkdirSync(path.join(parent, 'taken')); + await expect(createProjectFolder(parent, 'taken')).rejects.toThrow('already exists'); + await expect(createProjectFolder(path.join(parent, 'nope'), 'x')).rejects.toThrow('parent'); + await expect(createProjectFolder('relative/dir', 'x')).rejects.toThrow('absolute'); + for (const bad of ['', '..', 'a/b', 'a\\b', 'a\nb']) + await expect(createProjectFolder(parent, bad)).rejects.toThrow(); + }); +}); diff --git a/electron/remote/project-create.ts b/electron/remote/project-create.ts new file mode 100644 index 00000000..4d7ef172 --- /dev/null +++ b/electron/remote/project-create.ts @@ -0,0 +1,52 @@ +import { execFile } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { promisify } from 'util'; +import { validatePath } from '../ipc/validate.js'; + +const execFileAsync = promisify(execFile); + +const MAX_NAME_LENGTH = 100; + +/** A folder name only: no separators, dot-names or control characters. */ +export function validateProjectFolderName(raw: unknown): string { + if (typeof raw !== 'string') throw new Error('name must be a string'); + const name = raw.trim(); + if (!name) throw new Error('name must be a non-empty string'); + if (name.length > MAX_NAME_LENGTH) + throw new Error(`name must be ${MAX_NAME_LENGTH} characters or fewer`); + // eslint-disable-next-line no-control-regex + if (/[/\\\x00-\x1f\x7f]/.test(name) || name === '.' || name === '..') + throw new Error('name must be a plain folder name'); + return name; +} + +/** + * Create `/` as a new git repository for a phone-created + * project. The parent must already exist and the folder must not, so this + * never writes into or over something the user already has. + */ +export async function createProjectFolder(parentDir: unknown, rawName: unknown): Promise { + const name = validateProjectFolderName(rawName); + if (typeof parentDir !== 'string') throw new Error('parentDir must be a string'); + const parent = parentDir.startsWith('~/') + ? path.join(os.homedir(), parentDir.slice(2)) + : parentDir === '~' + ? os.homedir() + : parentDir; + validatePath(parent, 'parentDir'); + if (!(await fs.promises.stat(parent).catch(() => null))?.isDirectory()) + throw new Error('parent folder does not exist'); + + const projectPath = path.join(parent, name); + try { + await fs.promises.mkdir(projectPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'EEXIST') + throw new Error('a folder with that name already exists'); + throw err; + } + await execFileAsync('git', ['init'], { cwd: projectPath }); + return projectPath; +} diff --git a/electron/remote/server.ts b/electron/remote/server.ts index e45d5b26..45edfe6d 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -58,9 +58,11 @@ import { } from '../shared/evidence.js'; import type { SessionCaller, SessionCapabilities } from '../shared/delegation-types.js'; import type { ReasoningDocument } from '../shared/reasoning.js'; +import type { UsageProvider, UsageState } from '../ipc/shared-types.js'; import type { ReasoningUpdate } from '../shared/reasoning-state.js'; import type { Coordinator } from '../mcp/coordinator.js'; import { validateBranchName } from '../mcp/validation.js'; +import { validateProjectFolderName } from './project-create.js'; import type { ApiTaskDetail, LandSelfInput, SubtaskVerification } from '../mcp/types.js'; import { parseSignalDoneInput } from '../shared/completion-report.js'; import { CODEX_MODEL_PATTERN } from '../shared/ask-code-models.js'; @@ -309,6 +311,8 @@ interface RemoteServer { export interface RemoteProject { id: string; name: string; + /** Desktop folder, so the phone can suggest where a new project goes. */ + path?: string; } /** The task a paired phone asked for; agent and model fall back to the desktop default. */ @@ -1026,6 +1030,8 @@ export function startRemoteServer(opts: { getAgentChoices?: () => Promise; /** Create a top-level task on behalf of a paired phone (renderer-backed). */ createTaskFromMobile?: (req: MobileTaskRequest) => Promise<{ taskId: string }>; + /** Create a new git project folder and register it (renderer-backed). */ + createProjectFromMobile?: (req: { parentDir: string; name: string }) => Promise; readMindMap?: (taskId: string) => Promise; readReasoning?: (taskId: string) => Promise; updateReasoning?: (taskId: string, update: ReasoningUpdate) => Promise; @@ -1062,6 +1068,8 @@ export function startRemoteServer(opts: { closeTaskFromMobile?: (taskId: string, force: boolean) => Promise; /** A task's diff against its base branch (renderer-backed). */ getTaskDiff?: (taskId: string) => Promise; + /** The desktop's last agent-subscription usage snapshot (renderer-backed). */ + getUsage?: () => Promise>; /** Renderer-derived task attention state (needs input, working, ready, …). */ getTaskAttention?: (taskId: string) => RemoteAttentionState; getTaskContext?: (taskId: string) => RemoteTaskContext | undefined; @@ -1521,6 +1529,30 @@ export function startRemoteServer(opts: { return; } + if (url.pathname === '/api/mobile/projects' && req.method === 'POST') { + const createProject = opts.createProjectFromMobile; + if (!createProject) return jsonEnd(503, { error: 'project creation unavailable' }); + readJsonBody(req) + .then(async (body) => { + let name: string; + try { + name = validateProjectFolderName(body.name); + } catch (err) { + return jsonEnd(400, { error: err instanceof Error ? err.message : String(err) }); + } + const parentDir = typeof body.parentDir === 'string' ? body.parentDir : ''; + if (!parentDir) + return jsonEnd(400, { error: 'parentDir must be a non-empty string' }); + try { + jsonEnd(201, await createProject({ parentDir, name })); + } catch (err) { + jsonEnd(400, { error: err instanceof Error ? err.message : String(err) }); + } + }) + .catch(() => jsonEnd(400, { error: 'bad request' })); + return; + } + if (url.pathname === '/api/mobile/tasks' && req.method === 'POST') { const createTask = opts.createTaskFromMobile; if (!createTask) return jsonEnd(503, { error: 'task creation unavailable' }); @@ -1563,6 +1595,21 @@ export function startRemoteServer(opts: { return jsonEnd(405, { error: 'method not allowed' }); } + // --- Subscription usage (read-only: mobile + paired) --- + // Serves the snapshot the desktop status bar already polls, so phones + // never add requests to the rate-limited usage endpoints. + if (url.pathname === '/api/mobile/usage') { + if (tokenClass !== 'mobile' && tokenClass !== 'paired') + return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'GET') return jsonEnd(405, { error: 'method not allowed' }); + if (!opts.getUsage) return jsonEnd(503, { error: 'usage unavailable' }); + opts + .getUsage() + .then((usage) => jsonEnd(200, usage)) + .catch((err) => jsonEnd(500, { error: String(err) })); + return; + } + // --- Task diff (read: mobile + paired) --- // The same changes the desktop's diff view shows. Read-only, like notes: // the view-only token already streams the terminals that made them. diff --git a/src/ipc/types.ts b/src/ipc/types.ts index 48bdc38f..c41a3f22 100644 --- a/src/ipc/types.ts +++ b/src/ipc/types.ts @@ -39,6 +39,7 @@ export type { CreditUsage, UsageProvider, UsageResult, + UsageState, UsageWindow, VerificationRun, VerificationRunStatus, diff --git a/src/remote/AgentList.client.test.tsx b/src/remote/AgentList.client.test.tsx index 894945b4..109ada21 100644 --- a/src/remote/AgentList.client.test.tsx +++ b/src/remote/AgentList.client.test.tsx @@ -4,6 +4,9 @@ import { AgentList } from './AgentList'; import { connect, reconnect } from './ws'; import type { RemoteAgent } from '../../electron/remote/protocol'; +// Usage meters poll over HTTP; they have their own tests. +vi.mock('./UsageStrip', () => ({ UsageStrip: () => null })); + class Socket { static OPEN = 1; static CONNECTING = 0; diff --git a/src/remote/AgentList.tsx b/src/remote/AgentList.tsx index f9697d03..88884cbe 100644 --- a/src/remote/AgentList.tsx +++ b/src/remote/AgentList.tsx @@ -2,6 +2,7 @@ import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'; import { agents, status, canControl } from './ws'; import { agentStatusDisplay, needsYou as agentNeedsYou } from './attention'; import { ConnectionBanner } from './ConnectionBanner'; +import { UsageStrip } from './UsageStrip'; import { NotificationSettings } from './NotificationSettings'; import { readLocal, writeLocal } from './storage'; import { ProjectSwatch } from '../components/ProjectSwatch'; @@ -78,6 +79,7 @@ export function AgentList(props: AgentListProps) {
+ diff --git a/src/remote/App.client.test.tsx b/src/remote/App.client.test.tsx index f031ffe0..965371a8 100644 --- a/src/remote/App.client.test.tsx +++ b/src/remote/App.client.test.tsx @@ -3,6 +3,9 @@ import { render } from 'solid-js/web'; import { App } from './App'; import { reconnect } from './ws'; +// Usage meters poll over HTTP; they have their own tests. +vi.mock('./UsageStrip', () => ({ UsageStrip: () => null })); + class Socket { static OPEN = 1; static CONNECTING = 0; diff --git a/src/remote/NewTaskScreen.client.test.tsx b/src/remote/NewTaskScreen.client.test.tsx index ef223fb5..a7a60fd6 100644 --- a/src/remote/NewTaskScreen.client.test.tsx +++ b/src/remote/NewTaskScreen.client.test.tsx @@ -1,10 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'solid-js/web'; import { NewTaskScreen } from './NewTaskScreen'; -import { fetchProjects, createTask, ApiError } from './api'; +import { fetchProjects, fetchAgents, createTask, ApiError } from './api'; vi.mock('./api', () => ({ fetchProjects: vi.fn(), + fetchAgents: vi.fn(), createTask: vi.fn(), ApiError: class extends Error { constructor( @@ -28,6 +29,7 @@ beforeEach(() => { { id: 'p1', name: 'First project' }, { id: 'p2', name: 'Second project', agentName: 'Agent B' }, ]); + vi.mocked(fetchAgents).mockResolvedValue([]); }); afterEach(() => { dispose?.(); @@ -132,4 +134,28 @@ describe('creating a task from the phone', () => { expect(onCreated).not.toHaveBeenCalled(); expect(localStorage.getItem('parallel-mobile:new-prompt')).toBe('A different task'); }); + it('allows choosing an agent and passes agentId to createTask', async () => { + vi.mocked(fetchAgents).mockResolvedValue([ + { id: 'claude', name: 'Claude Code', available: true, isDefault: true }, + { id: 'codex', name: 'Codex CLI', available: true, isDefault: false }, + ]); + vi.mocked(createTask).mockResolvedValue('task-with-agent'); + const { onCreated } = mount(); + await vi.waitFor(() => expect(host.querySelectorAll('select').length).toBe(2)); + const agentSelect = host.querySelectorAll('select')[1]; + expect(agentSelect.value).toBe('claude'); + agentSelect.value = 'codex'; + agentSelect.dispatchEvent(new Event('change', { bubbles: true })); + type('textarea', 'A task for codex'); + submit(); + await vi.waitFor(() => + expect(createTask).toHaveBeenCalledWith({ + projectId: 'p1', + name: 'A task for codex', + prompt: 'A task for codex', + agentId: 'codex', + }), + ); + expect(onCreated).toHaveBeenCalledWith('task-with-agent', 'A task for codex'); + }); }); diff --git a/src/remote/NewTaskScreen.tsx b/src/remote/NewTaskScreen.tsx index f13c6cd0..81fea161 100644 --- a/src/remote/NewTaskScreen.tsx +++ b/src/remote/NewTaskScreen.tsx @@ -1,5 +1,12 @@ import { createSignal, createEffect, onMount, onCleanup, For, Show } from 'solid-js'; -import { fetchProjects, createTask, ApiError, type MobileProject } from './api'; +import { + fetchProjects, + fetchAgents, + createTask, + ApiError, + type MobileProject, + type MobileAgent, +} from './api'; import { clearPairedToken } from './auth'; import { readLocal, writeLocal } from './storage'; import { status } from './ws'; @@ -17,7 +24,9 @@ export function NewTaskScreen(props: NewTaskScreenProps) { disposed = true; }); const [projects, setProjects] = createSignal([]); + const [agents, setAgents] = createSignal([]); const [projectId, setProjectId] = createSignal(readLocal('project')); + const [agentId, setAgentId] = createSignal(readLocal('new-agent')); const [name, setName] = createSignal(readLocal('new-name')); const [prompt, setPrompt] = createSignal(readLocal('new-prompt')); const [error, setError] = createSignal(null); @@ -26,6 +35,7 @@ export function NewTaskScreen(props: NewTaskScreenProps) { const title = () => name().trim() || prompt().trim().replace(/\s+/g, ' ').slice(0, 80); const agentName = () => projects().find((p) => p.id === projectId())?.agentName; createEffect(() => writeLocal('project', projectId())); + createEffect(() => writeLocal('new-agent', agentId())); createEffect(() => writeLocal('new-name', name())); createEffect(() => writeLocal('new-prompt', prompt())); @@ -42,10 +52,18 @@ export function NewTaskScreen(props: NewTaskScreenProps) { setLoading(true); setError(null); try { - const list = await fetchProjects(); + const [list, agentList] = await Promise.all([ + fetchProjects(), + fetchAgents().catch(() => [] as MobileAgent[]), + ]); if (disposed) return; setProjects(list); + setAgents(agentList); if (!list.some((p) => p.id === projectId())) setProjectId(list[0]?.id ?? ''); + if (!agentList.some((a) => a.id === agentId())) { + const defaultAgent = agentList.find((a) => a.isDefault) ?? agentList[0]; + setAgentId(defaultAgent?.id ?? ''); + } } catch (err) { if (disposed) return; if (!handleAuthError(err)) @@ -71,6 +89,7 @@ export function NewTaskScreen(props: NewTaskScreenProps) { projectId: projectId(), name: taskName, prompt: draftPrompt.trim(), + ...(agentId() ? { agentId: agentId() } : {}), }); if (readLocal('new-prompt') === draftPrompt && readLocal('new-name') === draftName) { writeLocal('new-name', ''); @@ -133,7 +152,27 @@ export function NewTaskScreen(props: NewTaskScreenProps) { - + 0}> + + +

{agentName() ? `Runs with ${agentName()}` : 'Runs with your default agent'} · Desktop settings diff --git a/src/remote/UsageStrip.client.test.tsx b/src/remote/UsageStrip.client.test.tsx new file mode 100644 index 00000000..abdc863a --- /dev/null +++ b/src/remote/UsageStrip.client.test.tsx @@ -0,0 +1,51 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { render } from 'solid-js/web'; +import { UsageStrip } from './UsageStrip'; +import { fetchUsage } from './api'; + +vi.mock('./ws', () => ({ status: () => 'connected' })); +vi.mock('./api', () => ({ fetchUsage: vi.fn() })); + +let dispose: (() => void) | undefined; +afterEach(() => { + dispose?.(); + document.body.innerHTML = ''; +}); + +async function mount(): Promise { + const host = document.createElement('div'); + document.body.append(host); + dispose = render(() => , host); + await vi.waitFor(() => expect(fetchUsage).toHaveBeenCalled()); + await Promise.resolve(); + return host; +} + +it('shows the providers the desktop has a snapshot for', async () => { + vi.mocked(fetchUsage).mockResolvedValue({ + claude: { + fiveHour: { usedPercent: 85, resetsAt: null }, + sevenDay: { usedPercent: 20, resetsAt: null }, + fetchedAt: 1, + status: 'ok', + error: null, + }, + codex: { fiveHour: null, sevenDay: null, fetchedAt: null, status: 'unavailable', error: 'x' }, + }); + const host = await mount(); + await vi.waitFor(() => expect(host.querySelector('.mobile-usage')).not.toBeNull()); + expect(host.textContent).toContain('Claude'); + expect(host.textContent).not.toContain('Codex'); + expect(host.textContent).toContain('15% left'); + expect(host.textContent).toContain('80% left'); + expect(host.querySelector('.usage-meter.warn')).not.toBeNull(); +}); + +it('stays hidden when no subscription usage is readable', async () => { + vi.mocked(fetchUsage).mockResolvedValue({ + claude: { fiveHour: null, sevenDay: null, fetchedAt: null, status: 'unavailable', error: 'x' }, + codex: { fiveHour: null, sevenDay: null, fetchedAt: null, status: 'idle', error: null }, + }); + const host = await mount(); + expect(host.querySelector('.mobile-usage')).toBeNull(); +}); diff --git a/src/remote/UsageStrip.tsx b/src/remote/UsageStrip.tsx new file mode 100644 index 00000000..af82da04 --- /dev/null +++ b/src/remote/UsageStrip.tsx @@ -0,0 +1,92 @@ +import { For, Show, createEffect, createSignal, onCleanup, onMount, untrack } from 'solid-js'; +import { fetchUsage } from './api'; +import { status } from './ws'; +import { + USAGE_WARN_PERCENT, + formatReset, + hasUsageSnapshot, + remainingPercent, + usageVisible, +} from '../components/usage-format'; +import type { UsageProvider, UsageState, UsageWindow } from '../../electron/ipc/shared-types'; + +// The desktop polls the usage endpoints itself; this only re-reads its snapshot. +const POLL_INTERVAL_MS = 60_000; + +const PROVIDER_LABELS: Record = { claude: 'Claude', codex: 'Codex' }; + +function Meter(props: { label: string; window: UsageWindow }) { + const left = () => remainingPercent(props.window); + const reset = () => formatReset(props.window.resetsAt); + return ( +

= USAGE_WARN_PERCENT }}> + {props.label} + + + + {left()}% left + + {reset()} + +
+ ); +} + +/** + * The desktop status bar's agent-subscription meters (Claude Code, Codex). + * Hidden until the desktop has a snapshot, like the bar itself; tap to re-read. + */ +export function UsageStrip() { + const [usage, setUsage] = createSignal | null>(null); + const providers = () => { + const u = usage(); + return u + ? (Object.keys(PROVIDER_LABELS) as UsageProvider[]).filter((p) => u[p] && usageVisible(u[p])) + : []; + }; + const load = () => { + if (status() !== 'connected') return; + fetchUsage().then(setUsage, () => { + // Keep the last snapshot; the connection banner already reports outages. + }); + }; + + // Also reads on every (re)connect, which covers the first load. + createEffect(() => { + if (status() === 'connected') untrack(load); + }); + onMount(() => { + const timer = setInterval(load, POLL_INTERVAL_MS); + onCleanup(() => clearInterval(timer)); + }); + + return ( + 0}> + + + ); +} diff --git a/src/remote/api.ts b/src/remote/api.ts index 95a16cdc..1e026cf2 100644 --- a/src/remote/api.ts +++ b/src/remote/api.ts @@ -1,8 +1,9 @@ // REST helpers for the mobile SPA. Data flows over the WebSocket (see ws.ts); -// these cover the request/response actions: pairing, task creation, and -// reading/saving task notes. +// these cover the request/response actions: pairing, task creation, task +// notes, and the subscription usage meters. import { getToken, getPairedToken } from './auth'; +import type { UsageProvider, UsageState } from '../../electron/ipc/shared-types'; export class ApiError extends Error { status: number; @@ -60,6 +61,14 @@ export interface MobileProject { agentName?: string; } +export interface MobileAgent { + id: string; + name: string; + description?: string; + available?: boolean; + isDefault?: boolean; +} + /** List projects the New Task screen can target. Requires a paired token. */ export function fetchProjects(): Promise { const token = getPairedToken(); @@ -67,11 +76,19 @@ export function fetchProjects(): Promise { return request('/api/mobile/projects', { token }); } +/** List agents the New Task screen can target. Requires a paired token. */ +export function fetchAgents(): Promise { + const token = getPairedToken(); + if (!token) throw new ApiError('Not paired', 401); + return request('/api/mobile/agents', { token }); +} + /** Create a top-level task. Requires a paired token. Returns the new task id. */ export async function createTask(input: { projectId: string; name: string; prompt: string; + agentId?: string; }): Promise { const token = getPairedToken(); if (!token) throw new ApiError('Not paired', 401); @@ -192,6 +209,13 @@ export async function saveNotes(taskId: string, notes: string): Promise { }); } +/** The desktop's agent-subscription usage. Works with the base connection token. */ +export function fetchUsage(): Promise> { + const token = getToken(); + if (!token) throw new ApiError('Not connected', 401); + return request>('/api/mobile/usage', { token }); +} + /** Notification delivery is owned by this paired phone, independently of its live socket. */ export function fetchPushSettings(): Promise<{ publicKey: string; endpoint: string | null }> { const token = getPairedToken(); diff --git a/src/remote/mobile.css b/src/remote/mobile.css index 38703ef5..ee3abba0 100644 --- a/src/remote/mobile.css +++ b/src/remote/mobile.css @@ -275,6 +275,64 @@ select:focus-visible { textarea.mobile-input { resize: vertical; } +.mobile-usage { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid var(--mobile-border); + border-radius: 10px; + background: var(--mobile-panel); + color: var(--mobile-muted); + font: inherit; + font-size: 12px; + text-align: left; +} +.usage-provider { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 12px; +} +.usage-provider.stale { + opacity: 0.6; +} +.usage-name { + min-width: 52px; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.usage-meter { + display: inline-flex; + align-items: center; + gap: 6px; +} +.usage-bar { + width: 48px; + height: 5px; + overflow: hidden; + border: 1px solid var(--mobile-border); + border-radius: 3px; + background: var(--mobile-bg); +} +.usage-bar span { + display: block; + height: 100%; + background: var(--mobile-accent); +} +.usage-left { + color: #e0eaf3; + font-weight: 500; +} +.usage-meter.warn .usage-bar span { + background: #ffdd98; +} +.usage-meter.warn .usage-left { + color: #ffdd98; +} .mobile-fix-ci-prompt { font-family: ui-monospace, monospace; font-size: 13px; diff --git a/src/store/remoteTaskHandler.ts b/src/store/remoteTaskHandler.ts index 86c890eb..8c46d5dd 100644 --- a/src/store/remoteTaskHandler.ts +++ b/src/store/remoteTaskHandler.ts @@ -10,8 +10,9 @@ import { publishGitHubList } from './github-lists'; import { getTaskMindMap, openCanvasViewFromAgent, updateTaskMindMapFromAgent } from './canvas'; import { getEvidenceForAgent, submitEvidence } from './evidence'; import { getTaskReasoning, updateTaskReasoningFromAgent } from './reasoning'; +import { unwrap } from 'solid-js/store'; import { store } from './core'; -import { codeProjects, getProject, getProjectPath } from './projects'; +import { addProject, codeProjects, getProject, getProjectPath } from './projects'; import { closeTask, createTask, @@ -86,6 +87,16 @@ function reply(reqId: string, ok: boolean, data?: unknown, error?: string): void invoke(IPC.Remote_RendererReply, { reqId, ok, data, error }).catch(() => {}); } +interface AddProjectRequest extends RendererRequest { + name: string; + path: string; +} + +/** Register a folder the main process just created as a git project. */ +function handleAddProject(req: AddProjectRequest): void { + reply(req.reqId, true, { id: addProject(req.name, req.path, true), name: req.name }); +} + function handleGetProjects(req: RendererRequest): void { reply( req.reqId, @@ -93,6 +104,7 @@ function handleGetProjects(req: RendererRequest): void { codeProjects().map((p) => ({ id: p.id, name: p.name, + path: p.path, agentName: defaultAgent()?.name ?? '', })), ); @@ -342,7 +354,6 @@ async function handleGetMergeReadiness(req: GetTaskDiffRequest): Promise { if (task.gitIsolation !== 'worktree') { reply(req.reqId, true, { readiness: { - overall: 'blocked', checks: [ { label: 'Merge safety', @@ -490,6 +501,12 @@ export function startRemoteTaskHandlers(): () => void { if (data && typeof data === 'object') handleGetProjects(data as RendererRequest); }, ); + const offAddProject = window.electron.ipcRenderer.on( + IPC.Remote_AddProjectRequest, + (data: unknown) => { + if (data && typeof data === 'object') handleAddProject(data as AddProjectRequest); + }, + ); const offAgents = window.electron.ipcRenderer.on(IPC.Remote_GetAgentsRequest, (data: unknown) => { if (data && typeof data === 'object') void handleGetAgents(data as RendererRequest); }); @@ -505,6 +522,13 @@ export function startRemoteTaskHandlers(): () => void { if (data && typeof data === 'object') handleGetNotes(data as GetNotesRequest); }, ); + const offGetUsage = window.electron.ipcRenderer.on( + IPC.Remote_GetUsageRequest, + (data: unknown) => { + if (data && typeof data === 'object') + reply((data as RendererRequest).reqId, true, unwrap(store.usage)); + }, + ); const offSetNotes = window.electron.ipcRenderer.on( IPC.Remote_SetNotesRequest, (data: unknown) => { @@ -648,6 +672,7 @@ export function startRemoteTaskHandlers(): () => void { offPublishConfidence(); offPublishGitHubList(); offProjects(); + offAddProject(); offCreate(); offAgents(); offGetNotes(); @@ -658,5 +683,6 @@ export function startRemoteTaskHandlers(): () => void { offFixCiPrompt(); offSendFixCi(); offMerge(); + offGetUsage(); }; } diff --git a/src/store/types.ts b/src/store/types.ts index 5cafbb7d..ec275d48 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -14,12 +14,12 @@ import type { AgentTourPayload } from '../../electron/shared/agent-tour'; import type { CanvasTaskLink, CanvasTaskSource } from '../lib/canvas-task-links'; import type { AgentDef, - CreditUsage, UsageProvider, - UsageWindow, + UsageState, VerificationRun, WorktreeStatus, } from '../ipc/types'; +export type { UsageState } from '../ipc/types'; import type { ChatPermissionMode, ChatSession } from '../../electron/shared/agent-chat-types'; import type { AskCodeProvider } from '../../electron/shared/ask-code-models'; import type { TaskNameModelId } from '../../electron/shared/task-name-model'; @@ -559,17 +559,6 @@ export interface MCPStatus { mcpConfigPath: string | null; } -export interface UsageState { - fiveHour: UsageWindow | null; - sevenDay: UsageWindow | null; - creditUsage?: CreditUsage | null; - /** When the current windows were fetched; null until the first success. */ - fetchedAt: number | null; - /** `unavailable` means no subscription login — the bar hides. `error` keeps the last snapshot. */ - status: 'idle' | 'ok' | 'error' | 'unavailable'; - error: string | null; -} - // Panel cell IDs. Shell terminals use "shell:0", "shell:1", etc. // Shell toolbar buttons use "shell-toolbar:0", "shell-toolbar:1", etc. export type PanelId = string;