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
2 changes: 1 addition & 1 deletion docs/architecture/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ type piRPCWorker struct {
| `/api/settings` | GET/POST | `handleGetSettings` / `handleSaveSettings` | Server-backed user settings (SQLite) |
| `/api/btw` | GET | `handleGetBtw` | Resolve the btw scratch-chat session for a parent (SQLite) |
| `/api/btw/new` | POST | `handleNewBtw` | Create a new btw scratch-chat session (SQLite) |
| `/api/projects` | GET/POST | `handleApiProjects` / `handleUpdateProject` | List projects + filter state (`limit`/`offset`, optional `current` priority + `sessionLimit` bundled summaries, active session IDs per project); enable/disable/register/remove, bulk enable-all/disable-all, enable-filter/disable-filter (SQLite) |
| `/api/projects` | GET/POST | `handleApiProjects` / `handleUpdateProject` | List projects + filter state (`limit`/`offset`, optional `current` priority + `sessionLimit` bundled summaries, active session IDs per project, `filtered=1` to apply the enabled-projects allowlist with the current project always kept); enable/disable/register/remove, bulk enable-all/disable-all, enable-filter/disable-filter (SQLite) |
| `/api/sounds` | GET | `handleApiSounds` | List available notification sounds |
| `/sounds/` | GET | `handleSounds` | Serve a sound asset (no auth) |
| `/custom-themes.css` | GET | `handleCustomThemes` | User custom theme CSS |
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture/system-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ across devices. See `internal/server/projects.go`.
(no client flash) and is a no-op while the master switch is off. Manage via the
index menu → **Manage Projects** (search, select/deselect-all, register, and the
filter switch), backed by `GET/POST /api/projects`.
- The session sidebar (Projects tab and the Sessions tab's project switcher)
requests `GET /api/projects?filtered=1`, which applies the same allowlist
server-side — except the current session's project, which is always included so
the project you are in never disappears. The Manage Projects modal omits the
param and keeps seeing every project.

## Startup Order

Expand Down
13 changes: 13 additions & 0 deletions internal/server/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,19 @@ func (s *Server) handleApiProjects(w http.ResponseWriter, r *http.Request) {
RunningSessionIDs: runningByProject[p],
})
}
// filtered=1 applies the Manage Projects allowlist (used by the session
// sidebar). The current project is always kept so the project you are in
// never disappears; the modal omits the param to keep seeing everything.
if q.Get("filtered") == "1" && s.projectFilterEnabled() {
kept := make([]projectEntry, 0, len(entries))
for _, entry := range entries {
if entry.Enabled || entry.Path == currentProject {
kept = append(kept, entry)
}
}
entries = kept
}

sort.Slice(entries, func(i, j int) bool {
if (entries[i].Path == currentProject) != (entries[j].Path == currentProject) {
return entries[i].Path == currentProject
Expand Down
59 changes: 59 additions & 0 deletions internal/server/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,65 @@ func TestHandleApiProjects(t *testing.T) {
}
}

func TestHandleApiProjectsFiltered(t *testing.T) {
sessionsDir := t.TempDir()
writeSessionWithCWD(t, filepath.Join(sessionsDir, "sub1"), "a.jsonl", "/home/user/project-a")
writeSessionWithCWD(t, filepath.Join(sessionsDir, "sub2"), "b.jsonl", "/home/user/project-b")

s := &Server{db: newProjectPrefsDB(t), sessionsDir: sessionsDir, cache: sessions.NewCache(), now: time.Now}
// Seed both projects, then disable project-b.
s.syncProjectPrefs([]string{"/home/user/project-a", "/home/user/project-b"})
if _, err := s.db.Exec("UPDATE project_prefs SET enabled = 0 WHERE project_path = ?", "/home/user/project-b"); err != nil {
t.Fatal(err)
}

getPaths := func(url string) ([]string, int) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, url, nil)
w := httptest.NewRecorder()
s.handleApiProjects(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d", w.Code)
}
var payload struct {
Projects []projectEntry `json:"projects"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
paths := make([]string, 0, len(payload.Projects))
for _, p := range payload.Projects {
paths = append(paths, p.Path)
}
return paths, payload.Total
}

// Master filter off: filtered=1 is a no-op.
if paths, total := getPaths("/api/projects?filtered=1"); len(paths) != 2 || total != 2 {
t.Fatalf("filter off: got %v (total %d), want both projects", paths, total)
}

s.setProjectFilterEnabled(true)

// Without filtered=1 (Manage Projects modal) everything still shows.
if paths, total := getPaths("/api/projects"); len(paths) != 2 || total != 2 {
t.Fatalf("no filtered param: got %v (total %d), want both projects", paths, total)
}

// filtered=1 drops the disabled project and total reflects it.
paths, total := getPaths("/api/projects?filtered=1")
if len(paths) != 1 || paths[0] != "/home/user/project-a" || total != 1 {
t.Fatalf("filtered: got %v (total %d), want only project-a", paths, total)
}

// The current project is kept even when disabled.
paths, total = getPaths("/api/projects?filtered=1&current=/home/user/project-b")
if len(paths) != 2 || total != 2 || paths[0] != "/home/user/project-b" {
t.Fatalf("filtered current: got %v (total %d), want project-b first", paths, total)
}
}

func TestHandleApiProjectsPagination(t *testing.T) {
sessionsDir := t.TempDir()
for i := range 25 {
Expand Down
1 change: 1 addition & 0 deletions web/src/components/session/SessionSidebarProjects.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
offset: projects.length,
currentProject: cwd,
currentSessionLimit: sessionPageSize,
filtered: true,
});
if (destroyed) return;
const knownProjectPaths = new Set(projects.map((project) => project.path));
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/session/SessionSidebarProjects.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('SessionSidebarProjects', () => {
offset: 0,
currentProject: '/repo/pi-web',
currentSessionLimit: 5,
filtered: true,
});
expect(fetchSessions).toHaveBeenCalledWith({
project: '/repo/pi-web',
Expand Down Expand Up @@ -190,6 +191,7 @@ describe('SessionSidebarProjects', () => {
offset: 20,
currentProject: '/repo/current',
currentSessionLimit: 5,
filtered: true,
});
});

Expand Down
2 changes: 1 addition & 1 deletion web/src/components/session/SessionSidebarSessions.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@
await tick();
projectSearchEl?.focus();
try {
const response = await fetchProjects();
const response = await fetchProjects({ filtered: true });
projects = Array.isArray(response.projects) ? response.projects : [];
} catch (err) {
projects = [];
Expand Down
6 changes: 4 additions & 2 deletions web/src/components/session/chat/textarea-controls.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { matchesAction } from '../../../shared/keybindings.js';

export function setupTextareaControls({
windowImpl = window,
textarea,
Expand Down Expand Up @@ -35,11 +37,11 @@ export function setupTextareaControls({
event.preventDefault();
form?.requestSubmit?.();
}
if (event.key === 'Tab' && event.shiftKey) {
if (matchesAction('cycle-thinking-level', event)) {
event.preventDefault();
getThinkingSelector()?.cycle?.();
}
if (event.ctrlKey && (event.key.toLowerCase() === 'i' || event.key.toLowerCase() === 'l')) {
if (matchesAction('open-model-selector', event)) {
event.preventDefault();
getModelSelector()?.open?.();
}
Expand Down
9 changes: 8 additions & 1 deletion web/src/index/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,21 @@ export function defaultFetchRecent() {
export function defaultCreateSession(path) {
return postJSON('/api/new-session', { path });
}
export function defaultFetchProjects({ limit, offset, currentProject, currentSessionLimit } = {}) {
export function defaultFetchProjects({
limit,
offset,
currentProject,
currentSessionLimit,
filtered,
} = {}) {
const params = new URLSearchParams();
if (Number.isFinite(limit) && limit > 0) params.set('limit', String(limit));
if (Number.isFinite(offset) && offset > 0) params.set('offset', String(offset));
if (currentProject) params.set('current', currentProject);
if (Number.isFinite(currentSessionLimit) && currentSessionLimit > 0) {
params.set('sessionLimit', String(currentSessionLimit));
}
if (filtered) params.set('filtered', '1');
const qs = params.toString();
return getJSON('/api/projects' + (qs ? '?' + qs : ''));
}
Expand Down
5 changes: 3 additions & 2 deletions web/src/routes/SessionsPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import { createStatusEvents } from '../shared/status-events.js';
import { openSessionPalette, refreshSessionPalette } from '../shared/command-palette-runtime.js';
import { setupKeyboardNav } from '../shared/keyboard-nav.js';
import { matchesAction } from '../shared/keybindings.js';
import { toggleTheme, syncThemeIcons } from '../shared/theme.js';
import {
configureSettingsSync,
Expand Down Expand Up @@ -253,14 +254,14 @@
} catch {}

const keydown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
if (matchesAction('toggle-theme', e)) {
e.preventDefault();
e.stopPropagation();
toggleTheme(window, document);
syncThemeIcons(document);
return;
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
if (matchesAction('open-palette', e)) {
e.preventDefault();
openPalette();
return;
Expand Down
13 changes: 7 additions & 6 deletions web/src/session/session-globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as doneNotifier from './chat/done-notifier.js';
import * as sidebarApi from './ui/sidebar.js';
import { openSessionPalette } from '../shared/command-palette-runtime.js';
import { setupKeyboardNav } from '../shared/keyboard-nav.js';
import { matchesAction } from '../shared/keybindings.js';
import { openShortcuts } from './session-modals.svelte.js';
import { sessionRuntime } from './session-runtime.js';
import { toggleTheme, syncThemeIcons } from '../shared/theme.js';
Expand Down Expand Up @@ -42,15 +43,15 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {
// ── Global keyboard shortcuts ──────────────────────────────────────────────
// Cmd+K — session list palette
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
if (matchesAction('open-palette', e)) {
e.preventDefault();
openSessionPalette();
}
});

// Cmd+B — toggle sidebar/tree
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'b') {
if (matchesAction('toggle-sidebar', e)) {
e.preventDefault();
const sidebar = documentImpl.getElementById('sidebar');
if (sidebarApi.isMobileLayout({ windowImpl: target })) {
Expand All @@ -67,7 +68,7 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {

// Cmd+T — new session
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 't') {
if (matchesAction('new-session', e)) {
e.preventDefault();
const newBtn = documentImpl.getElementById('new-btn');
if (newBtn) newBtn.click();
Expand All @@ -80,7 +81,7 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {
target,
'keydown',
(e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
if (matchesAction('toggle-theme', e)) {
e.preventDefault();
e.stopPropagation();
toggleTheme(target, documentImpl);
Expand All @@ -92,7 +93,7 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {

// Cmd+Shift+N — toggle scratchpad (right sidebar)
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'n') {
if (matchesAction('toggle-scratchpad', e)) {
e.preventDefault();
sessionRuntime.rightSidebar?.toggle();
}
Expand All @@ -101,7 +102,7 @@ export function setupSessionGlobals({ windowImpl, documentImpl }) {
// Cmd+/ — keyboard shortcuts help modal (the <ShortcutsModal> Svelte
// component, opened via the shared sessionModals store).
on(target, 'keydown', (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
if (matchesAction('open-shortcuts-help', e)) {
e.preventDefault();
openShortcuts();
}
Expand Down
9 changes: 5 additions & 4 deletions web/src/session/ui/search-filters.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { matchesAction } from '../../shared/keybindings.js';

export function setupSessionSearchAndFilters({
documentImpl = document,
getLeafId,
Expand Down Expand Up @@ -68,14 +70,13 @@ export function setupSessionKeyboardShortcuts({
return;
}

const key = e.key.toLowerCase();
if (key === 't') {
if (matchesAction('toggle-thinking', e)) {
e.preventDefault();
toggleThinking();
} else if (key === 'o') {
} else if (matchesAction('toggle-tools', e)) {
e.preventDefault();
toggleToolsVisibility();
} else if (key === 'p') {
} else if (matchesAction('toggle-tool-outputs', e)) {
e.preventDefault();
toggleToolOutputs();
}
Expand Down
Loading