Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.factory
.factory

# Playwright MCP test artifacts
.playwright-mcp/
7 changes: 4 additions & 3 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata, Viewport } from "next";
import { Noto_Sans_Mono } from "next/font/google";
import { PwaRegistration } from "@/components/PwaRegistration";
import { withBasePath } from "@/lib/base-path";
import "katex/dist/katex.min.css";
import "./globals.css";

Expand All @@ -14,18 +15,18 @@ export const metadata: Metadata = {
title: "Pi Web",
description: "Pi Web interface for the pi coding agent",
applicationName: "Pi Web",
manifest: "/manifest.webmanifest",
manifest: withBasePath("/manifest.webmanifest"),
icons: {
icon: [
{
url: "/icons/icon-192.png",
url: withBasePath("/icons/icon-192.png"),
sizes: "192x192",
type: "image/png",
},
],
apple: [
{
url: "/icons/apple-touch-icon.png",
url: withBasePath("/icons/apple-touch-icon.png"),
sizes: "180x180",
type: "image/png",
},
Expand Down
11 changes: 6 additions & 5 deletions app/manifest.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { MetadataRoute } from "next";
import { withBasePath } from "@/lib/base-path";

export default function manifest(): MetadataRoute.Manifest {
return {
id: "/",
id: withBasePath("/"),
name: "Pi Web",
short_name: "Pi Web",
description: "Local web interface for the pi coding agent",
start_url: "/",
scope: "/",
start_url: withBasePath("/"),
scope: withBasePath("/"),
display: "standalone",
background_color: "#1a1a1a",
theme_color: "#1a1a1a",
Expand All @@ -16,13 +17,13 @@ export default function manifest(): MetadataRoute.Manifest {
lang: "en",
icons: [
{
src: "/icons/icon-192.png",
src: withBasePath("/icons/icon-192.png"),
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-512.png",
src: withBasePath("/icons/icon-512.png"),
sizes: "512x512",
type: "image/png",
purpose: "any",
Expand Down
12 changes: 7 additions & 5 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useState, useCallback, useRef, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { apiUrl } from "@/lib/base-path";

import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
import { SessionSidebar } from "./SessionSidebar";
import { ChatWindow } from "./ChatWindow";
Expand Down Expand Up @@ -277,7 +279,7 @@ export function AppShell() {
setInitialCwdStatus("validating");
setInitialCwdError(null);

void fetch("/api/cwd/validate", {
void fetch(apiUrl("/api/cwd/validate"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd: requestedCwd }),
Expand Down Expand Up @@ -391,7 +393,7 @@ export function AppShell() {
// handleCwdChange relies on. Hydrate it from the session list so switching
// worktrees right after creating a session doesn't close the chat.
const hydrateSelectedSession = useCallback((sessionId: string) => {
void fetch("/api/sessions")
void fetch(apiUrl("/api/sessions"))
.then((r) => (r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null))
.then((d) => {
const full = d?.sessions.find((s) => s.id === sessionId);
Expand Down Expand Up @@ -423,7 +425,7 @@ export function AppShell() {
setAutoNameStatus({ kind: "naming" });

try {
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/auto-name`, {
const response = await fetch(apiUrl(`/api/sessions/${encodeURIComponent(sessionId)}/auto-name`), {
method: "POST",
});
const body = (await response.json().catch(() => ({}))) as { title?: string; error?: string };
Expand Down Expand Up @@ -562,7 +564,7 @@ export function AppShell() {
if (!projectTrustCwd) return;

const controller = new AbortController();
fetch(`/api/project-trust?cwd=${encodeURIComponent(projectTrustCwd)}`, {
fetch(apiUrl(`/api/project-trust?cwd=${encodeURIComponent(projectTrustCwd)}`), {
signal: controller.signal,
})
.then(async (response) => {
Expand All @@ -582,7 +584,7 @@ export function AppShell() {
setProjectTrustBusy(true);
setProjectTrustError(null);
try {
const response = await fetch("/api/project-trust", {
const response = await fetch(apiUrl("/api/project-trust"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ cwd: projectTrustCwd }),
Expand Down
8 changes: 5 additions & 3 deletions components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import React, { useRef, useState, useCallback, useEffect, useImperativeHandle, f
import type { BuiltinSlashCommandResult, CompactResultInfo, QueuedMessages, SlashCommandInfo } from "@/hooks/useAgentSession";
import type { SkillsResponse } from "@/lib/api-types";
import { clearDraft, getDraft, setDraft, type ChatDraftImage } from "@/lib/draft-store";
import { apiUrl } from "@/lib/base-path";

import {
MAX_ATTACHED_IMAGE_BYTES,
MAX_ATTACHED_IMAGES,
Expand Down Expand Up @@ -625,7 +627,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
const fetchCwd = cwd;
const query = atQueryText;
const timer = setTimeout(() => {
fetch(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}&q=${encodeURIComponent(query)}`)
fetch(apiUrl(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}&q=${encodeURIComponent(query)}`))
.then((res) => {
if (!res.ok) throw new Error(`file search failed: ${res.status}`);
return res.json() as Promise<{ matches?: FileIndexEntry[] }>;
Expand Down Expand Up @@ -668,7 +670,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
fileIndexFetchingRef.current = cwd;
const fetchCwd = cwd;
setFileIndexLoading(true);
fetch(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}`)
fetch(apiUrl(`/api/file-index?cwd=${encodeURIComponent(fetchCwd)}`))
.then((res) => {
if (!res.ok) throw new Error(`file index failed: ${res.status}`);
return res.json() as Promise<{ files?: string[]; truncated?: boolean }>;
Expand Down Expand Up @@ -1004,7 +1006,7 @@ export const ChatInput = forwardRef<ChatInputHandle, Props>(function ChatInput({
const requestCwd = cwd;
let cancelled = false;
setSkillDormancyState({ cwd: requestCwd, values: {} });
fetch(`/api/skills?cwd=${encodeURIComponent(requestCwd)}`)
fetch(apiUrl(`/api/skills?cwd=${encodeURIComponent(requestCwd)}`))
.then((res) => {
if (!res.ok) throw new Error(`skills fetch failed: ${res.status}`);
return res.json() as Promise<Partial<SkillsResponse>>;
Expand Down
4 changes: 3 additions & 1 deletion components/DirectoryPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { FormEvent, useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "@/hooks/useI18n";
import { apiUrl } from "@/lib/base-path";


interface DirectoryEntry {
name: string;
Expand All @@ -19,7 +21,7 @@ interface BrowseResponse {

async function loadDirectories(directory?: string): Promise<BrowseResponse> {
const query = directory ? `?path=${encodeURIComponent(directory)}` : "";
const response = await fetch(`/api/cwd/browse${query}`);
const response = await fetch(apiUrl(`/api/cwd/browse${query}`));
const data = await response.json() as BrowseResponse;
if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`);
return data;
Expand Down
6 changes: 4 additions & 2 deletions components/FileExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { forwardRef, useState, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from "react";
import { getFileIcon, FolderIcon } from "./FileIcons";
import { apiUrl } from "@/lib/base-path";

import {
encodeFilePathForApi,
getFileDirectory,
Expand Down Expand Up @@ -76,7 +78,7 @@ interface PendingConflict {

async function fetchEntries(dirPath: string): Promise<FileNode[]> {
const encoded = encodeFilePathForApi(dirPath);
const res = await fetch(`/api/files/${encoded}?type=list`);
const res = await fetch(apiUrl(`/api/files/${encoded}?type=list`));
if (!res.ok) {
let message = `Failed to load files (HTTP ${res.status})`;
try {
Expand All @@ -100,7 +102,7 @@ async function fetchEntries(dirPath: string): Promise<FileNode[]> {

async function fetchGitStatus(cwd: string): Promise<GitStatusResponse> {
const params = new URLSearchParams({ cwd });
const res = await fetch(`/api/git/status?${params.toString()}`);
const res = await fetch(apiUrl(`/api/git/status?${params.toString()}`));
if (!res.ok) throw new Error(`Failed to load Git status (HTTP ${res.status})`);
return res.json() as Promise<GitStatusResponse>;
}
Expand Down
6 changes: 4 additions & 2 deletions components/FileViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"use client";

import { useEffect, useState, useRef, useCallback, useMemo, type CSSProperties, type MouseEvent } from "react";
import { apiUrl } from "@/lib/base-path";

import {
Prism as SyntaxHighlighter,
createElement as renderSyntaxNode,
Expand Down Expand Up @@ -204,7 +206,7 @@ function getFileApiUrl(
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) searchParams.set(key, String(value));
}
return `/api/files/${encoded}?${searchParams.toString()}`;
return apiUrl(`/api/files/${encoded}?${searchParams.toString()}`);
}

function DownloadLink({ filePath, sourceSessionId }: { filePath: string; sourceSessionId?: string | null }) {
Expand Down Expand Up @@ -841,7 +843,7 @@ function TextFileViewer({ filePath, cwd, sourceSessionId, onOpenFile, onMentionL

try {
const params = new URLSearchParams({ cwd, path: targetPath });
const response = await fetch(`/api/git/diff?${params.toString()}`);
const response = await fetch(apiUrl(`/api/git/diff?${params.toString()}`));
const next = await response.json() as GitFileDiffResponse & { error?: string };
if (requestId !== gitDiffRequestRef.current) return;
setGitDiff(response.ok && next.supported && typeof next.patch === "string" ? next : null);
Expand Down
66 changes: 64 additions & 2 deletions components/MessageView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,68 @@ import type {
const MAX_THINKING_CACHE_ENTRIES = 100;
const thinkingContentCache = new Map<string, Promise<string>>();

// Messages larger than this skip markdown rendering entirely. react-markdown +
// KaTeX + syntax highlighting on multi-hundred-KB payloads (e.g. pasted HAR or
// log dumps) freezes the browser main thread.
const MAX_MARKDOWN_CHARS = 100_000;

function formatMessageBytes(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`;
if (n >= 1_000) return `${Math.round(n / 1_000)} KB`;
return `${n} B`;
}

/**
* MarkdownBody with an oversized-content guard: huge messages render as a
* click-to-reveal plain-text <pre> instead of running the markdown pipeline.
*/
function SafeMarkdownBody({ children, className, ...props }: React.ComponentProps<typeof MarkdownBody>) {
const { t } = useI18n();
const [showRaw, setShowRaw] = useState(false);

if (children.length <= MAX_MARKDOWN_CHARS) {
return <MarkdownBody className={className} {...props}>{children}</MarkdownBody>;
}
if (!showRaw) {
return (
<button
onClick={() => setShowRaw(true)}
style={{
display: "block",
width: "100%",
margin: "4px 0",
padding: "7px 10px",
border: "1px solid var(--border)",
borderRadius: 6,
background: "var(--bg-panel)",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 12,
textAlign: "left",
}}
>
⚠ {t("i18n.largeMessageReveal", { size: formatMessageBytes(children.length) })}
</button>
);
}
return (
<div className={className} style={{ maxHeight: 420, overflow: "auto", fontSize: 12, lineHeight: 1.5 }}>
<pre
style={{
margin: 0,
padding: "8px 10px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "var(--font-mono)",
color: "var(--text-muted)",
}}
>
{children}
</pre>
</div>
);
}

function loadThinkingContent(sessionId: string, entryId: string, blockIndex: number): Promise<string> {
const key = `${sessionId}:${entryId}:${blockIndex}`;
const cached = thinkingContentCache.get(key);
Expand Down Expand Up @@ -222,7 +284,7 @@ function UserMessageView({ message, cwd, onOpenFile, entryId, onFork, forking, o
})}
</div>
)}
{content && <MarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</MarkdownBody>}
{content && <SafeMarkdownBody className="markdown-user-message" cwd={cwd} onOpenFile={onOpenFile}>{content}</SafeMarkdownBody>}
</div>

</div>
Expand Down Expand Up @@ -620,7 +682,7 @@ function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCal
}

function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) {
return <MarkdownBody isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile}>{block.text}</MarkdownBody>;
return <SafeMarkdownBody isStreaming={isStreaming} cwd={cwd} onOpenFile={onOpenFile}>{block.text}</SafeMarkdownBody>;
}

function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: {
Expand Down
Loading