Inital commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.expo/
|
||||
.expo-shared/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.local
|
||||
@@ -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/
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ["babel-preset-expo"],
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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 "";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Generated
+12549
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user