Skip to content
Merged
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
21 changes: 17 additions & 4 deletions apps/penpal/frontend/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,24 @@ pub fn run() {
// Store the child so we can kill it on quit
*app.state::<Sidecar>().0.lock().unwrap() = Some(child);

// Wait for server to be ready
// Wait for server to be fully ready (projects discovered and files scanned).
// The /api/ready endpoint blocks until initialization is complete.
let addr = format!("127.0.0.1:{}", port);
for _ in 0..50 {
if std::net::TcpStream::connect(&addr).is_ok() {
break;
for _ in 0..300 {
if let Ok(mut stream) = std::net::TcpStream::connect(&addr) {
use std::io::{Read, Write};
let req = format!("GET /api/ready HTTP/1.0\r\nHost: {}\r\n\r\n", addr);
if stream.write_all(req.as_bytes()).is_ok() {
// Set a generous timeout — initialization may take a while
stream.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
let mut buf = [0u8; 256];
if let Ok(n) = stream.read(&mut buf) {
let resp = String::from_utf8_lossy(&buf[..n]);
if resp.contains("200") {
break;
}
}
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
Expand Down
26 changes: 15 additions & 11 deletions apps/penpal/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ async function apiVoid(path: string, options?: RequestInit): Promise<void> {
if (!res.ok) throw new Error(`API error: ${res.status}`);
}

function wtParam(worktree?: string): string {
return worktree ? `&worktree=${encodeURIComponent(worktree)}` : '';
}

export const api = {
// Projects
listProjects: () => apiFetch<APIProject[]>('/api/projects'),
Expand All @@ -47,8 +51,8 @@ export const api = {
apiVoid('/api/projects', { method: 'DELETE', body: JSON.stringify({ path }) }),

// Project files
getProjectFiles: (qn: string) =>
apiFetch<APIFileGroupView[]>(`/api/project/${qn}`),
getProjectFiles: (qn: string, worktree?: string) =>
apiFetch<APIFileGroupView[]>(`/api/project/${qn}${worktree ? '?worktree=' + encodeURIComponent(worktree) : ''}`),
getProjectInfo: (name: string) =>
apiFetch<ProjectInfo>(`/api/project-info?name=${encodeURIComponent(name)}`),
deleteProject: (project: string) =>
Expand All @@ -61,30 +65,30 @@ export const api = {
getInReview: () => apiFetch<ReviewGroup[]>('/api/in-review'),

// Threads
getThreads: (project: string, file: string) =>
apiFetch<ThreadResponse[]>(`/api/threads?project=${encodeURIComponent(project)}&path=${encodeURIComponent(file)}`),
getThreads: (project: string, file: string, worktree?: string) =>
apiFetch<ThreadResponse[]>(`/api/threads?project=${encodeURIComponent(project)}&path=${encodeURIComponent(file)}${wtParam(worktree)}`),
getAllThreads: (project: string) =>
apiFetch<ThreadWithFile[]>(`/api/threads?project=${encodeURIComponent(project)}`),
createThread: (data: CreateThreadReq) =>
createThread: (data: CreateThreadReq & { worktree?: string }) =>
apiFetch<Thread>('/api/threads', { method: 'POST', body: JSON.stringify(data) }),
replyToThread: (id: string, data: ReplyReq) =>
replyToThread: (id: string, data: ReplyReq & { worktree?: string }) =>
apiFetch<Thread>(`/api/threads/${encodeURIComponent(id)}/comments`, {
method: 'POST',
body: JSON.stringify(data),
}),
patchThread: (id: string, data: PatchThreadReq) =>
patchThread: (id: string, data: PatchThreadReq & { worktree?: string }) =>
apiFetch<{ ok: boolean }>(`/api/threads/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(data),
}),

// Reviews
getReviews: (project: string) =>
apiFetch<APIFileInReview[]>(`/api/reviews?project=${encodeURIComponent(project)}`),
getReviews: (project: string, worktree?: string) =>
apiFetch<APIFileInReview[]>(`/api/reviews?project=${encodeURIComponent(project)}${wtParam(worktree)}`),

// Raw file content
getRawFile: (project: string, path: string) =>
fetch(`${API_BASE}/api/raw?project=${encodeURIComponent(project)}&path=${encodeURIComponent(path)}`, {
getRawFile: (project: string, path: string, worktree?: string) =>
fetch(`${API_BASE}/api/raw?project=${encodeURIComponent(project)}&path=${encodeURIComponent(path)}${wtParam(worktree)}`, {
cache: 'no-store',
}).then(
(r) => {
Expand Down
21 changes: 17 additions & 4 deletions apps/penpal/frontend/src/components/CommentsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface CommentsPanelProps {
threads: ThreadResponse[];
anchorLines: Record<string, number>;
project: string;
worktree?: string;
filePath: string;
onRefresh: () => void;
onThreadFocus?: (threadId: string, line: number) => void;
Expand Down Expand Up @@ -45,6 +46,7 @@ export default function CommentsPanel({
threads,
anchorLines,
project,
worktree,
filePath,
onRefresh,
onThreadFocus,
Expand Down Expand Up @@ -147,6 +149,7 @@ export default function CommentsPanel({
anchor={pendingAnchor}
selectedText={pendingText || ''}
project={project}
worktree={worktree}
filePath={filePath}
onSubmit={() => {
onCancelNewThread?.();
Expand All @@ -170,6 +173,7 @@ export default function CommentsPanel({
isHighlighted={highlightedThread === t.id}
onClick={() => handleThreadClick(t.id)}
project={project}
worktree={worktree}
filePath={filePath}
onRefresh={onRefresh}
replyOpen={replyFormThread === t.id}
Expand All @@ -188,6 +192,7 @@ export default function CommentsPanel({
isHighlighted={highlightedThread === t.id}
onClick={() => handleThreadClick(t.id)}
project={project}
worktree={worktree}
filePath={filePath}
onRefresh={onRefresh}
replyOpen={replyFormThread === t.id}
Expand Down Expand Up @@ -227,6 +232,7 @@ interface NewThreadFormProps {
anchor: Anchor;
selectedText: string;
project: string;
worktree?: string;
filePath: string;
onSubmit: () => void;
onCancel: () => void;
Expand All @@ -237,6 +243,7 @@ function NewThreadForm({
anchor,
selectedText,
project,
worktree,
filePath,
onSubmit,
onCancel,
Expand All @@ -263,6 +270,7 @@ function NewThreadForm({
author: author.trim(),
role: 'human',
body: body.trim(),
worktree: worktree || undefined,
});
onSubmit();
} catch (err) {
Expand Down Expand Up @@ -324,6 +332,7 @@ interface ThreadCardProps {
isHighlighted: boolean;
onClick: () => void;
project: string;
worktree?: string;
filePath: string;
onRefresh: () => void;
replyOpen: boolean;
Expand All @@ -338,6 +347,7 @@ function ThreadCard({
isHighlighted,
onClick,
project,
worktree,
filePath,
onRefresh,
replyOpen,
Expand All @@ -353,14 +363,14 @@ function ThreadCard({
const handleResolve = (e: React.MouseEvent) => {
e.stopPropagation();
const author = getSavedAuthor() || 'anonymous';
api.patchThread(thread.id, { project, path: filePath, status: 'resolved', resolvedBy: author })
api.patchThread(thread.id, { project, path: filePath, status: 'resolved', resolvedBy: author, worktree: worktree || undefined })
.then(onRefresh)
.catch((err) => console.error('Failed to resolve:', err));
};

const handleReopen = (e: React.MouseEvent) => {
e.stopPropagation();
api.patchThread(thread.id, { project, path: filePath, status: 'open' })
api.patchThread(thread.id, { project, path: filePath, status: 'open', worktree: worktree || undefined })
.then(onRefresh)
.catch((err) => console.error('Failed to reopen:', err));
};
Expand All @@ -371,7 +381,7 @@ function ThreadCard({
onToggleReply();
return;
}
api.replyToThread(thread.id, { project, path: filePath, author, role: 'human', body: text })
api.replyToThread(thread.id, { project, path: filePath, author, role: 'human', body: text, worktree: worktree || undefined })
.then(onRefresh)
.catch((err) => console.error('Failed to submit suggested reply:', err));
};
Expand Down Expand Up @@ -487,6 +497,7 @@ function ThreadCard({
<ReplyForm
threadId={thread.id}
project={project}
worktree={worktree}
filePath={filePath}
onSubmit={() => {
onToggleReply();
Expand All @@ -504,12 +515,13 @@ function ThreadCard({
interface ReplyFormProps {
threadId: string;
project: string;
worktree?: string;
filePath: string;
onSubmit: () => void;
onCancel: () => void;
}

function ReplyForm({ threadId, project, filePath, onSubmit, onCancel }: ReplyFormProps) {
function ReplyForm({ threadId, project, worktree, filePath, onSubmit, onCancel }: ReplyFormProps) {
const [body, setBody] = useState('');
const [author, setAuthor] = useState(getSavedAuthor());
const [submitting, setSubmitting] = useState(false);
Expand All @@ -530,6 +542,7 @@ function ReplyForm({ threadId, project, filePath, onSubmit, onCancel }: ReplyFor
author: author.trim(),
role: 'human',
body: body.trim(),
worktree: worktree || undefined,
});
onSubmit();
} catch (err) {
Expand Down
91 changes: 72 additions & 19 deletions apps/penpal/frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import FindBar from './FindBar';
import InstallToolsModal from './InstallToolsModal';
import type { Heading } from './TableOfContents';
import type { APIProject, SSEEvent } from '../types';
import { parseProjectWorktree } from '../utils/worktree';

export interface LayoutContext {
setHeadings: (headings: Heading[]) => void;
Expand Down Expand Up @@ -252,12 +253,30 @@ export default function Layout() {
return () => window.removeEventListener('keydown', handleKeyDown);
}, [goBack, goForward, canGoBack, canGoForward]);

// Detect project-mode view: /project/:qn or /file/:qn/*
// Detect project-mode view: /project/:qn[@worktree] or /file/:qn[@worktree]/*
// QN may contain slashes (e.g. "Development/birdseye"), so match against known projects
const pathAfterPrefix = location.pathname.match(/^\/(project|file)\/(.+)/)?.[2] || '';
const activeProject = pathAfterPrefix
? projects.find((p) => pathAfterPrefix === p.qualifiedName || pathAfterPrefix.startsWith(p.qualifiedName + '/'))
: null;
const { activeProject, activeWorktree } = (() => {
if (!pathAfterPrefix) return { activeProject: null, activeWorktree: '' };
// Strip @worktree suffix before matching against project QNs
const sorted = [...projects].sort((a, b) => b.qualifiedName.length - a.qualifiedName.length);
for (const p of sorted) {
// Check for exact match or prefix match (with / or @ following)
if (
pathAfterPrefix === p.qualifiedName ||
pathAfterPrefix.startsWith(p.qualifiedName + '/') ||
pathAfterPrefix.startsWith(p.qualifiedName + '@')
) {
const rest = pathAfterPrefix.slice(p.qualifiedName.length);
const { worktree } = parseProjectWorktree(p.qualifiedName + rest.split('/')[0]);
return { activeProject: p, activeWorktree: worktree };
}
}
// Fallback: try parsing with @
const parsed = parseProjectWorktree(pathAfterPrefix.split('/').slice(0, 2).join('/'));
const fallbackProject = projects.find((p) => p.qualifiedName === parsed.project) || null;
return { activeProject: fallbackProject, activeWorktree: parsed.worktree };
})();
// Show project-mode sidebar as soon as URL matches, even before projects load
const isProjectMode = !!pathAfterPrefix;

Expand Down Expand Up @@ -468,30 +487,64 @@ export default function Layout() {
.some((p) => p.agentConnected) && <span className="agent-dot" />}
</NavLink>
)}
{activeProject && (
{activeProject && activeProject.worktrees && activeProject.worktrees.length > 1 ? (
<>
{activeProject.worktrees.map((wt) => {
const isActive = wt.isMain ? !activeWorktree : activeWorktree === wt.name;
const url = wt.isMain
? `/project/${activeProject.qualifiedName}`
: `/project/${activeProject.qualifiedName}@${wt.name}`;
return (
<NavLink
key={wt.name}
to={url}
className={`sidebar-item subitem worktree-item${isActive ? ' active' : ''}`}
>
<span className="worktree-name">
{wt.isMain ? activeProject.name : wt.name}
{wt.isMain && activeProject.badges.map((b) => (
<span
key={b.text}
className="source-badge"
style={{ '--badge-bg': b.bg, '--badge-color': b.color, '--badge-active-bg': b.activeBg || b.bg, '--badge-active-color': b.activeColor || b.color } as React.CSSProperties}
>
{b.text}
</span>
))}
</span>
{wt.branch && (
<span className="branch-name">{wt.branch}</span>
)}
</NavLink>
);
})}
</>
) : activeProject ? (
<NavLink
to={`/project/${activeProject.qualifiedName}`}
className="sidebar-item subitem active"
className="sidebar-item subitem worktree-item active"
>
{activeProject.name}
{activeProject.badges.map((b) => (
<span
key={b.text}
className="source-badge"
style={{ '--badge-bg': b.bg, '--badge-color': b.color, '--badge-active-bg': b.activeBg || b.bg, '--badge-active-color': b.activeColor || b.color } as React.CSSProperties}
>
{b.text}
</span>
))}
{activeProject.agentConnected && <span className="agent-dot" />}
<span className="worktree-name">
{activeProject.name}
{activeProject.badges.map((b) => (
<span
key={b.text}
className="source-badge"
style={{ '--badge-bg': b.bg, '--badge-color': b.color, '--badge-active-bg': b.activeBg || b.bg, '--badge-active-color': b.activeColor || b.color } as React.CSSProperties}
>
{b.text}
</span>
))}
{activeProject.agentConnected && <span className="agent-dot" />}
</span>
{activeProject.branch && (
<span className="branch-info">
<span className="branch-name">
{activeProject.branch}
{activeProject.dirty && <span className="branch-dirty">*</span>}
</span>
)}
</NavLink>
)}
) : null}
</>
) : (
<>
Expand Down
19 changes: 11 additions & 8 deletions apps/penpal/frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,10 @@ a:hover { text-decoration: underline; }
background: var(--badge-active-bg);
color: var(--badge-active-color);
}
.sidebar-item .branch-info {
font-size: 0.8em;
color: var(--accent-success);
flex-shrink: 0;
margin-left: auto;
}
.sidebar-item .branch-dirty { color: var(--accent-danger); }
.sidebar-item.worktree-item { flex-direction: column; align-items: flex-start; gap: 1px; }
.sidebar-item.worktree-item .worktree-name { display: flex; align-items: center; gap: 6px; }
.sidebar-item.worktree-item .branch-name { font-size: 0.85em; color: var(--accent-success); opacity: 0.8; }
.sidebar-item.worktree-item .branch-dirty { color: var(--accent-danger); }
.agent-dot {
display: inline-block;
width: 7px;
Expand Down Expand Up @@ -1179,9 +1176,15 @@ a:hover { text-decoration: underline; }
}
.project-card-name a { text-decoration: none; }
.project-card-name a:hover { text-decoration: none; }
.project-card-meta { font-size: 0.8em; color: var(--text-subtle); margin-bottom: 8px; }
.project-card-meta { font-size: 0.8em; color: var(--text-subtle); margin-bottom: 8px; display: flex; flex-wrap: wrap; gap: 4px 8px; }
.project-card-meta .branch { color: var(--accent-success); }
.project-card-meta .dirty { color: var(--accent-danger); }
.project-card-worktrees { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.worktree-card-item { display: flex; flex-direction: column; text-decoration: none; padding: 4px 8px; border-radius: 4px; background: var(--bg-surface-tertiary); min-width: 0; }
.worktree-card-item:hover { background: var(--bg-surface-hover); }
.worktree-card-name { font-size: 0.85em; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.worktree-card-branch { font-size: 0.78em; color: var(--accent-success); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.worktree-card-branch .dirty { color: var(--accent-danger); }
.project-card .project-age { font-size: 0.8em; color: var(--text-faint); }
.project-card-name .agent-dot {
margin-left: 8px;
Expand Down
Loading