Inital commit

This commit is contained in:
2026-07-09 12:07:57 -05:00
commit 6fa32c1020
39 changed files with 17590 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
dist/
.expo/
.expo-shared/
*.log
.DS_Store
.env
.env.local
+20
View File
@@ -0,0 +1,20 @@
I want to build a mobile app called "Pi mobile" that will allow you to easily remote control a Pi agent via a mobile app.
**How it will work?**
It will allow you to easily create/control Pi sessions by allowing the user to type in their computers IP address and port. Then it will connect to the Pi mobile server hosted on the computer.
The server will be Socket. It will allow you to send commands to Pi agents like new messages, executing commands, etc. The server will act as a hub for a bunch of Pi agents to be managed cleanly.
The goal is to build a seemless way of controling your Pi agents from your phone.
Out of scope:
- Security/Privacy
We are just trying to build a working prototype.
**Languages & Frameworks**
I want to use **TypeScript+Express.Js** for the backend API and **Expo+React Native** for the mobile app.
**File Structure**
apps/ -> api/ & mobile/
+107
View File
@@ -0,0 +1,107 @@
# Pi Mobile
Prototype mobile remote control for [Pi](https://pi.dev/docs/latest) coding agents.
Phone connects to a hub running on your computer over the local network. The hub embeds the Pi SDK in-process and exposes session management plus streaming chat over Socket.IO.
## Prerequisites
- Node.js 20+
- Pi installed and authenticated on the host machine (`pi` CLI with `/login` or provider API keys)
- Expo Go on your phone (or iOS Simulator / Android emulator)
- Phone and computer on the same LAN
## Project structure
```
apps/api/ Express + Socket.IO hub with Pi SDK
apps/mobile/ Expo Router mobile app
```
## Setup
```bash
npm install
```
## Run the API hub
From the repo root:
```bash
npm run dev:api
```
Or directly:
```bash
cd apps/api
npm run dev
```
The server listens on `0.0.0.0:8787` by default and prints LAN URLs on startup.
Custom port:
```bash
npm run start -w @pi-mobile/api -- --port 9000
```
## Run the mobile app
In a second terminal:
```bash
npm run dev:mobile
```
Scan the QR code with Expo Go, then:
1. Enter your computer's LAN IP and port (`8787`)
2. Tap **Connect**
3. Create a session by choosing a folder under `~/`
4. Send prompts and watch streamed assistant text + tool activity
5. Resume persisted Pi sessions from the list
## API surface
### HTTP
- `GET /health` — liveness check
- `GET /fs/list?path=~` — folder-only explorer rooted at home
### Socket.IO (client → server)
| Event | Payload |
|-------|---------|
| `sessions:list` | — |
| `runtime:create` | `{ cwd, name? }` |
| `runtime:open` | `{ sessionFile }` |
| `runtime:attach` | `{ runtimeId }` |
| `chat:prompt` | `{ runtimeId, message }` |
| `chat:abort` | `{ runtimeId }` |
### Socket.IO (server → client)
| Event | Payload |
|-------|---------|
| `sessions:list:result` | `{ persisted, live }` |
| `runtime:created` / `runtime:opened` | `{ runtimeId, sessionId, cwd, sessionName?, sessionFile? }` |
| `chat:event` | `{ runtimeId, event }` |
| `error` | `{ message }` |
## Notes
- Security/auth are intentionally out of scope for this prototype.
- The folder browser only exposes directories under your home folder.
- Pi credentials are read from the host environment / Pi auth storage — the mobile app does not handle login.
- Multiple live runtimes can run concurrently; switch between them from the Sessions screen.
## Success checklist
- [ ] Connect from phone via LAN IP
- [ ] Browse folders starting at `~/`
- [ ] Create a new Pi session
- [ ] Send a prompt and see streamed text + tool rows
- [ ] Resume a persisted session
- [ ] Open a second live runtime without stopping the first
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@pi-mobile/api",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "latest",
"cors": "^2.8.5",
"express": "^4.21.2",
"socket.io": "^4.8.1"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
+106
View File
@@ -0,0 +1,106 @@
import { mkdir, readdir, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
const HOME = homedir();
export function resolveBrowsePath(input?: string): string {
if (!input || input === "~") {
return HOME;
}
if (input.startsWith("~/")) {
return resolve(HOME, input.slice(2));
}
return resolve(input);
}
export function isPathAllowed(targetPath: string): boolean {
const resolved = resolve(targetPath);
const home = resolve(HOME);
return resolved === home || resolved.startsWith(`${home}/`);
}
export async function listFolders(inputPath?: string) {
const path = resolveBrowsePath(inputPath);
if (!isPathAllowed(path)) {
throw new Error("Path is outside the home directory");
}
const entries = await readdir(path, { withFileTypes: true });
const dirEntries = entries.filter(
(entry) => entry.isDirectory() && !entry.name.startsWith("."),
);
const folders = await Promise.all(
dirEntries.map(async (entry) => {
const entryPath = join(path, entry.name);
let mtimeMs = 0;
try {
const info = await stat(entryPath);
mtimeMs = info.mtimeMs;
} catch {
mtimeMs = 0;
}
return { name: entry.name, path: entryPath, mtimeMs };
}),
);
folders.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
const parent = dirname(path);
const canGoUp = isPathAllowed(parent) && parent !== path;
return {
path,
parent: canGoUp ? parent : null,
entries: folders.map(({ name, path: folderPath }) => ({ name, path: folderPath })),
};
}
const INVALID_NAME_CHARS = /[/\\:]/;
export async function createFolder(inputPath: string | undefined, name: string) {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error("Folder name is required");
}
if (
trimmedName === "." ||
trimmedName === ".." ||
INVALID_NAME_CHARS.test(trimmedName)
) {
throw new Error("Invalid folder name");
}
const parentPath = resolveBrowsePath(inputPath);
if (!isPathAllowed(parentPath)) {
throw new Error("Path is outside the home directory");
}
const newPath = join(parentPath, trimmedName);
if (!isPathAllowed(newPath)) {
throw new Error("Path is outside the home directory");
}
let alreadyExists = false;
try {
await stat(newPath);
alreadyExists = true;
} catch (error) {
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
throw error;
}
}
if (alreadyExists) {
throw new Error("A file or folder with that name already exists");
}
await mkdir(newPath);
return { name: trimmedName, path: newPath };
}
+106
View File
@@ -0,0 +1,106 @@
import cors from "cors";
import express from "express";
import { createServer } from "node:http";
import { networkInterfaces } from "node:os";
import { Server } from "socket.io";
import { createFolder, listFolders } from "./fs.js";
import { RuntimeRegistry } from "./runtime-registry.js";
import { registerSocketHandlers } from "./socket-handlers.js";
const DEFAULT_PORT = 8787;
function getPort(): number {
const argIndex = process.argv.indexOf("--port");
if (argIndex !== -1) {
const value = Number(process.argv[argIndex + 1]);
if (!Number.isNaN(value)) {
return value;
}
}
const envPort = Number(process.env.PORT);
return Number.isNaN(envPort) ? DEFAULT_PORT : envPort;
}
function getLanAddresses(): string[] {
const addresses: string[] = [];
const interfaces = networkInterfaces();
for (const entries of Object.values(interfaces)) {
if (!entries) {
continue;
}
for (const entry of entries) {
if (entry.family === "IPv4" && !entry.internal) {
addresses.push(entry.address);
}
}
}
return addresses;
}
const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
cors: {
origin: "*",
methods: ["GET", "POST"],
},
});
const registry = new RuntimeRegistry();
app.use(cors());
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ ok: true, service: "pi-mobile-api" });
});
app.get("/fs/list", async (req, res) => {
try {
const path = typeof req.query.path === "string" ? req.query.path : "~";
const result = await listFolders(path);
res.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to list folders";
res.status(400).json({ error: message });
}
});
app.post("/fs/mkdir", async (req, res) => {
try {
const path = typeof req.body?.path === "string" ? req.body.path : "~";
const name = typeof req.body?.name === "string" ? req.body.name : "";
const result = await createFolder(path, name);
res.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to create folder";
res.status(400).json({ error: message });
}
});
registerSocketHandlers(io, registry);
const port = getPort();
httpServer.listen(port, "0.0.0.0", () => {
const lan = getLanAddresses();
console.log(`Pi Mobile API listening on 0.0.0.0:${port}`);
if (lan.length > 0) {
console.log("Connect from your phone using:");
for (const address of lan) {
console.log(` http://${address}:${port}`);
}
} else {
console.log(`Connect from your phone using your computer's LAN IP on port ${port}`);
}
});
process.on("SIGINT", () => {
for (const runtimeId of [...registry.listLive().map((item) => item.runtimeId)]) {
registry.dispose(runtimeId);
}
process.exit(0);
});
+141
View File
@@ -0,0 +1,141 @@
import type { ChatHistoryMessage } from "./types.js";
type HistorySourceMessage = {
role: string;
content?: unknown;
toolCallId?: string;
toolName?: string;
isError?: boolean;
};
export function extractText(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return content
.map((block) => {
if (typeof block === "object" && block && "type" in block && block.type === "text") {
return String((block as { text?: string }).text ?? "");
}
return "";
})
.join("");
}
/** Last user/assistant text preview for chat list titles. */
export function lastMessagePreview(messages: HistorySourceMessage[]): string | undefined {
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
if (message.role !== "user" && message.role !== "assistant") {
continue;
}
const text = extractText(message.content).replace(/\s+/g, " ").trim();
if (text) {
return text;
}
}
return undefined;
}
function findPendingToolIndex(history: ChatHistoryMessage[], toolCallId: string): number {
for (let index = history.length - 1; index >= 0; index -= 1) {
const entry = history[index];
if (entry.role === "tool" && entry.toolCallId === toolCallId && entry.pending) {
return index;
}
}
return -1;
}
export function serializeMessages(messages: HistorySourceMessage[]): ChatHistoryMessage[] {
const history: ChatHistoryMessage[] = [];
for (const message of messages) {
if (message.role === "user") {
const content = extractText(message.content);
if (content) {
history.push({ role: "user", content });
}
continue;
}
if (message.role === "assistant") {
const content = extractText(message.content);
if (content) {
history.push({ role: "assistant", content });
}
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (
typeof block === "object" &&
block &&
"type" in block &&
block.type === "toolCall" &&
"id" in block &&
"name" in block
) {
const args = (block as { arguments?: Record<string, unknown> }).arguments;
history.push({
role: "tool",
toolCallId: String(block.id),
toolName: String(block.name),
args: args && typeof args === "object" ? args : undefined,
content: "",
isError: false,
pending: true,
});
}
}
}
continue;
}
if (message.role === "toolResult") {
const toolCallId = String(message.toolCallId ?? "");
const content = extractText(message.content);
const pendingIndex = findPendingToolIndex(history, toolCallId);
if (pendingIndex >= 0) {
const pending = history[pendingIndex];
if (pending.role === "tool") {
history[pendingIndex] = {
role: "tool",
toolCallId: pending.toolCallId,
toolName: pending.toolName,
args: pending.args,
content,
isError: Boolean(message.isError),
};
}
} else {
history.push({
role: "tool",
toolCallId,
toolName: String(message.toolName ?? "tool"),
content,
isError: Boolean(message.isError),
});
}
}
}
return history.map((entry) => {
if (entry.role === "tool" && entry.pending) {
return {
role: "tool" as const,
toolCallId: entry.toolCallId,
toolName: entry.toolName,
args: entry.args,
content: entry.content,
isError: false,
};
}
return entry;
});
}
+22
View File
@@ -0,0 +1,22 @@
import {
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionServices,
} from "@earendil-works/pi-coding-agent";
export const createRuntimeFactory: CreateAgentSessionRuntimeFactory = async ({
cwd,
sessionManager,
sessionStartEvent,
}) => {
const services = await createAgentSessionServices({ cwd });
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
})),
services,
diagnostics: services.diagnostics,
};
};
+184
View File
@@ -0,0 +1,184 @@
import { randomUUID } from "node:crypto";
import {
type AgentSessionEvent,
type AgentSessionRuntime,
createAgentSessionRuntime,
getAgentDir,
initTheme,
SessionManager,
} from "@earendil-works/pi-coding-agent";
import { lastMessagePreview } from "./message-history.js";
import { createRuntimeFactory } from "./pi-factory.js";
import { lastMessageFromSessionFile } from "./session-preview.js";
import type { LiveRuntimeInfo, PersistedSessionInfo } from "./types.js";
let themeReady = false;
function ensureThemeInitialized(): void {
if (themeReady) {
return;
}
// Headless API still loads interactive extensions (e.g. pi-codex-status)
// that touch the global theme proxy on session_start.
initTheme("dark");
themeReady = true;
}
type RuntimeRecord = {
runtime: AgentSessionRuntime;
cwd: string;
unsubscribe?: () => void;
listeners: Set<(event: AgentSessionEvent) => void>;
};
export class RuntimeRegistry {
private runtimes = new Map<string, RuntimeRecord>();
listLive(): LiveRuntimeInfo[] {
return [...this.runtimes.entries()].map(([runtimeId, record]) => {
const session = record.runtime.session;
return {
runtimeId,
sessionId: session.sessionId,
cwd: record.cwd,
sessionName: session.sessionManager.getSessionName(),
sessionFile: session.sessionFile,
lastMessage: lastMessagePreview(session.messages),
isStreaming: session.isStreaming,
};
});
}
async listPersisted(): Promise<PersistedSessionInfo[]> {
const sessions = await SessionManager.listAll();
return Promise.all(
sessions.map(async (session) => ({
id: session.id,
path: session.path,
cwd: session.cwd,
name: session.name,
lastMessage: await lastMessageFromSessionFile(session.path),
modified: session.modified.toISOString(),
messageCount: session.messageCount,
})),
);
}
get(runtimeId: string): RuntimeRecord | undefined {
return this.runtimes.get(runtimeId);
}
async createRuntime(cwd: string, name?: string): Promise<LiveRuntimeInfo> {
const sessionManager = SessionManager.create(cwd);
const runtime = await createAgentSessionRuntime(createRuntimeFactory, {
cwd,
agentDir: getAgentDir(),
sessionManager,
});
if (name) {
runtime.session.sessionManager.appendSessionInfo(name);
}
return this.registerRuntime(runtime, cwd);
}
async openRuntime(sessionFile: string): Promise<LiveRuntimeInfo> {
const sessionManager = SessionManager.open(sessionFile);
const cwd = sessionManager.getCwd();
const runtime = await createAgentSessionRuntime(createRuntimeFactory, {
cwd,
agentDir: getAgentDir(),
sessionManager,
});
return this.registerRuntime(runtime, cwd);
}
subscribe(runtimeId: string, listener: (event: AgentSessionEvent) => void): () => void {
const record = this.runtimes.get(runtimeId);
if (!record) {
throw new Error(`Runtime not found: ${runtimeId}`);
}
record.listeners.add(listener);
return () => {
record.listeners.delete(listener);
};
}
getMessages(runtimeId: string) {
return this.requireRuntime(runtimeId).runtime.session.messages;
}
async prompt(runtimeId: string, message: string): Promise<void> {
const record = this.requireRuntime(runtimeId);
await record.runtime.session.prompt(message);
}
async abort(runtimeId: string): Promise<void> {
const record = this.requireRuntime(runtimeId);
await record.runtime.session.abort();
}
dispose(runtimeId: string): void {
const record = this.runtimes.get(runtimeId);
if (!record) {
return;
}
record.unsubscribe?.();
record.runtime.session.dispose();
this.runtimes.delete(runtimeId);
}
private registerRuntime(runtime: AgentSessionRuntime, cwd: string): LiveRuntimeInfo {
const runtimeId = randomUUID();
const record: RuntimeRecord = {
runtime,
cwd,
listeners: new Set(),
};
record.unsubscribe = this.bindSession(record);
this.runtimes.set(runtimeId, record);
const session = runtime.session;
return {
runtimeId,
sessionId: session.sessionId,
cwd,
sessionName: session.sessionManager.getSessionName(),
sessionFile: session.sessionFile,
lastMessage: lastMessagePreview(session.messages),
isStreaming: session.isStreaming,
};
}
private bindSession(record: RuntimeRecord): () => void {
record.unsubscribe?.();
const session = record.runtime.session;
ensureThemeInitialized();
void session.bindExtensions({}).catch((error) => {
console.error(
"[runtime] bindExtensions failed:",
error instanceof Error ? error.message : error,
);
});
return session.subscribe((event) => {
for (const listener of record.listeners) {
listener(event);
}
});
}
private requireRuntime(runtimeId: string): RuntimeRecord {
const record = this.runtimes.get(runtimeId);
if (!record) {
throw new Error(`Runtime not found: ${runtimeId}`);
}
return record;
}
}
+55
View File
@@ -0,0 +1,55 @@
import { createReadStream } from "node:fs";
import { createInterface } from "node:readline";
import { extractText } from "./message-history.js";
/**
* Scan a session JSONL file for the last user/assistant text message.
* Mirrors SessionManager.listAll's message filtering without loading the full session.
*/
export async function lastMessageFromSessionFile(filePath: string): Promise<string | undefined> {
let lastMessage: string | undefined;
try {
const rl = createInterface({
input: createReadStream(filePath, { encoding: "utf8" }),
crlfDelay: Infinity,
});
for await (const line of rl) {
if (!line.trim()) {
continue;
}
let entry: unknown;
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (
typeof entry !== "object" ||
!entry ||
!("type" in entry) ||
(entry as { type?: string }).type !== "message" ||
!("message" in entry)
) {
continue;
}
const message = (entry as { message?: { role?: string; content?: unknown } }).message;
if (!message || (message.role !== "user" && message.role !== "assistant")) {
continue;
}
const text = extractText(message.content).replace(/\s+/g, " ").trim();
if (text) {
lastMessage = text;
}
}
} catch {
return undefined;
}
return lastMessage;
}
+102
View File
@@ -0,0 +1,102 @@
import type { Server, Socket } from "socket.io";
import { serializeMessages } from "./message-history.js";
import type { RuntimeRegistry } from "./runtime-registry.js";
import type { ChatEvent, ChatHistoryPayload, RuntimeCreatedPayload } from "./types.js";
type AttachState = {
runtimeId?: string;
unsubscribe?: () => void;
};
export function registerSocketHandlers(io: Server, registry: RuntimeRegistry) {
io.on("connection", (socket: Socket) => {
const state: AttachState = {};
socket.on("sessions:list", async () => {
try {
const [persisted, live] = await Promise.all([
registry.listPersisted(),
Promise.resolve(registry.listLive()),
]);
socket.emit("sessions:list:result", { persisted, live });
} catch (error) {
emitError(socket, error);
}
});
socket.on("runtime:create", async (payload: { cwd: string; name?: string }) => {
try {
const info = await registry.createRuntime(payload.cwd, payload.name);
const created: RuntimeCreatedPayload = info;
socket.emit("runtime:created", created);
io.emit("sessions:list:result", {
persisted: await registry.listPersisted(),
live: registry.listLive(),
});
} catch (error) {
emitError(socket, error);
}
});
socket.on("runtime:open", async (payload: { sessionFile: string }) => {
try {
const info = await registry.openRuntime(payload.sessionFile);
const opened: RuntimeCreatedPayload = info;
socket.emit("runtime:opened", opened);
io.emit("sessions:list:result", {
persisted: await registry.listPersisted(),
live: registry.listLive(),
});
} catch (error) {
emitError(socket, error);
}
});
socket.on("runtime:attach", (payload: { runtimeId: string }) => {
try {
state.unsubscribe?.();
state.runtimeId = payload.runtimeId;
state.unsubscribe = registry.subscribe(payload.runtimeId, (event) => {
const chatEvent: ChatEvent = {
runtimeId: payload.runtimeId,
event,
};
socket.emit("chat:event", chatEvent);
});
const history: ChatHistoryPayload = {
runtimeId: payload.runtimeId,
messages: serializeMessages(registry.getMessages(payload.runtimeId)),
};
socket.emit("chat:history", history);
} catch (error) {
emitError(socket, error);
}
});
socket.on("chat:prompt", async (payload: { runtimeId: string; message: string }) => {
try {
await registry.prompt(payload.runtimeId, payload.message);
} catch (error) {
emitError(socket, error);
}
});
socket.on("chat:abort", async (payload: { runtimeId: string }) => {
try {
await registry.abort(payload.runtimeId);
} catch (error) {
emitError(socket, error);
}
});
socket.on("disconnect", () => {
state.unsubscribe?.();
});
});
}
function emitError(socket: Socket, error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
socket.emit("error", { message });
}
+53
View File
@@ -0,0 +1,53 @@
import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
export type LiveRuntimeInfo = {
runtimeId: string;
sessionId: string;
cwd: string;
sessionName?: string;
sessionFile?: string;
lastMessage?: string;
isStreaming: boolean;
};
export type PersistedSessionInfo = {
id: string;
path: string;
cwd: string;
name?: string;
lastMessage?: string;
modified: string;
messageCount: number;
};
export type ChatEvent = {
runtimeId: string;
event: AgentSessionEvent;
};
export type ChatHistoryMessage =
| { role: "user"; content: string }
| { role: "assistant"; content: string }
| {
role: "tool";
toolCallId: string;
toolName: string;
args?: Record<string, unknown>;
content: string;
isError: boolean;
pending?: boolean;
};
export type ChatHistoryPayload = {
runtimeId: string;
messages: ChatHistoryMessage[];
};
export type RuntimeCreatedPayload = {
runtimeId: string;
sessionId: string;
cwd: string;
sessionName?: string;
sessionFile?: string;
lastMessage?: string;
};
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
+6
View File
@@ -0,0 +1,6 @@
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
+25
View File
@@ -0,0 +1,25 @@
{
"expo": {
"name": "Pi Mobile",
"slug": "pi-mobile",
"version": "1.0.0",
"orientation": "portrait",
"scheme": "pimobile",
"userInterfaceStyle": "dark",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#0a0a0a"
}
},
"plugins": [
"expo-router"
],
"experiments": {
"typedRoutes": true
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { SocketProvider } from "@/contexts/SocketContext";
export default function RootLayout() {
return (
<SocketProvider>
<StatusBar style="light" />
<Stack
screenOptions={{
headerStyle: { backgroundColor: "#0a0a0a" },
headerTintColor: "#f5f5f5",
contentStyle: { backgroundColor: "#0a0a0a" },
}}
>
<Stack.Screen name="index" options={{ title: "Pi Mobile" }} />
<Stack.Screen name="sessions" options={{ title: "Projects" }} />
<Stack.Screen name="project" options={{ title: "Project" }} />
<Stack.Screen name="picker" options={{ title: "Choose Folder" }} />
<Stack.Screen name="chat/[runtimeId]" options={{ title: "Chat" }} />
</Stack>
</SocketProvider>
);
}
+503
View File
@@ -0,0 +1,503 @@
import { router, useLocalSearchParams } from "expo-router";
import { useEffect, useRef, useState } from "react";
import {
FlatList,
KeyboardAvoidingView,
type NativeScrollEvent,
type NativeSyntheticEvent,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { AssistantMessage } from "@/components/AssistantMessage";
import { MessageRow, type Sender } from "@/components/MessageRow";
import { ToolCallCard } from "@/components/ToolCallCard";
import { UserMessage } from "@/components/UserMessage";
import { useSocket } from "@/contexts/SocketContext";
import { extractResultText, toolArgsPreview, truncateDetail } from "@/lib/tool-format";
import { colors, monoFont } from "@/lib/theme";
import type { ChatHistoryMessage, ChatItem } from "@/lib/types";
function extractText(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((block) => {
if (typeof block === "object" && block && "text" in block) {
return String((block as { text?: string }).text ?? "");
}
return "";
})
.join("");
}
return "";
}
function historyToItems(messages: ChatHistoryMessage[]): ChatItem[] {
return messages.map((message, index) => {
if (message.role === "user") {
return { id: `history-user-${index}`, kind: "user", text: message.content };
}
if (message.role === "assistant") {
return {
id: `history-assistant-${index}`,
kind: "assistant",
text: message.content,
streaming: false,
};
}
return {
id: `history-tool-${message.toolCallId || index}`,
kind: "tool",
toolName: message.toolName,
status: message.isError ? "error" : "done",
argsPreview: toolArgsPreview(message.args),
detail: message.content ? truncateDetail(message.content) : undefined,
};
});
}
function senderOf(item: ChatItem): Sender {
return item.kind === "user" ? "user" : "agent";
}
/** Skip rows that render nothing so they don't produce an empty labeled block. */
function isRenderable(item: ChatItem): boolean {
return item.kind !== "assistant" || Boolean(item.text.trim() || item.streaming);
}
function renderChatItem(item: ChatItem, index: number, items: ChatItem[]) {
if (!isRenderable(item)) {
return null;
}
const sender = senderOf(item);
let previous: ChatItem | undefined;
for (let i = index - 1; i >= 0; i--) {
if (isRenderable(items[i])) {
previous = items[i];
break;
}
}
const groupStart = !previous || senderOf(previous) !== sender;
let content;
if (item.kind === "user") {
content = <UserMessage text={item.text} />;
} else if (item.kind === "tool") {
content = (
<ToolCallCard
toolName={item.toolName}
status={item.status}
argsPreview={item.argsPreview}
detail={item.detail}
/>
);
} else {
content = <AssistantMessage text={item.text} streaming={item.streaming} />;
}
return (
<MessageRow sender={sender} groupStart={groupStart}>
{content}
</MessageRow>
);
}
export default function ChatScreen() {
const params = useLocalSearchParams<{ runtimeId: string | string[]; cwd?: string }>();
const paramRuntimeId = Array.isArray(params.runtimeId) ? params.runtimeId[0] : params.runtimeId;
const isDraftChat = paramRuntimeId === "new";
const projectCwd = params.cwd ?? "";
const {
attachRuntime,
createRuntime,
sendPrompt,
abortPrompt,
subscribeChat,
subscribeChatHistory,
waitForRuntime,
} = useSocket();
const [activeRuntimeId, setActiveRuntimeId] = useState<string | null>(
isDraftChat ? null : (paramRuntimeId ?? null),
);
const [items, setItems] = useState<ChatItem[]>([]);
const [draft, setDraft] = useState("");
const [streaming, setStreaming] = useState(false);
const [creating, setCreating] = useState(false);
const assistantIdRef = useRef<string | null>(null);
const toolIdsRef = useRef(new Map<string, string>());
const pendingMessageRef = useRef<string | null>(null);
const listRef = useRef<FlatList<ChatItem>>(null);
const atBottomRef = useRef(true);
useEffect(() => {
if (!activeRuntimeId) {
return;
}
if (!pendingMessageRef.current) {
setItems([]);
}
setStreaming(false);
assistantIdRef.current = null;
toolIdsRef.current.clear();
const unsubscribeHistory = subscribeChatHistory((payload) => {
if (payload.runtimeId !== activeRuntimeId) {
return;
}
const historyItems = historyToItems(payload.messages);
setItems((current) => (historyItems.length > 0 ? historyItems : current));
});
const unsubscribeChat = subscribeChat((payload) => {
if (payload.runtimeId !== activeRuntimeId) {
return;
}
const event = payload.event;
switch (event.type) {
case "agent_start":
setStreaming(true);
break;
case "message_start": {
const role = (event as { message?: { role?: string } }).message?.role;
if (role === "assistant") {
const id = `assistant-${Date.now()}`;
assistantIdRef.current = id;
setItems((current) => [
...current,
{ id, kind: "assistant", text: "", streaming: true },
]);
}
break;
}
case "message_update": {
const assistantEvent = (
event as { assistantMessageEvent?: { type?: string; delta?: string } }
).assistantMessageEvent;
if (assistantEvent?.type === "text_delta" && assistantEvent.delta) {
const activeId = assistantIdRef.current;
if (!activeId) {
break;
}
setItems((current) =>
current.map((item) =>
item.id === activeId && item.kind === "assistant"
? { ...item, text: item.text + assistantEvent.delta }
: item,
),
);
}
break;
}
case "message_end": {
const activeId = assistantIdRef.current;
if (!activeId) {
break;
}
setItems((current) =>
current.map((item) =>
item.id === activeId && item.kind === "assistant"
? { ...item, streaming: false }
: item,
),
);
assistantIdRef.current = null;
break;
}
case "tool_execution_start": {
const toolName = String((event as { toolName?: string }).toolName ?? "tool");
const toolCallId = String((event as { toolCallId?: string }).toolCallId ?? toolName);
const id = `tool-${toolCallId}`;
toolIdsRef.current.set(toolCallId, id);
setItems((current) => [
...current,
{
id,
kind: "tool",
toolName,
status: "running",
argsPreview: toolArgsPreview((event as { args?: unknown }).args),
},
]);
break;
}
case "tool_execution_update": {
const toolCallId = String((event as { toolCallId?: string }).toolCallId ?? "");
const id = toolIdsRef.current.get(toolCallId);
if (!id) {
break;
}
const partial = (event as { partialResult?: unknown; output?: unknown }).partialResult;
const fallback = (event as { output?: unknown }).output;
const detail = extractResultText(partial ?? fallback);
if (!detail.trim()) {
break;
}
setItems((current) =>
current.map((item) =>
item.id === id && item.kind === "tool"
? { ...item, detail: truncateDetail(detail) }
: item,
),
);
break;
}
case "tool_execution_end": {
const toolCallId = String((event as { toolCallId?: string }).toolCallId ?? "");
const id = toolIdsRef.current.get(toolCallId);
if (!id) {
break;
}
const isError = Boolean((event as { isError?: boolean }).isError);
const resultText = extractResultText((event as { result?: unknown }).result);
setItems((current) =>
current.map((item) =>
item.id === id && item.kind === "tool"
? {
...item,
status: isError ? "error" : "done",
detail: resultText.trim() ? truncateDetail(resultText) : item.detail,
}
: item,
),
);
break;
}
case "agent_end": {
setStreaming(false);
const messages = (event as { messages?: Array<{ role?: string; content?: unknown }> })
.messages;
if (!messages?.length) {
break;
}
setItems((current) => {
const next = [...current];
for (const message of messages) {
if (message.role === "user") {
const text = extractText(message.content);
if (text && !next.some((item) => item.kind === "user" && item.text === text)) {
next.push({ id: `user-${Date.now()}-${text.slice(0, 8)}`, kind: "user", text });
}
}
}
return next;
});
break;
}
}
});
attachRuntime(activeRuntimeId);
const pendingMessage = pendingMessageRef.current;
if (pendingMessage) {
pendingMessageRef.current = null;
sendPrompt(activeRuntimeId, pendingMessage);
}
return () => {
unsubscribeHistory();
unsubscribeChat();
};
}, [
activeRuntimeId,
attachRuntime,
sendPrompt,
subscribeChat,
subscribeChatHistory,
]);
const send = async () => {
const message = draft.trim();
if (!message || creating) {
return;
}
atBottomRef.current = true;
setItems((current) => [...current, { id: `user-${Date.now()}`, kind: "user", text: message }]);
setDraft("");
if (!activeRuntimeId) {
if (!projectCwd) {
setItems((current) => current.slice(0, -1));
return;
}
pendingMessageRef.current = message;
setCreating(true);
createRuntime(projectCwd);
try {
const runtime = await waitForRuntime("created");
setActiveRuntimeId(runtime.runtimeId);
router.replace(`/chat/${runtime.runtimeId}`);
} catch {
pendingMessageRef.current = null;
setItems((current) => current.slice(0, -1));
} finally {
setCreating(false);
}
return;
}
sendPrompt(activeRuntimeId, message);
};
const handleScroll = (scrollEvent: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = scrollEvent.nativeEvent;
atBottomRef.current =
contentOffset.y + layoutMeasurement.height >= contentSize.height - 80;
};
const handleContentSizeChange = () => {
if (atBottomRef.current) {
listRef.current?.scrollToEnd({ animated: false });
}
};
const canSend = draft.trim().length > 0 && !creating;
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={88}
>
<FlatList
ref={listRef}
data={items}
keyExtractor={(item) => item.id}
contentContainerStyle={styles.list}
onScroll={handleScroll}
scrollEventThrottle={100}
onContentSizeChange={handleContentSizeChange}
ListEmptyComponent={
<View style={styles.empty}>
<Text style={styles.emptyText}>no messages yet</Text>
</View>
}
renderItem={({ item, index }) => renderChatItem(item, index, items)}
/>
<View style={styles.composer}>
{streaming ? (
<Pressable
style={styles.abortButton}
onPress={() => activeRuntimeId && abortPrompt(activeRuntimeId)}
>
<Text style={styles.abortText}>Stop</Text>
</Pressable>
) : null}
<TextInput
value={draft}
onChangeText={setDraft}
placeholder="Message Pi…"
placeholderTextColor={colors.textMuted}
style={styles.input}
multiline
/>
<Pressable
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
onPress={send}
disabled={!canSend}
>
<Text style={styles.sendText}>Send</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
list: {
paddingHorizontal: 14,
paddingTop: 2,
paddingBottom: 28,
flexGrow: 1,
},
empty: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
emptyText: {
color: colors.textMuted,
fontFamily: monoFont,
fontSize: 13,
},
composer: {
borderTopWidth: 1,
borderTopColor: colors.borderSubtle,
paddingHorizontal: 12,
paddingVertical: 10,
gap: 8,
flexDirection: "row",
alignItems: "flex-end",
},
input: {
flex: 1,
minHeight: 40,
maxHeight: 120,
backgroundColor: colors.surfaceDeep,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 15,
},
sendButton: {
borderRadius: 8,
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.surface,
paddingHorizontal: 14,
paddingVertical: 11,
},
sendButtonDisabled: {
opacity: 0.4,
},
sendText: {
color: colors.text,
fontFamily: monoFont,
fontSize: 13,
fontWeight: "700",
},
abortButton: {
borderWidth: 1,
borderColor: colors.errorBorder,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 11,
},
abortText: {
color: colors.errorMuted,
fontFamily: monoFont,
fontSize: 13,
fontWeight: "700",
},
});
+277
View File
@@ -0,0 +1,277 @@
import { router } from "expo-router";
import { useEffect } from "react";
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { useSocket } from "@/contexts/SocketContext";
import { colors, monoFont } from "@/lib/theme";
import type { SavedComputer } from "@/lib/types";
function SavedComputerRow({
computer,
connecting,
onConnect,
onRemove,
}: {
computer: SavedComputer;
connecting: boolean;
onConnect: () => void;
onRemove: () => void;
}) {
return (
<View style={styles.savedRow}>
<Pressable
style={({ pressed }) => [styles.savedRowMain, pressed && styles.savedRowPressed]}
disabled={connecting}
onPress={onConnect}
>
<Text style={styles.savedName} numberOfLines={1}>
{computer.name}
</Text>
<Text style={styles.savedAddress}>
{computer.host}:{computer.port}
</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.removeButton, pressed && styles.removeButtonPressed]}
disabled={connecting}
onPress={onRemove}
hitSlop={8}
>
<Text style={styles.removeButtonText}>×</Text>
</Pressable>
</View>
);
}
export default function ConnectScreen() {
const {
host,
port,
computerName,
connected,
connecting,
error,
savedComputers,
setHost,
setPort,
setComputerName,
connect,
connectToSavedComputer,
removeSavedComputerEntry,
} = useSocket();
useEffect(() => {
if (connected) {
router.replace("/sessions");
}
}, [connected]);
return (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.brand}>Pi Mobile</Text>
<Text style={styles.subtitle}>Remote control for Pi agents on your computer.</Text>
<View style={styles.card}>
<Text style={styles.label}>Computer name</Text>
<TextInput
value={computerName}
onChangeText={setComputerName}
placeholder="My MacBook"
placeholderTextColor="#666"
autoCapitalize="words"
autoCorrect={false}
style={styles.input}
/>
<Text style={styles.label}>Computer IP</Text>
<TextInput
value={host}
onChangeText={setHost}
placeholder="192.168.1.10"
placeholderTextColor="#666"
autoCapitalize="none"
autoCorrect={false}
style={styles.input}
/>
<Text style={styles.label}>Port</Text>
<TextInput
value={port}
onChangeText={setPort}
placeholder="8787"
placeholderTextColor="#666"
keyboardType="number-pad"
style={styles.input}
/>
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
style={[styles.button, connecting && styles.buttonDisabled]}
disabled={connecting || !host.trim()}
onPress={() => void connect()}
>
{connecting ? (
<ActivityIndicator color="#0a0a0a" />
) : (
<Text style={styles.buttonText}>Connect</Text>
)}
</Pressable>
</View>
{savedComputers.length > 0 ? (
<View style={styles.savedSection}>
<Text style={styles.savedSectionTitle}>Saved computers</Text>
{savedComputers.map((computer) => (
<SavedComputerRow
key={computer.id}
computer={computer}
connecting={connecting}
onConnect={() => void connectToSavedComputer(computer)}
onRemove={() => void removeSavedComputerEntry(computer.id)}
/>
))}
</View>
) : (
<Text style={styles.savedHint}>
Successful connections are saved here so you can reconnect with one tap.
</Text>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: {
flex: 1,
},
container: {
padding: 24,
gap: 16,
paddingBottom: 40,
},
brand: {
fontSize: 36,
fontWeight: "700",
color: "#f5f5f5",
marginTop: 24,
},
subtitle: {
color: "#a3a3a3",
fontSize: 16,
lineHeight: 22,
},
card: {
marginTop: 24,
gap: 12,
},
label: {
color: "#d4d4d4",
fontSize: 14,
fontWeight: "600",
},
input: {
backgroundColor: "#171717",
borderWidth: 1,
borderColor: "#262626",
borderRadius: 12,
color: "#f5f5f5",
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 16,
},
button: {
marginTop: 8,
backgroundColor: "#f5f5f5",
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
minHeight: 48,
},
buttonDisabled: {
opacity: 0.7,
},
buttonText: {
color: "#0a0a0a",
fontSize: 16,
fontWeight: "700",
},
error: {
color: "#f87171",
fontSize: 14,
},
savedSection: {
marginTop: 8,
gap: 10,
},
savedSectionTitle: {
color: colors.textSecondary,
fontSize: 13,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 0.6,
fontFamily: monoFont,
},
savedRow: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
overflow: "hidden",
},
savedRowMain: {
flex: 1,
paddingHorizontal: 14,
paddingVertical: 12,
gap: 2,
},
savedRowPressed: {
backgroundColor: colors.surfaceDeep,
},
savedName: {
color: colors.text,
fontSize: 16,
fontWeight: "600",
},
savedAddress: {
color: colors.textSecondary,
fontSize: 13,
fontFamily: monoFont,
},
removeButton: {
paddingHorizontal: 16,
paddingVertical: 12,
alignSelf: "stretch",
justifyContent: "center",
borderLeftWidth: 1,
borderLeftColor: colors.border,
},
removeButtonPressed: {
backgroundColor: colors.surfaceDeep,
},
removeButtonText: {
color: colors.textMuted,
fontSize: 22,
fontWeight: "300",
lineHeight: 22,
},
savedHint: {
marginTop: 8,
color: colors.textMuted,
fontSize: 14,
lineHeight: 20,
},
});
+667
View File
@@ -0,0 +1,667 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
Modal,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useSocket } from "@/contexts/SocketContext";
import { createFolder, listFolders } from "@/lib/api";
import { colors, monoFont } from "@/lib/theme";
import type { FolderEntry, FolderListResult } from "@/lib/types";
function FolderRow({
item,
isLast,
onPress,
}: {
item: FolderEntry;
isLast: boolean;
onPress: () => void;
}) {
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<View style={styles.rowIcon}>
<Ionicons name="folder-outline" size={18} color={colors.accent} />
</View>
<View style={[styles.rowMain, !isLast && styles.rowDivider]}>
<Text style={styles.folderName} numberOfLines={1}>
{item.name}
</Text>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
</Pressable>
);
}
function NewFolderModal({
visible,
name,
creating,
error,
onChangeName,
onCancel,
onCreate,
}: {
visible: boolean;
name: string;
creating: boolean;
error: string | null;
onChangeName: (value: string) => void;
onCancel: () => void;
onCreate: () => void;
}) {
return (
<Modal visible={visible} animationType="fade" transparent onRequestClose={onCancel}>
<KeyboardAvoidingView
style={styles.modalOverlay}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.modalCard}>
<Text style={styles.modalTitle}>New folder</Text>
<TextInput
value={name}
onChangeText={onChangeName}
placeholder="Folder name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus
style={styles.modalInput}
onSubmitEditing={onCreate}
/>
{error ? <Text style={styles.modalError}>{error}</Text> : null}
<View style={styles.modalActions}>
<Pressable
style={({ pressed }) => [styles.modalButton, pressed && styles.pressedDim]}
onPress={onCancel}
disabled={creating}
>
<Text style={styles.modalButtonText}>Cancel</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.modalButton,
styles.modalButtonPrimary,
(creating || !name.trim()) && styles.useButtonDisabled,
pressed && !creating && styles.pressedDim,
]}
onPress={onCreate}
disabled={creating || !name.trim()}
>
{creating ? (
<ActivityIndicator color={colors.userBubbleText} size="small" />
) : (
<Text style={styles.modalButtonTextPrimary}>Create</Text>
)}
</Pressable>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
function EmptyFolders() {
return (
<View style={styles.empty}>
<View style={styles.emptyIcon}>
<Ionicons name="folder-open-outline" size={22} color={colors.textMuted} />
</View>
<Text style={styles.emptyTitle}>No folders here</Text>
<Text style={styles.emptyHint}>This directory has no subfolders to browse.</Text>
</View>
);
}
function NoSearchResults({ query }: { query: string }) {
return (
<View style={styles.empty}>
<View style={styles.emptyIcon}>
<Ionicons name="search-outline" size={22} color={colors.textMuted} />
</View>
<Text style={styles.emptyTitle}>No matches</Text>
<Text style={styles.emptyHint}>No folders match "{query}".</Text>
</View>
);
}
export default function FolderPickerScreen() {
const { baseUrl, addProject, savedProjects, error } = useSocket();
const insets = useSafeAreaInsets();
const [current, setCurrent] = useState<FolderListResult | null>(null);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const [newFolderVisible, setNewFolderVisible] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [newFolderError, setNewFolderError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const loadPath = useCallback(
async (path = "~") => {
if (!baseUrl) {
return;
}
setLoading(true);
setLocalError(null);
setSearch("");
try {
const result = (await listFolders(baseUrl, path)) as FolderListResult;
setCurrent(result);
} catch (loadError) {
const message = loadError instanceof Error ? loadError.message : "Failed to load folders";
setLocalError(message);
} finally {
setLoading(false);
}
},
[baseUrl],
);
useEffect(() => {
void loadPath("~");
}, [loadPath]);
const useFolder = async () => {
if (!current) {
return;
}
setAdding(true);
setLocalError(null);
try {
await addProject(current.path);
router.replace({ pathname: "/project", params: { cwd: current.path } });
} catch (addError) {
const message =
addError instanceof Error ? addError.message : "Failed to add project";
setLocalError(message);
setAdding(false);
}
};
const openNewFolder = () => {
setNewFolderName("");
setNewFolderError(null);
setNewFolderVisible(true);
};
const closeNewFolder = () => {
if (creatingFolder) {
return;
}
setNewFolderVisible(false);
};
const submitNewFolder = async () => {
const trimmed = newFolderName.trim();
if (!trimmed || !current || !baseUrl) {
return;
}
setCreatingFolder(true);
setNewFolderError(null);
try {
await createFolder(baseUrl, current.path, trimmed);
setNewFolderVisible(false);
await loadPath(current.path);
} catch (createError) {
const message =
createError instanceof Error ? createError.message : "Failed to create folder";
setNewFolderError(message);
} finally {
setCreatingFolder(false);
}
};
const alreadyAdded = current ? savedProjects.includes(current.path) : false;
const entries = current?.entries ?? [];
const folderCount = entries.length;
const trimmedSearch = search.trim().toLowerCase();
const filteredEntries = trimmedSearch
? entries.filter((item) => item.name.toLowerCase().includes(trimmedSearch))
: entries;
if (!baseUrl) {
return (
<View style={styles.center}>
<View style={styles.emptyIcon}>
<Ionicons name="cloud-offline-outline" size={22} color={colors.textMuted} />
</View>
<Text style={styles.emptyTitle}>Not connected</Text>
<Text style={styles.emptyHint}>Connect to a host first.</Text>
</View>
);
}
return (
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.pathCard}>
<View style={styles.pathRow}>
{current?.parent ? (
<Pressable
style={({ pressed }) => [styles.upButton, pressed && styles.pressedDim]}
onPress={() => void loadPath(current.parent ?? "~")}
hitSlop={6}
>
<Ionicons name="chevron-back" size={18} color={colors.accent} />
</Pressable>
) : (
<View style={styles.upButtonDisabled}>
<Ionicons name="chevron-back" size={18} color={colors.textMuted} />
</View>
)}
<Ionicons name="folder-outline" size={14} color={colors.textMuted} />
<Text style={styles.path} numberOfLines={2}>
{current?.path ?? "~"}
</Text>
</View>
{!loading ? (
<View style={styles.pathMetaRow}>
<Text style={styles.pathMeta}>
{folderCount === 0
? "No subfolders"
: `${folderCount} folder${folderCount === 1 ? "" : "s"}`}
</Text>
<Pressable
style={({ pressed }) => [styles.newFolderButton, pressed && styles.pressedDim]}
onPress={openNewFolder}
hitSlop={6}
>
<Ionicons name="add" size={14} color={colors.accent} />
<Text style={styles.newFolderButtonText}>New folder</Text>
</Pressable>
</View>
) : null}
</View>
{!loading && folderCount > 0 ? (
<View style={styles.searchBar}>
<Ionicons name="search-outline" size={16} color={colors.textMuted} />
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Search folders"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
style={styles.searchInput}
returnKeyType="search"
clearButtonMode="while-editing"
/>
{Platform.OS !== "ios" && search ? (
<Pressable onPress={() => setSearch("")} hitSlop={6}>
<Ionicons name="close-circle" size={16} color={colors.textMuted} />
</Pressable>
) : null}
</View>
) : null}
</View>
{loading ? (
<View style={styles.center}>
<ActivityIndicator color={colors.text} />
</View>
) : folderCount === 0 ? (
<EmptyFolders />
) : filteredEntries.length === 0 ? (
<NoSearchResults query={search.trim()} />
) : (
<FlatList
data={filteredEntries}
keyExtractor={(item) => item.path}
style={styles.listScroll}
contentContainerStyle={styles.list}
keyboardShouldPersistTaps="handled"
renderItem={({ item, index }) => (
<FolderRow
item={item}
isLast={index === filteredEntries.length - 1}
onPress={() => void loadPath(item.path)}
/>
)}
/>
)}
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
{localError || error ? (
<View style={styles.errorBanner}>
<Ionicons name="alert-circle-outline" size={16} color={colors.errorMuted} />
<Text style={styles.errorText}>{localError ?? error}</Text>
</View>
) : null}
<Pressable
style={({ pressed }) => [
styles.useButton,
(adding || !current) && styles.useButtonDisabled,
pressed && !adding && styles.pressedDim,
]}
disabled={adding || !current}
onPress={() => void useFolder()}
>
{adding ? (
<ActivityIndicator color={colors.userBubbleText} />
) : (
<>
<Ionicons
name={alreadyAdded ? "open-outline" : "add-circle-outline"}
size={20}
color={colors.userBubbleText}
/>
<Text style={styles.useButtonText}>
{alreadyAdded ? "Open project" : "Add this folder"}
</Text>
</>
)}
</Pressable>
</View>
<NewFolderModal
visible={newFolderVisible}
name={newFolderName}
creating={creatingFolder}
error={newFolderError}
onChangeName={setNewFolderName}
onCancel={closeNewFolder}
onCreate={() => void submitNewFolder()}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingHorizontal: 16,
paddingTop: 8,
paddingBottom: 4,
gap: 10,
},
searchBar: {
flexDirection: "row",
alignItems: "center",
gap: 8,
backgroundColor: colors.surface,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: 12,
height: 40,
},
searchInput: {
flex: 1,
color: colors.text,
fontSize: 15,
height: "100%",
},
pathCard: {
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: 14,
gap: 10,
},
upButton: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(147, 197, 253, 0.1)",
borderRadius: 8,
},
upButtonDisabled: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
opacity: 0.4,
},
pathRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
path: {
flex: 1,
color: colors.textSecondary,
fontSize: 13,
lineHeight: 18,
fontFamily: monoFont,
},
pathMetaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
pathMeta: {
color: colors.textMuted,
fontSize: 12,
fontFamily: monoFont,
},
newFolderButton: {
flexDirection: "row",
alignItems: "center",
gap: 3,
backgroundColor: "rgba(147, 197, 253, 0.1)",
borderRadius: 8,
paddingHorizontal: 9,
paddingVertical: 5,
},
newFolderButtonText: {
color: colors.accent,
fontSize: 12,
fontWeight: "600",
},
listScroll: {
flex: 1,
},
list: {
margin: 16,
marginBottom: 8,
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderSubtle,
overflow: "hidden",
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingLeft: 14,
},
rowPressed: {
backgroundColor: colors.surfaceDeep,
},
rowIcon: {
width: 34,
height: 34,
borderRadius: 9,
backgroundColor: "rgba(147, 197, 253, 0.1)",
alignItems: "center",
justifyContent: "center",
},
rowMain: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 13,
paddingRight: 14,
},
rowDivider: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
folderName: {
flex: 1,
color: colors.text,
fontSize: 16,
fontWeight: "600",
},
footer: {
paddingHorizontal: 16,
paddingTop: 8,
gap: 10,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.background,
},
useButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
backgroundColor: colors.userBubbleBg,
borderRadius: 14,
minHeight: 52,
},
useButtonDisabled: {
opacity: 0.6,
},
useButtonText: {
color: colors.userBubbleText,
fontWeight: "700",
fontSize: 16,
},
pressedDim: {
opacity: 0.7,
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: 32,
},
empty: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: 32,
},
emptyIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.surfaceDeep,
borderWidth: 1,
borderColor: colors.border,
alignItems: "center",
justifyContent: "center",
marginBottom: 4,
},
emptyTitle: {
color: colors.text,
fontSize: 15,
fontWeight: "600",
},
emptyHint: {
color: colors.textMuted,
fontSize: 13,
lineHeight: 19,
textAlign: "center",
maxWidth: 260,
},
errorBanner: {
flexDirection: "row",
alignItems: "center",
gap: 8,
backgroundColor: "#450a0a",
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
},
errorText: {
flex: 1,
color: colors.errorMuted,
fontSize: 13,
lineHeight: 18,
},
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.6)",
alignItems: "center",
justifyContent: "center",
padding: 24,
},
modalCard: {
width: "100%",
maxWidth: 340,
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: 18,
gap: 12,
},
modalTitle: {
color: colors.text,
fontSize: 16,
fontWeight: "700",
},
modalInput: {
backgroundColor: colors.surfaceDeep,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.border,
color: colors.text,
fontSize: 15,
paddingHorizontal: 12,
paddingVertical: 10,
},
modalError: {
color: colors.errorMuted,
fontSize: 13,
lineHeight: 18,
},
modalActions: {
flexDirection: "row",
gap: 10,
},
modalButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
minHeight: 44,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.border,
},
modalButtonPrimary: {
backgroundColor: colors.userBubbleBg,
borderColor: colors.userBubbleBg,
},
modalButtonText: {
color: colors.text,
fontSize: 15,
fontWeight: "600",
},
modalButtonTextPrimary: {
color: colors.userBubbleText,
fontSize: 15,
fontWeight: "700",
},
});
+533
View File
@@ -0,0 +1,533 @@
import { Ionicons } from "@expo/vector-icons";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useCallback, useLayoutEffect, useMemo, useState } from "react";
import {
ActivityIndicator,
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { useSocket } from "@/contexts/SocketContext";
import {
chatTitle,
formatMessageCount,
formatRelativeDate,
projectDisplayName,
sessionsForProject,
shortenPath,
} from "@/lib/session-utils";
import { colors, monoFont, tintFor } from "@/lib/theme";
import type { LiveRuntimeInfo, PersistedSessionInfo } from "@/lib/types";
function ChatRow({
title,
meta,
badge,
loading,
isLast,
onPress,
}: {
title: string;
meta?: string;
badge?: { label: string; tone: "active" | "streaming" };
loading?: boolean;
isLast?: boolean;
onPress: () => void;
}) {
return (
<Pressable
style={({ pressed }) => [
styles.row,
pressed && styles.rowPressed,
loading && styles.rowLoading,
]}
onPress={onPress}
disabled={loading}
>
<View style={styles.rowIcon}>
<Ionicons name="chatbubble-outline" size={16} color={colors.textSecondary} />
</View>
<View style={[styles.rowMain, !isLast && styles.rowDivider]}>
<View style={styles.rowText}>
<View style={styles.rowTitleRow}>
<Text style={styles.rowTitle} numberOfLines={1}>
{title}
</Text>
{badge ? (
<View
style={[styles.badge, badge.tone === "streaming" && styles.badgeStreaming]}
>
<View
style={[
styles.badgeDot,
badge.tone === "streaming" ? styles.badgeDotStreaming : styles.badgeDotActive,
]}
/>
<Text
style={[
styles.badgeText,
badge.tone === "streaming" && styles.badgeTextStreaming,
]}
>
{badge.label}
</Text>
</View>
) : null}
</View>
{meta ? <Text style={styles.rowMeta}>{meta}</Text> : null}
</View>
{loading ? (
<ActivityIndicator color={colors.textSecondary} size="small" />
) : (
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
)}
</View>
</Pressable>
);
}
function EmptyChats() {
return (
<View style={styles.emptyChats}>
<Ionicons name="chatbubbles-outline" size={20} color={colors.textMuted} />
<Text style={styles.emptyChatsText}>No past chats in this project yet.</Text>
</View>
);
}
export default function ProjectScreen() {
const { cwd: cwdParam } = useLocalSearchParams<{ cwd: string }>();
const projectCwd = cwdParam ?? "";
const navigation = useNavigation();
const {
connected,
error,
persistedSessions,
liveRuntimes,
refreshSessions,
openRuntime,
waitForRuntime,
attachRuntime,
removeProject,
} = useSocket();
const [refreshing, setRefreshing] = useState(false);
const [openingPath, setOpeningPath] = useState<string | null>(null);
const { live, persisted } = sessionsForProject(projectCwd, liveRuntimes, persistedSessions);
const projectName = projectDisplayName(projectCwd);
const tint = useMemo(() => tintFor(projectName), [projectName]);
useLayoutEffect(() => {
navigation.setOptions({ title: projectName });
}, [navigation, projectName]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
refreshSessions();
setTimeout(() => setRefreshing(false), 600);
}, [refreshSessions]);
useFocusEffect(
useCallback(() => {
if (connected) {
refreshSessions();
}
}, [connected, refreshSessions]),
);
const openLive = (runtime: LiveRuntimeInfo) => {
attachRuntime(runtime.runtimeId);
router.push(`/chat/${runtime.runtimeId}`);
};
const resumePersisted = async (session: PersistedSessionInfo) => {
setOpeningPath(session.path);
openRuntime(session.path);
try {
const runtime = await waitForRuntime("opened");
attachRuntime(runtime.runtimeId);
router.push(`/chat/${runtime.runtimeId}`);
} catch {
// error surfaced via socket context
} finally {
setOpeningPath(null);
}
};
const startNewChat = () => {
if (!projectCwd) {
return;
}
router.push(`/chat/new?cwd=${encodeURIComponent(projectCwd)}`);
};
const handleRemoveProject = async () => {
await removeProject(projectCwd);
router.back();
};
if (!projectCwd) {
return (
<View style={styles.center}>
<Text style={styles.muted}>Project not found.</Text>
</View>
);
}
return (
<View style={styles.container}>
{error ? (
<View style={styles.errorBanner}>
<Text style={styles.errorBannerText}>{error}</Text>
</View>
) : null}
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => void handleRefresh()}
tintColor={colors.text}
/>
}
>
<View style={styles.hero}>
<View style={[styles.heroTile, { backgroundColor: tint.bg }]}>
<Text style={[styles.heroLetter, { color: tint.fg }]}>
{projectName.charAt(0).toUpperCase()}
</Text>
{live.length > 0 ? <View style={styles.heroActiveDot} /> : null}
</View>
<View style={styles.heroText}>
<Text style={styles.heroName}>{projectName}</Text>
<Text style={styles.heroPath} numberOfLines={1}>
{shortenPath(projectCwd)}
</Text>
</View>
</View>
<Pressable
style={({ pressed }) => [styles.newChatButton, pressed && styles.pressedDim]}
onPress={startNewChat}
>
<View style={styles.newChatIcon}>
<Ionicons name="add" size={22} color={colors.text} />
</View>
<View style={styles.newChatText}>
<Text style={styles.newChatTitle}>New chat</Text>
<Text style={styles.newChatHint}>Start a fresh conversation in this project</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.text} />
</Pressable>
{live.length > 0 ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Active now · {live.length}
</Text>
<View style={styles.listCard}>
{live.map((runtime, index) => (
<ChatRow
key={runtime.runtimeId}
title={chatTitle(runtime.lastMessage, runtime.sessionFile ?? runtime.runtimeId)}
meta="Currently open"
badge={
runtime.isStreaming
? { label: "Working", tone: "streaming" }
: { label: "Open", tone: "active" }
}
isLast={index === live.length - 1}
onPress={() => openLive(runtime)}
/>
))}
</View>
</View>
) : null}
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Chats{persisted.length > 0 ? ` · ${persisted.length}` : ""}
</Text>
{persisted.length === 0 ? (
<EmptyChats />
) : (
<View style={styles.listCard}>
{persisted.map((session, index) => (
<ChatRow
key={session.path}
title={chatTitle(session.lastMessage, session.path)}
meta={`${formatMessageCount(session.messageCount)} · ${formatRelativeDate(session.modified)}`}
loading={openingPath === session.path}
isLast={index === persisted.length - 1}
onPress={() => void resumePersisted(session)}
/>
))}
</View>
)}
</View>
<Pressable
style={({ pressed }) => [styles.removeButton, pressed && styles.pressedDim]}
onPress={() => void handleRemoveProject()}
>
<Ionicons name="trash-outline" size={16} color={colors.errorMuted} />
<Text style={styles.removeButtonText}>Remove project</Text>
</Pressable>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scroll: {
padding: 16,
gap: 20,
paddingBottom: 40,
},
pressedDim: {
opacity: 0.7,
},
hero: {
flexDirection: "row",
alignItems: "center",
gap: 14,
paddingHorizontal: 4,
},
heroTile: {
width: 52,
height: 52,
borderRadius: 14,
alignItems: "center",
justifyContent: "center",
},
heroLetter: {
fontSize: 22,
fontWeight: "700",
},
heroActiveDot: {
position: "absolute",
right: -2,
top: -2,
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: colors.success,
borderWidth: 2,
borderColor: colors.background,
},
heroText: {
flex: 1,
gap: 3,
},
heroName: {
color: colors.text,
fontSize: 22,
fontWeight: "700",
letterSpacing: -0.3,
},
heroPath: {
color: colors.textMuted,
fontSize: 12,
fontFamily: monoFont,
},
newChatButton: {
flexDirection: "row",
alignItems: "center",
gap: 14,
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingVertical: 16,
paddingHorizontal: 16,
minHeight: 72,
},
newChatIcon: {
width: 40,
height: 40,
borderRadius: 12,
backgroundColor: "rgba(255, 255, 255, 0.12)",
alignItems: "center",
justifyContent: "center",
},
newChatText: {
flex: 1,
gap: 2,
},
newChatTitle: {
color: colors.text,
fontSize: 17,
fontWeight: "700",
},
newChatHint: {
color: colors.text,
fontSize: 13,
},
section: {
gap: 10,
},
sectionTitle: {
color: colors.textMuted,
fontSize: 12,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 1,
fontFamily: monoFont,
paddingHorizontal: 4,
},
listCard: {
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderSubtle,
overflow: "hidden",
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingLeft: 14,
},
rowPressed: {
backgroundColor: colors.surfaceDeep,
},
rowLoading: {
opacity: 0.7,
},
rowIcon: {
width: 34,
height: 34,
borderRadius: 9,
backgroundColor: colors.surfaceDeep,
borderWidth: 1,
borderColor: colors.border,
alignItems: "center",
justifyContent: "center",
},
rowMain: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 13,
paddingRight: 14,
},
rowDivider: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
rowText: {
flex: 1,
gap: 2,
},
rowTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
rowTitle: {
flex: 1,
color: colors.text,
fontSize: 15,
fontWeight: "600",
},
rowMeta: {
color: colors.textMuted,
fontSize: 12,
},
badge: {
flexDirection: "row",
alignItems: "center",
gap: 5,
borderRadius: 6,
paddingHorizontal: 7,
paddingVertical: 3,
backgroundColor: "rgba(74, 222, 128, 0.12)",
},
badgeStreaming: {
backgroundColor: "rgba(251, 191, 36, 0.12)",
},
badgeDot: {
width: 6,
height: 6,
borderRadius: 3,
},
badgeDotActive: {
backgroundColor: colors.success,
},
badgeDotStreaming: {
backgroundColor: colors.warning,
},
badgeText: {
color: colors.success,
fontSize: 11,
fontWeight: "700",
fontFamily: monoFont,
},
badgeTextStreaming: {
color: colors.warning,
},
emptyChats: {
flexDirection: "row",
alignItems: "center",
gap: 10,
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingVertical: 20,
paddingHorizontal: 16,
},
emptyChatsText: {
flex: 1,
color: colors.textMuted,
fontSize: 13,
lineHeight: 18,
},
removeButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 6,
marginTop: 4,
paddingVertical: 14,
borderRadius: 12,
borderWidth: 1,
borderColor: colors.errorBorder,
backgroundColor: "rgba(127, 29, 29, 0.2)",
},
removeButtonText: {
color: colors.errorMuted,
fontSize: 14,
fontWeight: "600",
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
muted: {
color: colors.textMuted,
fontSize: 15,
},
errorBanner: {
backgroundColor: "#450a0a",
paddingHorizontal: 16,
paddingVertical: 10,
},
errorBannerText: {
color: colors.errorMuted,
fontSize: 14,
},
});
+441
View File
@@ -0,0 +1,441 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useCallback, useMemo, useState } from "react";
import {
Pressable,
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useFocusEffect } from "@react-navigation/native";
import { useSocket } from "@/contexts/SocketContext";
import { groupByProject, projectMeta, shortenPath } from "@/lib/session-utils";
import { colors, monoFont, tintFor } from "@/lib/theme";
function ProjectRow({
name,
path,
meta,
active,
isLast,
onPress,
}: {
name: string;
path: string;
meta: string;
active: boolean;
isLast: boolean;
onPress: () => void;
}) {
const tint = tintFor(name);
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<View style={[styles.tile, { backgroundColor: tint.bg }]}>
<Text style={[styles.tileLetter, { color: tint.fg }]}>
{name.charAt(0).toUpperCase()}
</Text>
{active ? <View style={styles.tileActiveDot} /> : null}
</View>
<View style={[styles.rowMain, !isLast && styles.rowMainDivider]}>
<View style={styles.rowText}>
<Text style={styles.rowTitle} numberOfLines={1}>
{name}
</Text>
<Text style={styles.rowPath} numberOfLines={1}>
{path}
</Text>
<Text style={styles.rowMeta} numberOfLines={1}>
{meta}
</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
</Pressable>
);
}
function EmptyState() {
return (
<View style={styles.empty}>
<View style={styles.emptyIcon}>
<Ionicons name="folder-open-outline" size={22} color={colors.textMuted} />
</View>
<Text style={styles.emptyTitle}>No projects yet</Text>
<Text style={styles.emptyHint}>
Add a folder from your computer to start chatting with Pi.
</Text>
</View>
);
}
export default function SessionsScreen() {
const {
host,
port,
connected,
error,
persistedSessions,
liveRuntimes,
savedProjects,
refreshSessions,
disconnect,
} = useSocket();
const [refreshing, setRefreshing] = useState(false);
const projects = useMemo(
() => groupByProject(savedProjects, liveRuntimes, persistedSessions),
[savedProjects, liveRuntimes, persistedSessions],
);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
refreshSessions();
setTimeout(() => setRefreshing(false), 600);
}, [refreshSessions]);
useFocusEffect(
useCallback(() => {
if (connected) {
refreshSessions();
}
}, [connected, refreshSessions]),
);
const openProject = (cwd: string) => {
router.push({ pathname: "/project", params: { cwd } });
};
const handleDisconnect = () => {
disconnect();
router.replace("/");
};
if (!connected) {
return (
<View style={styles.disconnected}>
<View style={styles.emptyIcon}>
<Ionicons name="cloud-offline-outline" size={22} color={colors.textMuted} />
</View>
<Text style={styles.disconnectedTitle}>Not connected</Text>
<Text style={styles.disconnectedHint}>
Connect to your computer to see your projects.
</Text>
<Pressable
style={({ pressed }) => [styles.secondaryButton, pressed && styles.pressedDim]}
onPress={() => router.replace("/")}
>
<Text style={styles.secondaryButtonText}>Connect</Text>
</Pressable>
</View>
);
}
return (
<View style={styles.container}>
{error ? (
<View style={styles.errorBanner}>
<Text style={styles.errorBannerText}>{error}</Text>
</View>
) : null}
<ScrollView
contentContainerStyle={styles.scroll}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={() => void handleRefresh()}
tintColor={colors.text}
/>
}
>
<View style={styles.statusRow}>
<View style={styles.statusLeft}>
<View style={styles.connectionDot} />
<Text style={styles.connectionText} numberOfLines={1}>
{host}:{port}
</Text>
</View>
<Pressable
onPress={handleDisconnect}
hitSlop={10}
style={({ pressed }) => pressed && styles.pressedDim}
>
<Text style={styles.disconnectText}>Disconnect</Text>
</Pressable>
</View>
<Pressable
style={({ pressed }) => [styles.addButton, pressed && styles.pressedDim]}
onPress={() => router.push("/picker")}
>
<View style={styles.addIcon}>
<Ionicons name="add" size={20} color={colors.text} />
</View>
<View style={styles.addText}>
<Text style={styles.addTitle}>Add project</Text>
<Text style={styles.addHint}>Choose a folder on your computer</Text>
</View>
<Ionicons name="chevron-forward" size={16} color={colors.text} />
</Pressable>
<View style={styles.section}>
<Text style={styles.sectionTitle}>
Projects{projects.length > 0 ? ` · ${projects.length}` : ""}
</Text>
{projects.length === 0 ? (
<EmptyState />
) : (
<View style={styles.listCard}>
{projects.map((project, index) => (
<ProjectRow
key={project.cwd || "(unknown)"}
name={project.name}
path={shortenPath(project.cwd) || "Unknown location"}
meta={projectMeta(project)}
active={project.activeCount > 0}
isLast={index === projects.length - 1}
onPress={() => openProject(project.cwd)}
/>
))}
</View>
)}
</View>
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
scroll: {
padding: 16,
gap: 16,
paddingBottom: 40,
},
pressedDim: {
opacity: 0.6,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 4,
paddingVertical: 2,
},
statusLeft: {
flexDirection: "row",
alignItems: "center",
gap: 8,
flexShrink: 1,
},
connectionDot: {
width: 7,
height: 7,
borderRadius: 4,
backgroundColor: colors.success,
},
connectionText: {
color: colors.textSecondary,
fontSize: 12,
fontFamily: monoFont,
},
disconnectText: {
color: colors.textMuted,
fontSize: 13,
fontWeight: "600",
},
addButton: {
flexDirection: "row",
alignItems: "center",
gap: 14,
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderSubtle,
paddingVertical: 14,
paddingHorizontal: 16,
},
addIcon: {
width: 36,
height: 36,
borderRadius: 10,
backgroundColor: "rgba(255, 255, 255, 0.12)",
alignItems: "center",
justifyContent: "center",
},
addText: {
flex: 1,
gap: 1,
},
addTitle: {
color: colors.text,
fontSize: 16,
fontWeight: "700",
},
addHint: {
color: colors.text,
fontSize: 13,
},
section: {
gap: 10,
marginTop: 4,
},
sectionTitle: {
color: colors.textMuted,
fontSize: 12,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 1,
fontFamily: monoFont,
paddingHorizontal: 4,
},
listCard: {
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderSubtle,
overflow: "hidden",
},
row: {
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingLeft: 14,
},
rowPressed: {
backgroundColor: colors.surfaceDeep,
},
tile: {
width: 42,
height: 42,
borderRadius: 12,
alignItems: "center",
justifyContent: "center",
},
tileLetter: {
fontSize: 17,
fontWeight: "700",
},
tileActiveDot: {
position: "absolute",
right: -2,
top: -2,
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: colors.success,
borderWidth: 2,
borderColor: colors.surface,
},
rowMain: {
flex: 1,
flexDirection: "row",
alignItems: "center",
gap: 8,
paddingVertical: 13,
paddingRight: 14,
},
rowMainDivider: {
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.border,
},
rowText: {
flex: 1,
gap: 2,
},
rowTitle: {
color: colors.text,
fontSize: 16,
fontWeight: "600",
},
rowPath: {
color: colors.textSecondary,
fontSize: 12,
fontFamily: monoFont,
},
rowMeta: {
color: colors.textMuted,
fontSize: 12,
},
empty: {
alignItems: "center",
gap: 6,
paddingVertical: 36,
paddingHorizontal: 24,
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.borderSubtle,
},
emptyIcon: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.surfaceDeep,
borderWidth: 1,
borderColor: colors.border,
alignItems: "center",
justifyContent: "center",
marginBottom: 6,
},
emptyTitle: {
color: colors.text,
fontSize: 15,
fontWeight: "600",
},
emptyHint: {
color: colors.textMuted,
fontSize: 13,
lineHeight: 19,
textAlign: "center",
},
secondaryButton: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 12,
paddingHorizontal: 24,
paddingVertical: 12,
alignItems: "center",
marginTop: 8,
},
secondaryButtonText: {
color: colors.text,
fontWeight: "600",
fontSize: 15,
},
disconnected: {
flex: 1,
alignItems: "center",
justifyContent: "center",
gap: 6,
padding: 32,
},
disconnectedTitle: {
color: colors.text,
fontSize: 18,
fontWeight: "700",
},
disconnectedHint: {
color: colors.textMuted,
fontSize: 15,
textAlign: "center",
lineHeight: 22,
},
errorBanner: {
backgroundColor: "#450a0a",
paddingHorizontal: 16,
paddingVertical: 10,
},
errorBannerText: {
color: colors.errorMuted,
fontSize: 14,
},
});
+6
View File
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
};
};
+206
View File
@@ -0,0 +1,206 @@
import Markdown from "@ronradtke/react-native-markdown-display";
import { memo } from "react";
import { ActivityIndicator, StyleSheet, View } from "react-native";
import { CollapsibleMessage } from "@/components/CollapsibleMessage";
import { colors, monoFont } from "@/lib/theme";
type Props = {
text: string;
streaming?: boolean;
};
function AssistantMessageComponent({ text, streaming }: Props) {
if (!text.trim()) {
if (!streaming) {
return null;
}
return (
<View style={styles.thinking}>
<ActivityIndicator size="small" color={colors.textMuted} />
</View>
);
}
return (
<CollapsibleMessage text={text} forceExpanded={streaming} style={styles.container}>
<Markdown style={markdownStyles} colorScheme="dark">
{text}
</Markdown>
</CollapsibleMessage>
);
}
export const AssistantMessage = memo(AssistantMessageComponent);
const styles = StyleSheet.create({
container: {
alignSelf: "stretch",
},
thinking: {
alignSelf: "flex-start",
paddingVertical: 4,
},
});
const markdownStyles = StyleSheet.create({
body: {
color: colors.text,
fontSize: 15,
lineHeight: 22,
},
paragraph: {
marginTop: 0,
marginBottom: 10,
},
heading1: {
color: colors.text,
fontSize: 22,
fontWeight: "700",
marginTop: 12,
marginBottom: 8,
},
heading2: {
color: colors.text,
fontSize: 19,
fontWeight: "700",
marginTop: 12,
marginBottom: 6,
},
heading3: {
color: colors.text,
fontSize: 17,
fontWeight: "700",
marginTop: 10,
marginBottom: 6,
},
heading4: {
color: colors.text,
fontSize: 15,
fontWeight: "700",
marginTop: 8,
marginBottom: 4,
},
heading5: {
color: colors.text,
fontSize: 15,
fontWeight: "600",
},
heading6: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: "600",
},
strong: {
fontWeight: "700",
},
em: {
fontStyle: "italic",
},
link: {
color: colors.accent,
textDecorationLine: "underline",
},
blockquote: {
backgroundColor: colors.surfaceDeep,
borderLeftWidth: 3,
borderLeftColor: colors.border,
paddingHorizontal: 12,
paddingVertical: 4,
marginVertical: 6,
borderRadius: 4,
},
code_inline: {
backgroundColor: colors.surface,
color: colors.accent,
fontFamily: monoFont,
fontSize: 13,
borderRadius: 4,
paddingHorizontal: 4,
},
code_block: {
backgroundColor: colors.surfaceDeep,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 8,
color: colors.text,
fontFamily: monoFont,
fontSize: 12.5,
lineHeight: 18,
padding: 10,
marginVertical: 6,
},
fence: {
backgroundColor: colors.surfaceDeep,
borderColor: colors.border,
borderWidth: 1,
borderRadius: 8,
overflow: "hidden",
marginVertical: 6,
},
fence_header: {
backgroundColor: colors.surface,
borderBottomColor: colors.border,
borderBottomWidth: 1,
paddingHorizontal: 10,
paddingVertical: 4,
},
fence_code: {
backgroundColor: colors.surfaceDeep,
padding: 10,
},
fence_language_label: {
color: colors.textMuted,
fontFamily: monoFont,
fontSize: 11,
},
fence_copy_text: {
color: colors.textMuted,
fontSize: 11,
},
fence_token: {
color: colors.text,
fontFamily: monoFont,
fontSize: 12.5,
lineHeight: 18,
},
bullet_list: {
marginBottom: 10,
},
ordered_list: {
marginBottom: 10,
},
list_item: {
flexDirection: "row",
marginBottom: 4,
},
bullet_list_icon: {
color: colors.textMuted,
marginRight: 8,
},
ordered_list_icon: {
color: colors.textMuted,
marginRight: 8,
},
hr: {
backgroundColor: colors.border,
height: 1,
marginVertical: 12,
},
table: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 6,
marginVertical: 6,
},
th: {
padding: 6,
fontWeight: "700",
},
td: {
padding: 6,
},
tr: {
borderBottomWidth: 1,
borderColor: colors.borderSubtle,
},
});
@@ -0,0 +1,91 @@
import { memo, useEffect, useState, type ReactNode } from "react";
import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from "react-native";
import { colors } from "@/lib/theme";
const CHAR_THRESHOLD = 480;
const LINE_THRESHOLD = 10;
export const COLLAPSED_MAX_HEIGHT = 220;
export function isLongMessage(text: string): boolean {
const trimmed = text.trim();
if (trimmed.length > CHAR_THRESHOLD) {
return true;
}
let lines = 1;
for (let i = 0; i < trimmed.length; i++) {
if (trimmed.charCodeAt(i) === 10) {
lines += 1;
if (lines > LINE_THRESHOLD) {
return true;
}
}
}
return false;
}
type Props = {
text: string;
/** Keep fully expanded (e.g. while streaming). */
forceExpanded?: boolean;
collapsedMaxHeight?: number;
toggleAlign?: "left" | "right";
style?: StyleProp<ViewStyle>;
children: ReactNode;
};
function CollapsibleMessageComponent({
text,
forceExpanded = false,
collapsedMaxHeight = COLLAPSED_MAX_HEIGHT,
toggleAlign = "left",
style,
children,
}: Props) {
const long = isLongMessage(text);
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (forceExpanded) {
setExpanded(true);
}
}, [forceExpanded]);
const collapsed = long && !expanded && !forceExpanded;
return (
<View style={style}>
<View style={collapsed ? { maxHeight: collapsedMaxHeight, overflow: "hidden" } : undefined}>
{children}
</View>
{long && !forceExpanded ? (
<Pressable
onPress={() => setExpanded((value) => !value)}
style={[styles.toggle, toggleAlign === "right" && styles.toggleRight]}
hitSlop={8}
>
<Text style={styles.toggleText}>{expanded ? "Show less" : "Show more"}</Text>
</Pressable>
) : null}
</View>
);
}
export const CollapsibleMessage = memo(CollapsibleMessageComponent);
const styles = StyleSheet.create({
toggle: {
alignSelf: "flex-start",
paddingTop: 6,
paddingBottom: 2,
},
toggleRight: {
alignSelf: "flex-end",
},
toggleText: {
color: colors.accent,
fontSize: 13,
fontWeight: "600",
},
});
+68
View File
@@ -0,0 +1,68 @@
import { memo, type ReactNode } from "react";
import { StyleSheet, Text, View } from "react-native";
import { colors, monoFont } from "@/lib/theme";
export type Sender = "user" | "agent";
const SENDER_LABEL: Record<Sender, string> = {
user: "you",
agent: "pi",
};
const SENDER_COLOR: Record<Sender, string> = {
user: colors.senderUser,
agent: colors.senderAgent,
};
const RAIL_COLOR: Record<Sender, string> = {
user: colors.railUser,
agent: colors.railAgent,
};
type Props = {
sender: Sender;
/** Show the sender label (first message of a consecutive run). */
groupStart: boolean;
children: ReactNode;
};
function MessageRowComponent({ sender, groupStart, children }: Props) {
return (
<View
style={[
styles.container,
{ borderLeftColor: RAIL_COLOR[sender] },
groupStart && styles.groupStart,
]}
>
{groupStart ? (
<Text style={[styles.sender, { color: SENDER_COLOR[sender] }]}>
{SENDER_LABEL[sender]}
</Text>
) : null}
{children}
</View>
);
}
export const MessageRow = memo(MessageRowComponent);
const styles = StyleSheet.create({
container: {
borderLeftWidth: 2,
paddingLeft: 12,
paddingRight: 4,
paddingVertical: 3,
},
groupStart: {
marginTop: 18,
},
sender: {
fontFamily: monoFont,
fontSize: 11,
fontWeight: "700",
letterSpacing: 1.5,
textTransform: "uppercase",
marginBottom: 5,
},
});
+124
View File
@@ -0,0 +1,124 @@
import { memo, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { colors, monoFont } from "@/lib/theme";
type Props = {
toolName: string;
status: "running" | "done" | "error";
argsPreview?: string;
detail?: string;
};
const STATUS_COLOR: Record<Props["status"], string> = {
running: colors.warning,
done: colors.success,
error: colors.error,
};
function ToolCallCardComponent({ toolName, status, argsPreview, detail }: Props) {
const [expanded, setExpanded] = useState(false);
const output = detail?.trim() ?? "";
const expandable = output.length > 0;
return (
<Pressable
style={styles.container}
onPress={expandable ? () => setExpanded((value) => !value) : undefined}
>
<View style={styles.header}>
<Text style={[styles.prompt, { color: STATUS_COLOR[status] }]}>
{status === "error" ? "✕" : "❯"}
</Text>
<Text style={styles.name} numberOfLines={1}>
{toolName}
</Text>
{argsPreview ? (
<Text style={styles.args} numberOfLines={1}>
{argsPreview}
</Text>
) : (
<View style={styles.spacer} />
)}
{status === "running" ? <Text style={styles.statusLabel}>running…</Text> : null}
{expandable ? <Text style={styles.chevron}>{expanded ? "−" : "+"}</Text> : null}
</View>
{expandable ? (
<View style={styles.outputWrap}>
{expanded ? (
<Text style={styles.output} selectable>
{output}
</Text>
) : (
<Text style={styles.outputPreview} numberOfLines={2}>
{output}
</Text>
)}
</View>
) : null}
</Pressable>
);
}
export const ToolCallCard = memo(ToolCallCardComponent);
const styles = StyleSheet.create({
container: {
alignSelf: "stretch",
paddingVertical: 2,
gap: 4,
},
header: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
prompt: {
fontFamily: monoFont,
fontSize: 12,
fontWeight: "700",
},
name: {
color: colors.textSecondary,
fontFamily: monoFont,
fontSize: 13,
fontWeight: "600",
},
args: {
flex: 1,
color: colors.textMuted,
fontFamily: monoFont,
fontSize: 12,
},
spacer: {
flex: 1,
},
statusLabel: {
color: colors.warning,
fontFamily: monoFont,
fontSize: 11,
},
chevron: {
color: colors.textMuted,
fontFamily: monoFont,
fontSize: 13,
},
outputWrap: {
borderLeftWidth: 1,
borderLeftColor: colors.borderSubtle,
marginLeft: 4,
paddingLeft: 10,
},
output: {
color: colors.textSecondary,
fontFamily: monoFont,
fontSize: 12,
lineHeight: 17,
},
outputPreview: {
color: colors.textMuted,
fontFamily: monoFont,
fontSize: 12,
lineHeight: 17,
},
});
+28
View File
@@ -0,0 +1,28 @@
import { memo } from "react";
import { StyleSheet, Text } from "react-native";
import { CollapsibleMessage } from "@/components/CollapsibleMessage";
import { colors } from "@/lib/theme";
type Props = {
text: string;
};
function UserMessageComponent({ text }: Props) {
return (
<CollapsibleMessage text={text}>
<Text style={styles.text} selectable>
{text}
</Text>
</CollapsibleMessage>
);
}
export const UserMessage = memo(UserMessageComponent);
const styles = StyleSheet.create({
text: {
color: colors.text,
fontSize: 15,
lineHeight: 22,
},
});
+368
View File
@@ -0,0 +1,368 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
buildBaseUrl,
checkHealth,
loadSavedComputers,
loadSavedProjects,
removeSavedComputer,
saveHostConfig,
saveSavedProjects,
upsertSavedComputer,
} from "@/lib/api";
import { createSocket } from "@/lib/socket";
import type {
ChatEvent,
ChatHistoryPayload,
LiveRuntimeInfo,
PersistedSessionInfo,
RuntimeCreatedPayload,
SavedComputer,
} from "@/lib/types";
import type { Socket } from "socket.io-client";
type SocketContextValue = {
host: string;
port: string;
computerName: string;
baseUrl: string | null;
connected: boolean;
connecting: boolean;
error: string | null;
savedComputers: SavedComputer[];
persistedSessions: PersistedSessionInfo[];
liveRuntimes: LiveRuntimeInfo[];
savedProjects: string[];
addProject: (cwd: string) => Promise<void>;
removeProject: (cwd: string) => Promise<void>;
setHost: (host: string) => void;
setPort: (port: string) => void;
setComputerName: (name: string) => void;
connect: () => Promise<boolean>;
connectToSavedComputer: (computer: SavedComputer) => Promise<boolean>;
removeSavedComputerEntry: (id: string) => Promise<void>;
disconnect: () => void;
refreshSessions: () => void;
createRuntime: (cwd: string, name?: string) => void;
openRuntime: (sessionFile: string) => void;
attachRuntime: (runtimeId: string) => void;
sendPrompt: (runtimeId: string, message: string) => void;
abortPrompt: (runtimeId: string) => void;
subscribeChat: (listener: (event: ChatEvent) => void) => () => void;
subscribeChatHistory: (listener: (payload: ChatHistoryPayload) => void) => () => void;
waitForRuntime: (
kind: "created" | "opened",
timeoutMs?: number,
) => Promise<RuntimeCreatedPayload>;
};
const SocketContext = createContext<SocketContextValue | null>(null);
export function SocketProvider({ children }: { children: ReactNode }) {
const [host, setHost] = useState("");
const [port, setPort] = useState("8787");
const [computerName, setComputerName] = useState("");
const [baseUrl, setBaseUrl] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [connecting, setConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedComputers, setSavedComputers] = useState<SavedComputer[]>([]);
const [persistedSessions, setPersistedSessions] = useState<PersistedSessionInfo[]>([]);
const [liveRuntimes, setLiveRuntimes] = useState<LiveRuntimeInfo[]>([]);
const [savedProjects, setSavedProjects] = useState<string[]>([]);
const socketRef = useRef<Socket | null>(null);
const chatListenersRef = useRef(new Set<(event: ChatEvent) => void>());
const chatHistoryListenersRef = useRef(new Set<(payload: ChatHistoryPayload) => void>());
const runtimeWaitersRef = useRef<{
created: Array<(payload: RuntimeCreatedPayload) => void>;
opened: Array<(payload: RuntimeCreatedPayload) => void>;
}>({ created: [], opened: [] });
useEffect(() => {
void loadSavedComputers().then((computers) => {
setSavedComputers(computers);
const recent = computers[0];
if (!recent) {
return;
}
setHost(recent.host);
setPort(recent.port);
setComputerName(recent.name);
void loadSavedProjects(recent.host, recent.port).then(setSavedProjects);
});
}, []);
const removeSavedComputerEntry = useCallback(
async (id: string) => {
const next = await removeSavedComputer(savedComputers, id);
setSavedComputers(next);
},
[savedComputers],
);
const addProject = useCallback(
async (cwd: string) => {
const trimmed = cwd.trim();
if (!trimmed) {
return;
}
setSavedProjects((current) => {
if (current.some((entry) => entry === trimmed)) {
return current;
}
const next = [...current, trimmed];
void saveSavedProjects(host, port, next);
return next;
});
},
[host, port],
);
const removeProject = useCallback(
async (cwd: string) => {
setSavedProjects((current) => {
const next = current.filter((entry) => entry !== cwd);
void saveSavedProjects(host, port, next);
return next;
});
},
[host, port],
);
const disconnect = useCallback(() => {
socketRef.current?.disconnect();
socketRef.current = null;
setConnected(false);
setBaseUrl(null);
}, []);
const connectWithHost = useCallback(
async (nextHost: string, nextPort: string, nextName?: string) => {
setConnecting(true);
setError(null);
try {
const nextBaseUrl = buildBaseUrl(nextHost, nextPort);
const healthy = await checkHealth(nextBaseUrl);
if (!healthy) {
throw new Error("Could not reach Pi Mobile API");
}
disconnect();
const socket = createSocket(nextBaseUrl, {
onConnect: () => {
setConnected(true);
setConnecting(false);
socket.emit("sessions:list");
},
onDisconnect: () => {
setConnected(false);
},
onError: (message) => setError(message),
onSessionsList: (payload) => {
setPersistedSessions(payload.persisted);
setLiveRuntimes(payload.live);
},
onRuntimeCreated: (payload) => {
for (const resolve of runtimeWaitersRef.current.created.splice(0)) {
resolve(payload);
}
},
onRuntimeOpened: (payload) => {
for (const resolve of runtimeWaitersRef.current.opened.splice(0)) {
resolve(payload);
}
},
onChatEvent: (payload) => {
for (const listener of chatListenersRef.current) {
listener(payload);
}
},
onChatHistory: (payload) => {
for (const listener of chatHistoryListenersRef.current) {
listener(payload);
}
},
});
socketRef.current = socket;
setBaseUrl(nextBaseUrl);
setHost(nextHost);
setPort(nextPort);
if (nextName !== undefined) {
setComputerName(nextName);
}
await saveHostConfig({ host: nextHost, port: nextPort });
const nextComputers = await upsertSavedComputer(
savedComputers,
nextHost,
nextPort,
nextName ?? computerName,
);
setSavedComputers(nextComputers);
const projects = await loadSavedProjects(nextHost, nextPort);
setSavedProjects(projects);
return true;
} catch (connectError) {
const message =
connectError instanceof Error ? connectError.message : "Failed to connect";
setError(message);
setConnecting(false);
return false;
}
},
[computerName, disconnect, savedComputers],
);
const connect = useCallback(async () => {
return connectWithHost(host, port, computerName);
}, [connectWithHost, host, port, computerName]);
const connectToSavedComputer = useCallback(
async (computer: SavedComputer) => {
return connectWithHost(computer.host, computer.port, computer.name);
},
[connectWithHost],
);
const refreshSessions = useCallback(() => {
socketRef.current?.emit("sessions:list");
}, []);
const createRuntime = useCallback((cwd: string, name?: string) => {
socketRef.current?.emit("runtime:create", { cwd, name });
}, []);
const openRuntime = useCallback((sessionFile: string) => {
socketRef.current?.emit("runtime:open", { sessionFile });
}, []);
const attachRuntime = useCallback((runtimeId: string) => {
socketRef.current?.emit("runtime:attach", { runtimeId });
}, []);
const sendPrompt = useCallback((runtimeId: string, message: string) => {
socketRef.current?.emit("chat:prompt", { runtimeId, message });
}, []);
const abortPrompt = useCallback((runtimeId: string) => {
socketRef.current?.emit("chat:abort", { runtimeId });
}, []);
const subscribeChat = useCallback((listener: (event: ChatEvent) => void) => {
chatListenersRef.current.add(listener);
return () => {
chatListenersRef.current.delete(listener);
};
}, []);
const subscribeChatHistory = useCallback((listener: (payload: ChatHistoryPayload) => void) => {
chatHistoryListenersRef.current.add(listener);
return () => {
chatHistoryListenersRef.current.delete(listener);
};
}, []);
const waitForRuntime = useCallback(
(kind: "created" | "opened", timeoutMs = 15000) =>
new Promise<RuntimeCreatedPayload>((resolve, reject) => {
const timer = setTimeout(() => {
runtimeWaitersRef.current[kind] = runtimeWaitersRef.current[kind].filter(
(entry) => entry !== resolve,
);
reject(new Error("Timed out waiting for runtime"));
}, timeoutMs);
const wrappedResolve = (payload: RuntimeCreatedPayload) => {
clearTimeout(timer);
resolve(payload);
};
runtimeWaitersRef.current[kind].push(wrappedResolve);
}),
[],
);
const value = useMemo(
() => ({
host,
port,
computerName,
baseUrl,
connected,
connecting,
error,
savedComputers,
persistedSessions,
liveRuntimes,
savedProjects,
addProject,
removeProject,
setHost,
setPort,
setComputerName,
connect,
connectToSavedComputer,
removeSavedComputerEntry,
disconnect,
refreshSessions,
createRuntime,
openRuntime,
attachRuntime,
sendPrompt,
abortPrompt,
subscribeChat,
subscribeChatHistory,
waitForRuntime,
}),
[
host,
port,
computerName,
baseUrl,
connected,
connecting,
error,
savedComputers,
persistedSessions,
liveRuntimes,
savedProjects,
addProject,
removeProject,
connect,
connectToSavedComputer,
removeSavedComputerEntry,
disconnect,
refreshSessions,
createRuntime,
openRuntime,
attachRuntime,
sendPrompt,
abortPrompt,
subscribeChat,
subscribeChatHistory,
waitForRuntime,
],
);
return <SocketContext.Provider value={value}>{children}</SocketContext.Provider>;
}
export function useSocket() {
const context = useContext(SocketContext);
if (!context) {
throw new Error("useSocket must be used within SocketProvider");
}
return context;
}
+210
View File
@@ -0,0 +1,210 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { HostConfig, SavedComputer } from "./types";
const HOST_KEY = "pi-mobile:last-host";
const COMPUTERS_KEY = "pi-mobile:computers";
const PROJECTS_PREFIX = "pi-mobile:projects:";
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function computerKey(host: string, port: string): string {
return `${host.trim()}:${port.trim() || "8787"}`;
}
function sortByRecent(computers: SavedComputer[]): SavedComputer[] {
return [...computers].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
}
function projectsStorageKey(host: string, port: string): string {
return `${PROJECTS_PREFIX}${host.trim()}:${port.trim() || "8787"}`;
}
export function buildBaseUrl(host: string, port: string): string {
const trimmedHost = host.trim();
const trimmedPort = port.trim() || "8787";
const withProtocol = trimmedHost.startsWith("http") ? trimmedHost : `http://${trimmedHost}`;
const url = new URL(withProtocol);
url.port = trimmedPort;
return url.origin;
}
export async function loadHostConfig(): Promise<HostConfig | null> {
const raw = await AsyncStorage.getItem(HOST_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw) as HostConfig;
} catch {
return null;
}
}
export async function saveHostConfig(config: HostConfig): Promise<void> {
await AsyncStorage.setItem(HOST_KEY, JSON.stringify(config));
}
export async function loadSavedComputers(): Promise<SavedComputer[]> {
const raw = await AsyncStorage.getItem(COMPUTERS_KEY);
if (raw) {
try {
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return sortByRecent(
parsed.filter(
(entry): entry is SavedComputer =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as SavedComputer).id === "string" &&
typeof (entry as SavedComputer).name === "string" &&
typeof (entry as SavedComputer).host === "string" &&
typeof (entry as SavedComputer).port === "string" &&
typeof (entry as SavedComputer).lastUsedAt === "number",
),
);
}
} catch {
// fall through to migration
}
}
const legacy = await loadHostConfig();
if (!legacy?.host.trim()) {
return [];
}
const migrated: SavedComputer = {
id: generateId(),
name: legacy.host.trim(),
host: legacy.host.trim(),
port: legacy.port.trim() || "8787",
lastUsedAt: Date.now(),
};
await saveSavedComputers([migrated]);
return [migrated];
}
export async function saveSavedComputers(computers: SavedComputer[]): Promise<void> {
await AsyncStorage.setItem(COMPUTERS_KEY, JSON.stringify(sortByRecent(computers)));
}
export async function upsertSavedComputer(
computers: SavedComputer[],
host: string,
port: string,
name?: string,
): Promise<SavedComputer[]> {
const trimmedHost = host.trim();
const trimmedPort = port.trim() || "8787";
if (!trimmedHost) {
return computers;
}
const key = computerKey(trimmedHost, trimmedPort);
const existing = computers.find((entry) => computerKey(entry.host, entry.port) === key);
const trimmedName = name?.trim();
if (existing) {
const next = computers.map((entry) =>
entry.id === existing.id
? {
...entry,
name: trimmedName || entry.name,
host: trimmedHost,
port: trimmedPort,
lastUsedAt: Date.now(),
}
: entry,
);
await saveSavedComputers(next);
return sortByRecent(next);
}
const next = [
{
id: generateId(),
name: trimmedName || trimmedHost,
host: trimmedHost,
port: trimmedPort,
lastUsedAt: Date.now(),
},
...computers,
];
await saveSavedComputers(next);
return sortByRecent(next);
}
export async function removeSavedComputer(
computers: SavedComputer[],
id: string,
): Promise<SavedComputer[]> {
const next = computers.filter((entry) => entry.id !== id);
await saveSavedComputers(next);
return next;
}
export async function loadSavedProjects(host: string, port: string): Promise<string[]> {
const raw = await AsyncStorage.getItem(projectsStorageKey(host, port));
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
} catch {
return [];
}
}
export async function saveSavedProjects(
host: string,
port: string,
projects: string[],
): Promise<void> {
await AsyncStorage.setItem(projectsStorageKey(host, port), JSON.stringify(projects));
}
export async function checkHealth(baseUrl: string): Promise<boolean> {
const response = await fetch(`${baseUrl}/health`);
if (!response.ok) {
return false;
}
const data = (await response.json()) as { ok?: boolean };
return data.ok === true;
}
export async function listFolders(baseUrl: string, path = "~") {
const response = await fetch(`${baseUrl}/fs/list?path=${encodeURIComponent(path)}`);
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? "Failed to list folders");
}
return response.json();
}
export async function createFolder(
baseUrl: string,
path: string,
name: string,
): Promise<{ name: string; path: string }> {
const response = await fetch(`${baseUrl}/fs/mkdir`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, name }),
});
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { error?: string };
throw new Error(body.error ?? "Failed to create folder");
}
return response.json();
}
+161
View File
@@ -0,0 +1,161 @@
import type { LiveRuntimeInfo, PersistedSessionInfo } from "@/lib/types";
export type ProjectSummary = {
cwd: string;
name: string;
chatCount: number;
activeCount: number;
streamingCount: number;
lastActivity?: string;
};
export function basename(path: string) {
const trimmed = path.replace(/\/$/, "");
const parts = trimmed.split("/");
return parts[parts.length - 1] || path;
}
export function shortenPath(path: string) {
const parts = path.split("/").filter(Boolean);
if (parts.length <= 3) {
return path;
}
return `…/${parts.slice(-2).join("/")}`;
}
export function formatRelativeDate(value: string) {
const date = new Date(value);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHr = Math.floor(diffMs / 3_600_000);
const diffDay = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) {
return "Just now";
}
if (diffMin < 60) {
return `${diffMin}m ago`;
}
if (diffHr < 24) {
return `${diffHr}h ago`;
}
if (diffDay === 1) {
return "Yesterday";
}
if (diffDay < 7) {
return `${diffDay}d ago`;
}
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
}
export function formatMessageCount(count: number) {
return count === 1 ? "1 message" : `${count} messages`;
}
export function formatChatCount(count: number) {
return count === 1 ? "1 chat" : `${count} chats`;
}
export function chatTitle(lastMessage: string | undefined, sessionPath: string) {
const preview = lastMessage?.replace(/\s+/g, " ").trim();
return preview || basename(sessionPath) || "Untitled chat";
}
export function projectKey(cwd: string) {
return cwd || "(unknown)";
}
export function projectDisplayName(cwd: string) {
if (!cwd) {
return "Other sessions";
}
return basename(cwd);
}
function upsertProject(
projects: Map<string, ProjectSummary>,
cwd: string,
): ProjectSummary {
const key = projectKey(cwd);
let project = projects.get(key);
if (!project) {
project = {
cwd,
name: projectDisplayName(cwd),
chatCount: 0,
activeCount: 0,
streamingCount: 0,
};
projects.set(key, project);
}
return project;
}
export function groupByProject(
savedProjectCwds: string[],
liveRuntimes: LiveRuntimeInfo[],
persistedSessions: PersistedSessionInfo[],
): ProjectSummary[] {
const projects = new Map<string, ProjectSummary>();
for (const cwd of savedProjectCwds) {
upsertProject(projects, cwd);
}
for (const session of persistedSessions) {
const project = projects.get(projectKey(session.cwd));
if (!project) {
continue;
}
project.chatCount += 1;
if (!project.lastActivity || session.modified > project.lastActivity) {
project.lastActivity = session.modified;
}
}
for (const runtime of liveRuntimes) {
const project = projects.get(projectKey(runtime.cwd));
if (!project) {
continue;
}
project.activeCount += 1;
if (runtime.isStreaming) {
project.streamingCount += 1;
}
}
return savedProjectCwds
.map((cwd) => projects.get(projectKey(cwd)))
.filter((project): project is ProjectSummary => project !== undefined);
}
export function sessionsForProject(
cwd: string,
liveRuntimes: LiveRuntimeInfo[],
persistedSessions: PersistedSessionInfo[],
) {
const key = projectKey(cwd);
const live = liveRuntimes.filter((runtime) => projectKey(runtime.cwd) === key);
const persisted = persistedSessions
.filter((session) => projectKey(session.cwd) === key)
.sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
return { live, persisted };
}
export function projectMeta(project: ProjectSummary) {
const parts: string[] = [];
if (project.chatCount > 0) {
parts.push(formatChatCount(project.chatCount));
}
if (project.activeCount > 0) {
parts.push(
project.streamingCount > 0
? `${project.activeCount} active · working`
: `${project.activeCount} active`,
);
}
if (project.lastActivity) {
parts.push(formatRelativeDate(project.lastActivity));
}
return parts.join(" · ") || "No chats yet";
}
+42
View File
@@ -0,0 +1,42 @@
import { io, type Socket } from "socket.io-client";
import type {
ChatEvent,
ChatHistoryPayload,
LiveRuntimeInfo,
PersistedSessionInfo,
RuntimeCreatedPayload,
} from "./types";
export type SocketCallbacks = {
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (message: string) => void;
onSessionsList?: (payload: {
persisted: PersistedSessionInfo[];
live: LiveRuntimeInfo[];
}) => void;
onRuntimeCreated?: (payload: RuntimeCreatedPayload) => void;
onRuntimeOpened?: (payload: RuntimeCreatedPayload) => void;
onChatEvent?: (payload: ChatEvent) => void;
onChatHistory?: (payload: ChatHistoryPayload) => void;
};
export function createSocket(baseUrl: string, callbacks: SocketCallbacks): Socket {
const socket = io(baseUrl, {
transports: ["websocket"],
autoConnect: true,
});
socket.on("connect", () => callbacks.onConnect?.());
socket.on("disconnect", () => callbacks.onDisconnect?.());
socket.on("error", (payload: { message?: string }) => {
callbacks.onError?.(payload.message ?? "Socket error");
});
socket.on("sessions:list:result", (payload) => callbacks.onSessionsList?.(payload));
socket.on("runtime:created", (payload) => callbacks.onRuntimeCreated?.(payload));
socket.on("runtime:opened", (payload) => callbacks.onRuntimeOpened?.(payload));
socket.on("chat:event", (payload) => callbacks.onChatEvent?.(payload));
socket.on("chat:history", (payload) => callbacks.onChatHistory?.(payload));
return socket;
}
+46
View File
@@ -0,0 +1,46 @@
import { Platform } from "react-native";
export const colors = {
background: "#0a0a0a",
surface: "#171717",
surfaceDeep: "#101010",
border: "#262626",
borderSubtle: "#1c1c1c",
text: "#f5f5f5",
textSecondary: "#a3a3a3",
textMuted: "#737373",
accent: "#93c5fd",
success: "#4ade80",
warning: "#fbbf24",
error: "#f87171",
errorMuted: "#fca5a5",
errorBorder: "#7f1d1d",
userBubbleBg: "#f5f5f5",
userBubbleText: "#0a0a0a",
senderUser: "#4ade80",
senderAgent: "#93c5fd",
railUser: "rgba(74, 222, 128, 0.35)",
railAgent: "rgba(147, 197, 253, 0.25)",
} as const;
export const monoFont = Platform.select({
ios: "Menlo",
default: "monospace",
});
export const projectTints = [
{ bg: "rgba(147, 197, 253, 0.14)", fg: "#93c5fd" },
{ bg: "rgba(74, 222, 128, 0.14)", fg: "#4ade80" },
{ bg: "rgba(251, 191, 36, 0.14)", fg: "#fbbf24" },
{ bg: "rgba(196, 181, 253, 0.14)", fg: "#c4b5fd" },
{ bg: "rgba(103, 232, 249, 0.14)", fg: "#67e8f9" },
{ bg: "rgba(253, 164, 175, 0.14)", fg: "#fda4af" },
] as const;
export function tintFor(name: string) {
let hash = 0;
for (let i = 0; i < name.length; i += 1) {
hash = (hash * 31 + name.charCodeAt(i)) | 0;
}
return projectTints[Math.abs(hash) % projectTints.length];
}
+106
View File
@@ -0,0 +1,106 @@
const PREVIEW_KEYS = [
"command",
"cmd",
"path",
"file_path",
"filePath",
"pattern",
"query",
"url",
] as const;
const MAX_PREVIEW_LENGTH = 160;
const MAX_DETAIL_LENGTH = 6000;
/** Single-line summary of tool call arguments, e.g. the bash command or file path. */
export function toolArgsPreview(args: unknown): string | undefined {
if (!args || typeof args !== "object" || Array.isArray(args)) {
return undefined;
}
const record = args as Record<string, unknown>;
let preview: string | undefined;
for (const key of PREVIEW_KEYS) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
preview = value.trim();
break;
}
}
if (!preview) {
const entries = Object.entries(record).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return undefined;
}
preview = entries
.map(([key, value]) => `${key}: ${typeof value === "string" ? value : safeStringify(value)}`)
.join(", ");
}
const lines = preview.split("\n");
let line = lines[0].trim();
if (lines.length > 1) {
line += " …";
}
return line.length > MAX_PREVIEW_LENGTH ? `${line.slice(0, MAX_PREVIEW_LENGTH)}…` : line;
}
/** Pulls readable text out of a tool result / partial result of unknown shape. */
export function extractResultText(result: unknown): string {
if (result == null) {
return "";
}
if (typeof result === "string") {
return result;
}
if (Array.isArray(result)) {
return result.map(blockText).join("");
}
if (typeof result === "object") {
const record = result as Record<string, unknown>;
if (Array.isArray(record.content)) {
return record.content.map(blockText).join("");
}
if (typeof record.output === "string") {
return record.output;
}
if (typeof record.text === "string") {
return record.text;
}
return safeStringify(record);
}
return String(result);
}
/** Caps tool output length so a huge result doesn't bloat list state. */
export function truncateDetail(text: string): string {
const trimmed = text.trim();
if (trimmed.length <= MAX_DETAIL_LENGTH) {
return trimmed;
}
return `${trimmed.slice(0, MAX_DETAIL_LENGTH)}\n… (output truncated)`;
}
function blockText(block: unknown): string {
if (typeof block === "string") {
return block;
}
if (typeof block === "object" && block && "text" in block) {
return String((block as { text?: string }).text ?? "");
}
return "";
}
function safeStringify(value: unknown): string {
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
+89
View File
@@ -0,0 +1,89 @@
export type LiveRuntimeInfo = {
runtimeId: string;
sessionId: string;
cwd: string;
sessionName?: string;
sessionFile?: string;
lastMessage?: string;
isStreaming: boolean;
};
export type PersistedSessionInfo = {
id: string;
path: string;
cwd: string;
name?: string;
lastMessage?: string;
modified: string;
messageCount: number;
};
export type FolderEntry = {
name: string;
path: string;
};
export type FolderListResult = {
path: string;
parent: string | null;
entries: FolderEntry[];
};
export type RuntimeCreatedPayload = {
runtimeId: string;
sessionId: string;
cwd: string;
sessionName?: string;
sessionFile?: string;
lastMessage?: string;
};
export type ChatEvent = {
runtimeId: string;
event: {
type: string;
[key: string]: unknown;
};
};
export type ChatHistoryMessage =
| { role: "user"; content: string }
| { role: "assistant"; content: string }
| {
role: "tool";
toolCallId: string;
toolName: string;
args?: Record<string, unknown>;
content: string;
isError: boolean;
};
export type ChatHistoryPayload = {
runtimeId: string;
messages: ChatHistoryMessage[];
};
export type ChatItem =
| { id: string; kind: "user"; text: string }
| { id: string; kind: "assistant"; text: string; streaming?: boolean }
| {
id: string;
kind: "tool";
toolName: string;
status: "running" | "done" | "error";
argsPreview?: string;
detail?: string;
};
export type HostConfig = {
host: string;
port: string;
};
export type SavedComputer = {
id: string;
name: string;
host: string;
port: string;
lastUsedAt: number;
};
+20
View File
@@ -0,0 +1,20 @@
const path = require("path");
const { getDefaultConfig } = require("expo/metro-config");
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
config.watchFolders = [workspaceRoot];
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
config.resolver.disableHierarchicalLookup = true;
config.resolver.extraNodeModules = {
react: path.resolve(workspaceRoot, "node_modules/react"),
"react-dom": path.resolve(workspaceRoot, "node_modules/react-dom"),
};
module.exports = config;
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@pi-mobile/mobile",
"version": "0.1.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
"@ronradtke/react-native-markdown-display": "^9.0.3",
"expo": "^54.0.0",
"expo-constants": "~18.0.13",
"expo-linking": "~8.0.12",
"expo-router": "~6.0.24",
"expo-status-bar": "~3.0.9",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"socket.io-client": "^4.8.1"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@types/react": "~19.1.10",
"typescript": "^5.7.2"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../node_modules/expo/tsconfig.base.json",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}
+12549
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "pi-mobile",
"private": true,
"workspaces": [
"apps/*"
],
"scripts": {
"dev:api": "npm run dev -w @pi-mobile/api",
"dev:mobile": "npm run start -w @pi-mobile/mobile",
"build:api": "npm run build -w @pi-mobile/api"
},
"overrides": {
"react": "19.1.0",
"react-dom": "19.1.0"
}
}