Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions PiMobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 18;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
Expand Down Expand Up @@ -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 = 18;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Config/Info.plist;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
Expand Down
1 change: 1 addition & 0 deletions PiMobile/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
Expand Down
9 changes: 8 additions & 1 deletion PiMobile/Views/ChatView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion PiMobile/Views/WorkspacesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
89 changes: 70 additions & 19 deletions server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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" });
Expand Down Expand Up @@ -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",
Expand All @@ -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() + "…";
};
Comment on lines +699 to +713

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep the complete workspace label within 50 characters.

For an overlong task, MAX limits the text before . The returned label can therefore contain 50 characters plus the ellipsis, for a total of 51 characters. Use a 49-character content budget when adding the suffix.

The PR objective requires a 50-character limit.

Proposed fix
   const MAX = 50;
+  const suffix = "…";
+  const contentMax = MAX - suffix.length;
-  if (cleaned.length <= MAX) return cleaned;
-  const cut = cleaned.slice(0, MAX + 1);
+  if (cleaned.length <= MAX) return cleaned;
+  const cut = cleaned.slice(0, contentMax + 1);
   const space = cut.lastIndexOf(" ");
-  return (space > MAX * 0.6 ? cut.slice(0, space) : cut.slice(0, MAX)).trimEnd() + "…";
+  return (space > contentMax * 0.6 ? cut.slice(0, space) : cut.slice(0, contentMax)).trimEnd() + suffix;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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() + "…";
};
const taskNameFrom = (text: string): string => {
// Drop fenced code blocks first, then take the first non-empty line.
const firstLine = text.replace(/
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/server.ts` around lines 699 - 713, The taskNameFrom truncation
currently allows 50 content characters plus the ellipsis; adjust its truncation
budget so the complete returned label, including the suffix, never exceeds 50
characters. Preserve the existing word-boundary behavior and unchanged output
for labels already within the limit.


function createWorkspace(repoId: string) {
const root = scanned().repoRoots.get(repoId);
if (!root || !existsSync(root)) return { error: "repo not found", status: 404 };
Expand All @@ -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) };
}

Expand Down Expand Up @@ -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);
}
Comment on lines +815 to +820

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Limit task-based renaming to phone-created workspaces.

This condition applies to every sessionless cwd. It includes the main checkout and folders registered through /projects. workspaceLabel states that those folders keep their folder names, but this block persists a task name for them after the first message.

Check the phone-worktree path, or use an explicit workspace origin, before calling setStoredName.

The PR objective scopes task-based names to phone-created workspaces.

Proposed fix
+  const isPhoneWorkspace = cwd.startsWith(`${homedir()}/pi-workspaces/`);
-  if (!found && (scanned().workspaces.get(encodeCwd(cwd))?.sessionCount ?? 0) === 0) {
+  if (
+    isPhoneWorkspace &&
+    !found &&
+    (scanned().workspaces.get(encodeCwd(cwd))?.sessionCount ?? 0) === 0
+  ) {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 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);
}
// First message in a brand-new workspace: swap the temporary "New Workspace"
// label for a short name derived from the task itself.
const isPhoneWorkspace = cwd.startsWith(`${homedir()}/pi-workspaces/`);
if (
isPhoneWorkspace &&
!found &&
(scanned().workspaces.get(encodeCwd(cwd))?.sessionCount ?? 0) === 0
) {
const name = taskNameFrom(text);
if (name) setStoredName(cwd, name);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/server.ts` around lines 769 - 774, Restrict the task-based renaming
block around taskNameFrom and setStoredName to phone-created workspaces only.
Before persisting the derived name, validate the workspace’s phone-worktree path
or explicit workspace origin, while preserving the existing first-message and
sessionless checks for eligible workspaces; do not rename the main checkout or
/projects folders.


const approvalMode = (opts.approvalMode ?? "auto").toLowerCase() === "ask" ? "ask" : "auto";
const args = [PI, "--mode", "rpc"];
if (found) args.push("--session", found.file);
Expand Down Expand Up @@ -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));
};

Expand Down Expand Up @@ -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 {}
Expand Down