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
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ vi.mock('./api', () => ({
listProjects: vi.fn().mockResolvedValue([]),
getRecentFiles: vi.fn().mockResolvedValue([]),
getInReview: vi.fn().mockResolvedValue([]),
clearFocus: vi.fn().mockResolvedValue(undefined),
},
}));

Expand Down
8 changes: 8 additions & 0 deletions apps/penpal/frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ export const api = {
installTools: () =>
apiFetch<InstallToolsStatus>('/api/install-tools', { method: 'POST' }),

// Focus (tells server what to deep-watch for the current view)
focusProject: (project: string) =>
apiVoid(`/api/focus?project=${encodeURIComponent(project)}`, { method: 'POST' }),
focusFile: (project: string, path: string, worktree?: string) =>
apiVoid(`/api/focus?project=${encodeURIComponent(project)}&path=${encodeURIComponent(path)}${wtParam(worktree)}`, { method: 'POST' }),
clearFocus: () =>
apiVoid('/api/focus', { method: 'DELETE' }),

// Misc
open: (path: string) =>
apiFetch<{ url: string }>('/api/open', { method: 'POST', body: JSON.stringify({ path }) }),
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/components/Layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ vi.mock('../api', () => ({
},
]),
getInReview: vi.fn().mockResolvedValue([]),
clearFocus: vi.fn().mockResolvedValue(undefined),
},
}));

Expand Down
2 changes: 2 additions & 0 deletions apps/penpal/frontend/src/pages/FilePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ vi.mock('../api', () => ({
startAgent: vi.fn().mockResolvedValue({ running: true, project: 'ws/proj' }),
getPublishState: vi.fn().mockResolvedValue({}),
recordView: vi.fn().mockResolvedValue(undefined),
focusProject: vi.fn().mockResolvedValue(undefined),
focusFile: vi.fn().mockResolvedValue(undefined),
},
}));

Expand Down
5 changes: 5 additions & 0 deletions apps/penpal/frontend/src/pages/FilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ export default function FilePage() {
}).catch(() => {});
}, [project, path, worktree]);

// Tell the server to watch just this file's directory
useEffect(() => {
if (project && path) api.focusFile(project, path, worktree || undefined).catch(() => {});
}, [project, path, worktree]);

// Initial data load
useEffect(() => {
fetchContent();
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/pages/InReviewPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock('../api', () => ({
},
]),
listProjects: vi.fn().mockResolvedValue([]),
clearFocus: vi.fn().mockResolvedValue(undefined),
},
API_BASE: 'http://localhost:8080',
isDesktopApp: false,
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/pages/InReviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export default function InReviewPage() {
api.getInReview().then(setGroups).catch(() => {});
}, []);

useEffect(() => { api.clearFocus().catch(() => {}); }, []);
useEffect(() => { refresh(); }, [refresh]);

const debouncedRefresh = useCallback(() => debounce(refresh, 200)(), [refresh]);
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/pages/ProjectPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock('../api', () => ({
getAgentStatus: vi.fn().mockResolvedValue({ running: false }),
listProjects: vi.fn().mockResolvedValue([{ qualifiedName: 'ws/proj' }]),
getInReview: vi.fn().mockResolvedValue([]),
focusProject: vi.fn().mockResolvedValue(undefined),
},
API_BASE: 'http://localhost:8080',
isDesktopApp: false,
Expand Down
5 changes: 5 additions & 0 deletions apps/penpal/frontend/src/pages/ProjectPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export default function ProjectPage() {
}).catch(() => {});
}, [qn]);

// Tell the server to deep-watch this project's files
useEffect(() => {
if (qn) api.focusProject(qn).catch(() => {});
}, [qn]);

useEffect(() => {
refreshFiles();
refreshReviews();
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/pages/RecentPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ vi.mock('../api', () => ({
]),
getInReview: vi.fn().mockResolvedValue([]),
listProjects: vi.fn().mockResolvedValue([]),
clearFocus: vi.fn().mockResolvedValue(undefined),
},
API_BASE: 'http://localhost:8080',
isDesktopApp: false,
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/pages/RecentPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default function RecentPage() {
api.getRecentFiles().then(setFiles).catch(() => {});
}, []);

useEffect(() => { api.clearFocus().catch(() => {}); }, []);
useEffect(() => { refresh(); }, [refresh]);

const debouncedRefresh = useCallback(() => debounce(refresh, 200)(), [refresh]);
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/pages/SearchPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ vi.mock('../api', () => ({
}),
getInReview: vi.fn().mockResolvedValue([]),
listProjects: vi.fn().mockResolvedValue([]),
clearFocus: vi.fn().mockResolvedValue(undefined),
},
API_BASE: 'http://localhost:8080',
isDesktopApp: false,
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/pages/SearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default function SearchPage() {
.finally(() => setLoading(false));
}, [query]);

useEffect(() => { api.clearFocus().catch(() => {}); }, []);
useEffect(() => { doSearch(); }, [doSearch]);

const hasProjects = (results?.matchingProjects?.length ?? 0) > 0;
Expand Down
1 change: 1 addition & 0 deletions apps/penpal/frontend/src/pages/WorkspacePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ vi.mock('../api', () => ({
]),
getProjectInfo: vi.fn().mockResolvedValue({ fileCount: 5, dirty: false, unpushedCommits: 0 }),
getInReview: vi.fn().mockResolvedValue([]),
clearFocus: vi.fn().mockResolvedValue(undefined),
},
API_BASE: 'http://localhost:8080',
isDesktopApp: false,
Expand Down
3 changes: 3 additions & 0 deletions apps/penpal/frontend/src/pages/WorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export default function WorkspacePage() {
}).catch(() => {});
}, [name]);

// Workspace dirs are already watched; clear any deep project/file focus
useEffect(() => { api.clearFocus().catch(() => {}); }, []);

useEffect(() => {
refreshProjects();
}, [refreshProjects]);
Expand Down
57 changes: 57 additions & 0 deletions apps/penpal/internal/server/api_focus_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package server

import (
"net/http"
"net/http/httptest"
"testing"
)

func TestAPIFocus_Project(t *testing.T) {
s, _, _ := testServer(t)
seedProject(s.cache, "ws/proj", t.TempDir(), nil)

req := httptest.NewRequest(http.MethodPost, "/api/focus?project=ws/proj", nil)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
}

func TestAPIFocus_File(t *testing.T) {
s, _, _ := testServer(t)
seedProject(s.cache, "ws/proj", t.TempDir(), nil)

req := httptest.NewRequest(http.MethodPost, "/api/focus?project=ws/proj&path=thoughts/plan.md", nil)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
}

func TestAPIFocus_Clear(t *testing.T) {
s, _, _ := testServer(t)

req := httptest.NewRequest(http.MethodDelete, "/api/focus", nil)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)

if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
}

func TestAPIFocus_MissingProject(t *testing.T) {
s, _, _ := testServer(t)

req := httptest.NewRequest(http.MethodPost, "/api/focus", nil)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)

if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
}
32 changes: 32 additions & 0 deletions apps/penpal/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/threads/", s.handleAPIThreadAction)
s.mux.HandleFunc("/api/reviews", s.handleAPIListReviews)
// Agent management endpoints
s.mux.HandleFunc("/api/focus", s.handleFocus)
s.mux.HandleFunc("/api/agents", s.handleAgentStatus)
s.mux.HandleFunc("/api/agents/start", s.handleAgentStart)
s.mux.HandleFunc("/api/agents/stop", s.handleAgentStop)
Expand All @@ -309,6 +310,37 @@ func (s *Server) routes() {

}

// handleFocus tells the watcher what to deep-watch based on current view.
//
// POST /api/focus?project=X — watch project sources (ProjectPage)
// POST /api/focus?project=X&path=Y — watch file directory only (FilePage)
// DELETE /api/focus — clear all deep watches
func (s *Server) handleFocus(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPost:
project := r.URL.Query().Get("project")
if project == "" {
http.Error(w, "missing project parameter", http.StatusBadRequest)
return
}
filePath := r.URL.Query().Get("path")
worktree := r.URL.Query().Get("worktree")
if filePath != "" {
s.watcher.FocusFile(project, filePath, worktree)
} else {
s.watcher.FocusProject(project)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
case http.MethodDelete:
s.watcher.ClearFocus()
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true}`))
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}

func formatAge(t time.Time) string {
d := time.Since(t)
switch {
Expand Down
Loading
Loading