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