From 73c84b4a416ffd99e5da5adf07aa8e26549f9038 Mon Sep 17 00:00:00 2001 From: Matt List Date: Fri, 24 Jul 2026 11:38:23 +1200 Subject: [PATCH 1/3] chore: bump build to 13 Co-Authored-By: Claude Fable 5 --- PiMobile.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PiMobile.xcodeproj/project.pbxproj b/PiMobile.xcodeproj/project.pbxproj index 2714e5b..060b6e1 100644 --- a/PiMobile.xcodeproj/project.pbxproj +++ b/PiMobile.xcodeproj/project.pbxproj @@ -159,7 +159,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = U5LM2CZXRN; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -188,7 +188,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = U5LM2CZXRN; - CURRENT_PROJECT_VERSION = 12; + CURRENT_PROJECT_VERSION = 13; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; From 5fa12f00a768ca006961403f39250e8d953036d0 Mon Sep 17 00:00:00 2001 From: Matt List Date: Sat, 1 Aug 2026 22:06:02 +1200 Subject: [PATCH 2/3] feat: task-based workspace names New workspaces show a temporary "New Workspace" label until the first user message, which the companion server turns into a short task-derived name (markdown stripped, first line, capped at 50 chars). The chat header and workspace list pick up the rename live via the new GET /workspaces/:id endpoint; git worktree folders/branches keep their city id. --- PiMobile/APIClient.swift | 1 + PiMobile/Views/ChatView.swift | 9 ++- PiMobile/Views/WorkspacesView.swift | 3 +- server/server.ts | 89 +++++++++++++++++++++++------ 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/PiMobile/APIClient.swift b/PiMobile/APIClient.swift index d8e9f78..eca7c5e 100644 --- a/PiMobile/APIClient.swift +++ b/PiMobile/APIClient.swift @@ -230,6 +230,7 @@ final class APIClient { } func workspaces(repoId: String) async throws -> [Workspace] { try await get("/repos/\(repoId)/workspaces") } + func workspace(_ id: String) async throws -> Workspace { try await get("/workspaces/\(id)") } func sessions(workspaceId: String) async throws -> [ChatSession] { try await get("/workspaces/\(workspaceId)/sessions") } func messages(sessionId: String) async throws -> [ChatMessage] { try await get("/sessions/\(sessionId)/messages") } func status(sessionId: String) async throws -> AgentStatus { try await get("/sessions/\(sessionId)/status") } diff --git a/PiMobile/Views/ChatView.swift b/PiMobile/Views/ChatView.swift index 7ca6edb..de225ea 100644 --- a/PiMobile/Views/ChatView.swift +++ b/PiMobile/Views/ChatView.swift @@ -13,6 +13,7 @@ struct ChatView: View { @Environment(APIClient.self) private var api @Environment(\.accessibilityReduceMotion) private var reduceMotion let workspace: Workspace + @State private var workspaceName: String // tracks server-side renames (task-derived) @State private var liveStatus: String @State private var messages: [ChatMessage] = [] @State private var draft = "" @@ -41,6 +42,7 @@ struct ChatView: View { init(workspace: Workspace) { self.workspace = workspace + _workspaceName = State(initialValue: workspace.name) _liveStatus = State(initialValue: workspace.status) } /// Active turn starts at this user message; a stable "active-turn" frame @@ -271,7 +273,7 @@ struct ChatView: View { private var chatToolbar: some ToolbarContent { ToolbarItem(placement: .principal) { VStack(alignment: .leading, spacing: 2) { - Text(workspace.name) + Text(workspaceName) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.text) .lineLimit(1) @@ -754,6 +756,11 @@ struct ChatView: View { pinnedMessageId = match.id } } + // A fresh workspace is renamed server-side from its first message — + // pick that up so the chat header shows the task-derived name. + if let ws = try? await api.workspace(workspace.id) { + workspaceName = ws.name + } } } diff --git a/PiMobile/Views/WorkspacesView.swift b/PiMobile/Views/WorkspacesView.swift index a7baa01..b32216e 100644 --- a/PiMobile/Views/WorkspacesView.swift +++ b/PiMobile/Views/WorkspacesView.swift @@ -108,7 +108,8 @@ struct WorkspacesView: View { } // Creates a fresh worktree + session on the Mac and jumps straight into the chat. - // The companion server picks a city/town name (Conductor-style) for the workspace. + // The workspace shows a temporary "New Workspace" label until the first + // message, when the companion server renames it from the task. private func createWorkspace() async { creating = true defer { creating = false } diff --git a/server/server.ts b/server/server.ts index ece0010..ccdf685 100644 --- a/server/server.ts +++ b/server/server.ts @@ -30,7 +30,9 @@ const TOKEN = readFileSync(tokenPath, "utf8").trim(); // file covers the ones the phone created before their first session. const projectsPath = `${tokenDir}/projects.json`; // Entries are {path, added, name?} (older versions stored bare path strings). -// `name` is the phone-facing workspace label (e.g. city) when we create a worktree. +// `name` is the phone-facing workspace label: "New Workspace" when a phone +// worktree is first created, replaced by a short task-derived name on the +// first user message. type ProjectEntry = { path: string; added: number; name?: string }; const projectEntries = (): ProjectEntry[] => { try { @@ -55,6 +57,14 @@ const rememberCwd = (cwd: string, name?: string) => { const forgetCwd = (cwd: string) => { writeFileSync(projectsPath, JSON.stringify(projectEntries().filter((e) => e.path !== cwd), null, 2)); }; +// Force-overwrite a workspace's phone-facing label (temporary name → task name). +const setStoredName = (cwd: string, name: string) => { + const list = projectEntries(); + const i = list.findIndex((e) => e.path === cwd); + if (i >= 0) list[i] = { ...list[i], name }; + else list.push({ path: cwd, added: Date.now(), name }); + writeFileSync(projectsPath, JSON.stringify(list, null, 2)); +}; const git = (cwd: string, ...a: string[]) => { const p = Bun.spawnSync(["git", ...a], { cwd, stdout: "pipe", stderr: "pipe" }); @@ -661,8 +671,10 @@ function createSession(workspaceId: string) { updated_at: new Date().toISOString() }; } -// City/town labels — same idea as Conductor desktop workspaces. Folder + branch -// stay lowercase; the phone-facing `name` is title-cased. +// Phone-created worktrees get a random city folder + branch (Conductor-style — +// keeps worktree/branch names unique and stable), but the phone-facing `name` +// is a temporary "New Workspace" label, replaced by a task-derived name on the +// first user message. const CITIES = [ "lisbon","porto","quito","nairobi","hanoi","tbilisi","perth","leipzig","malmo","bergen", "cusco","davao","hobart","tampere","galway","split","ankara","doha","manila","seville", @@ -678,6 +690,28 @@ const workspaceLabel = (cwd: string) => { return base; // main checkout / user-added folders keep their folder name }; +// Temporary label shown until the user's first message gives us a real task. +const NEW_WORKSPACE_LABEL = "New Workspace"; + +// Short task-based label from the first user message: first line, markdown +// stripped, capped on a word boundary. Empty → keep the temporary label +// (e.g. an image-only first message). +const taskNameFrom = (text: string): string => { + // Drop fenced code blocks first, then take the first non-empty line. + const firstLine = text.replace(/```[\s\S]*?```/g, " ").split("\n").find((l) => l.trim()) ?? ""; + const cleaned = firstLine + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // links keep their visible text + .replace(/[#>*_`~|]/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!cleaned) return ""; + const MAX = 50; + if (cleaned.length <= MAX) return cleaned; + const cut = cleaned.slice(0, MAX + 1); + const space = cut.lastIndexOf(" "); + return (space > MAX * 0.6 ? cut.slice(0, space) : cut.slice(0, MAX)).trimEnd() + "…"; +}; + function createWorkspace(repoId: string) { const root = scanned().repoRoots.get(repoId); if (!root || !existsSync(root)) return { error: "repo not found", status: 404 }; @@ -686,17 +720,18 @@ function createWorkspace(repoId: string) { const base = `${homedir()}/pi-workspaces/${repoName}`; const unused = CITIES.filter((c) => !existsSync(`${base}/${c}`)); const city = unused.length ? unused[Math.floor(Math.random() * unused.length)] : `mobile-${Date.now()}`; - const label = titleCase(city); const path = `${base}/${city}`; const branch = `mobile/${city}`; mkdirSync(base, { recursive: true }); const wt = Bun.spawnSync(["git", "worktree", "add", "-b", branch, path], { cwd: root, stdout: "pipe", stderr: "pipe" }); if (wt.exitCode !== 0) return { error: `git worktree failed: ${wt.stderr.toString().trim()}`, status: 500 }; - rememberCwd(path, label); + // Temporary phone-facing label; the first user message replaces it with a + // short task-derived name (folder + branch keep the city id). + rememberCwd(path, NEW_WORKSPACE_LABEL); scanCache = null; const id = encodeCwd(path); - return { id, repository_id: repoId, name: label, branch, status: "not-started", unread: false, + return { id, repository_id: repoId, name: NEW_WORKSPACE_LABEL, branch, status: "not-started", unread: false, updated_at: new Date().toISOString(), last_message_snippet: null, session: createSession(id) }; } @@ -731,6 +766,13 @@ function sendMessage( if (!cwd) return { error: "session not found", status: 404 }; if (!existsSync(cwd)) return { error: "workspace directory not found on this Mac", status: 400 }; + // First message in a brand-new workspace: swap the temporary "New Workspace" + // label for a short name derived from the task itself. + if (!found && (scanned().workspaces.get(encodeCwd(cwd))?.sessionCount ?? 0) === 0) { + const name = taskNameFrom(text); + if (name) setStoredName(cwd, name); + } + const approvalMode = (opts.approvalMode ?? "auto").toLowerCase() === "ask" ? "ask" : "auto"; const args = [PI, "--mode", "rpc"]; if (found) args.push("--session", found.file); @@ -882,23 +924,25 @@ function workspaceStatus(ws: Ws): string { return "done"; } +const workspaceJSON = (id: string, ws: Ws, repoId: string) => { + const newest = ws.dir ? sessionFiles(ws.dir)[0] : null; + return { + id, + repository_id: repoId, + name: workspaceLabel(ws.cwd), + branch: git(ws.cwd, "branch", "--show-current"), + status: workspaceStatus(ws), + unread: false, + updated_at: new Date(ws.mtime).toISOString(), + last_message_snippet: newest ? sessionSummary(`${ws.dir}/${newest}`, id).title : null, + }; +}; + const workspacesOf = (repoId: string) => { const { workspaces, repoOf } = scanned(); return [...workspaces] .filter(([id]) => repoOf.get(id) === repoId) - .map(([id, ws]) => { - const newest = ws.dir ? sessionFiles(ws.dir)[0] : null; - return { - id, - repository_id: repoId, - name: workspaceLabel(ws.cwd), - branch: git(ws.cwd, "branch", "--show-current"), - status: workspaceStatus(ws), - unread: false, - updated_at: new Date(ws.mtime).toISOString(), - last_message_snippet: newest ? sessionSummary(`${ws.dir}/${newest}`, id).title : null, - }; - }) + .map(([id, ws]) => workspaceJSON(id, ws, repoId)) .sort((a, b) => (a.updated_at < b.updated_at ? 1 : -1)); }; @@ -973,6 +1017,13 @@ Bun.serve({ scanCache = null; return Response.json({ ok: true }); } + // Single workspace — the chat header re-reads its name after a rename. + if (req.method === "GET" && (m = path.match(/^\/workspaces\/([^/]+)$/))) { + const { workspaces, repoOf } = scanned(); + const ws = workspaces.get(m[1]); + if (!ws) return Response.json({ error: "workspace not found" }, { status: 404 }); + return Response.json(workspaceJSON(m[1], ws, repoOf.get(m[1]) ?? "")); + } if (req.method === "POST" && (m = path.match(/^\/sessions\/([^/]+)\/stop$/))) { const t = turns.get(m[1]); try { t?.proc?.stdin?.write(JSON.stringify({ id: "stop", type: "abort" }) + "\n"); } catch {} From 433b80009d7c799bb0c6bfbc5bbbbd7e293cd24c Mon Sep 17 00:00:00 2001 From: Matt List Date: Sat, 1 Aug 2026 22:06:02 +1200 Subject: [PATCH 3/3] chore: bump build to 18 --- PiMobile.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/PiMobile.xcodeproj/project.pbxproj b/PiMobile.xcodeproj/project.pbxproj index 060b6e1..6381d7b 100644 --- a/PiMobile.xcodeproj/project.pbxproj +++ b/PiMobile.xcodeproj/project.pbxproj @@ -159,7 +159,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = U5LM2CZXRN; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 18; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; @@ -188,7 +188,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = U5LM2CZXRN; - CURRENT_PROJECT_VERSION = 13; + CURRENT_PROJECT_VERSION = 18; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Config/Info.plist; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;