Skip to content

Commit 1e4e0d4

Browse files
sinelawclaude
andcommitted
fix(orchestrator): make Open dialog cross-project
Sessions are inherently cross-project — each row can carry its own `project_path` — so scoping the picker to the active window's project hid exactly the rows the user came to switch into, and visiting a session in another project silently changed the next dialog's row count. Drops `currentProjectOnly` / `showAllProjects`, the Alt+P toggle, and the `(project: …)` header. Adds an e2e repro that opens the dialog with two project-tagged sessions and asserts both surface regardless of the active window's project. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0d9070b commit 1e4e0d4

3 files changed

Lines changed: 102 additions & 103 deletions

File tree

crates/fresh-editor/plugins/orchestrator.ts

Lines changed: 9 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -256,19 +256,6 @@ interface OpenDialogState {
256256
// too easy to skip over when the user's eyes are on the dialog.
257257
// Cleared on the next nav / filter change.
258258
lastError: string | null;
259-
// When `false` (the default), the list shows only sessions
260-
// whose `projectPath` matches `currentProject` (plus the
261-
// base session). When `true`, every session is shown
262-
// regardless of project, with the project path rendered
263-
// alongside the label so cross-project rows are
264-
// distinguishable. Toggle via Alt+P inside the dialog.
265-
showAllProjects: boolean;
266-
// Resolved canonical project root for the editor's cwd;
267-
// probed once at openControlRoom time. Empty string for
268-
// non-git launches (the path-comparison fallback in
269-
// `filterSessions` then matches every session that has no
270-
// recorded projectPath).
271-
currentProject: string;
272259
}
273260
let openDialog: OpenDialogState | null = null;
274261
let openPanel: FloatingWidgetPanel | null = null;
@@ -345,38 +332,12 @@ function ageString(createdAt: number): string {
345332
// then ties broken by label length so shorter matches surface
346333
// first. Empty needle returns the full list in numeric-id order.
347334
//
348-
// With `currentProjectOnly: true` (the default — see
349-
// `openDialog.showAllProjects`), only sessions whose
350-
// `projectPath` matches the editor's resolved project survive
351-
// the filter. The base session (id 1) is always included even
352-
// if its `projectPath` is missing, so a fresh launch never
353-
// shows an empty list.
354-
function filterSessions(
355-
needle: string,
356-
options?: { currentProjectOnly?: boolean; currentProject?: string },
357-
): number[] {
335+
// The picker is cross-project by design — every session is a
336+
// candidate regardless of which project the active window
337+
// points at — so there is no project-scope filter here.
338+
function filterSessions(needle: string): number[] {
358339
reconcileSessions();
359-
const currentProjectOnly = options?.currentProjectOnly ?? true;
360-
const currentProject = options?.currentProject ?? "";
361-
const matchesProject = (s: AgentSession): boolean => {
362-
if (!currentProjectOnly) return true;
363-
if (s.id === 1) return true; // base session always visible
364-
if (!currentProject) return true;
365-
if (s.projectPath === currentProject) return true;
366-
// Fall-back: legacy sessions without `projectPath` are
367-
// shown in every project view rather than hidden — the
368-
// alternative (drop them from the list entirely) would
369-
// make pre-Phase 5 sessions inaccessible without an
370-
// explicit toggle.
371-
if (s.projectPath == null) return true;
372-
return false;
373-
};
374-
const ids = Array.from(orchestratorSessions.keys())
375-
.filter((id) => {
376-
const s = orchestratorSessions.get(id);
377-
return !!s && matchesProject(s);
378-
})
379-
.sort((a, b) => a - b);
340+
const ids = Array.from(orchestratorSessions.keys()).sort((a, b) => a - b);
380341
if (!needle) return ids;
381342
const n = needle.toLowerCase();
382343
type Scored = { id: number; score: number; len: number };
@@ -902,14 +863,6 @@ function buildOpenSpec(): WidgetSpec {
902863
],
903864
}
904865
: null;
905-
// Header scope indicator: surfaces the current project filter
906-
// (or "all projects") so the user knows what set the list is
907-
// drawing from before they start filtering on top.
908-
const scopeText = openDialog.showAllProjects
909-
? "all projects"
910-
: openDialog.currentProject
911-
? `project: ${openDialog.currentProject}`
912-
: "current project";
913866
return col(
914867
{
915868
kind: "raw",
@@ -919,10 +872,6 @@ function buildOpenSpec(): WidgetSpec {
919872
text: "ORCHESTRATOR :: Sessions",
920873
style: { fg: "ui.popup_border_fg", bold: true },
921874
},
922-
{
923-
text: ` (${scopeText})`,
924-
style: { fg: "editor.whitespace_indicator_fg", italic: true },
925-
},
926875
]),
927876
],
928877
},
@@ -994,10 +943,6 @@ function buildOpenSpec(): WidgetSpec {
994943
{ keys: "↑↓", label: "nav" },
995944
{ keys: "Enter", label: "dive" },
996945
{ keys: "Tab", label: "focus" },
997-
{
998-
keys: "Alt+P",
999-
label: openDialog.showAllProjects ? "this project" : "all projects",
1000-
},
1001946
{ keys: "Esc", label: "close" },
1002947
]),
1003948
flexSpacer(),
@@ -1053,10 +998,7 @@ function clearDialogError(): void {
1053998

1054999
function refreshOpenDialog(): void {
10551000
if (!openPanel || !openDialog) return;
1056-
openDialog.filteredIds = filterSessions(openDialog.filter.value, {
1057-
currentProjectOnly: !openDialog.showAllProjects,
1058-
currentProject: openDialog.currentProject,
1059-
});
1001+
openDialog.filteredIds = filterSessions(openDialog.filter.value);
10601002
// Clamp the selection into range so a fresh filter or a
10611003
// session vanishing under us doesn't leave us pointing past
10621004
// the end of the list.
@@ -1080,21 +1022,6 @@ function openControlRoom(): void {
10801022
reconcileSessions();
10811023
const activeId = editor.activeWindow();
10821024
const listVisibleRows = openListVisibleRows();
1083-
// Resolve the editor's "current project" for the dialog's
1084-
// default scope. The right source is the ACTIVE window's
1085-
// `projectPath` — Window is the unit of project ownership in
1086-
// this editor (each Window has its own root, LSP, file
1087-
// explorer; switching active_window swaps all of that
1088-
// atomically). So when the user is currently diving in a
1089-
// session for project A and opens the picker, A's sessions
1090-
// should be the default view; if they then dive into a
1091-
// project-B session and reopen the picker, B becomes the
1092-
// default. Fall back to `root` for windows that pre-date
1093-
// the Project Path field, and to an empty string if even
1094-
// that isn't usable (the filter then matches every session
1095-
// with no recorded projectPath).
1096-
const activeSession = orchestratorSessions.get(activeId);
1097-
const currentProject = activeSession?.projectPath ?? activeSession?.root ?? "";
10981025
openDialog = {
10991026
filter: { value: "", cursor: 0 },
11001027
filteredIds: [],
@@ -1115,14 +1042,8 @@ function openControlRoom(): void {
11151042
showDetails: false,
11161043
inFlight: null,
11171044
lastError: null,
1118-
showAllProjects: false,
1119-
currentProject,
11201045
};
1121-
// Apply the initial filter (current-project view by default).
1122-
openDialog.filteredIds = filterSessions("", {
1123-
currentProjectOnly: !openDialog.showAllProjects,
1124-
currentProject,
1125-
});
1046+
openDialog.filteredIds = filterSessions("");
11261047
const activeIdx = openDialog.filteredIds.indexOf(activeId);
11271048
openDialog.selectedIndex = activeIdx >= 0 ? activeIdx : 0;
11281049
openPanel = new FloatingWidgetPanel();
@@ -1684,23 +1605,11 @@ async function deleteConfirmedSession(): Promise<void> {
16841605
// since OPEN_MODE doesn't claim them here.
16851606
editor.defineMode(
16861607
OPEN_MODE,
1687-
[
1688-
["M-n", "orchestrator_open_new_from_picker"],
1689-
// `Alt+P` toggles "all projects" vs "this project only".
1690-
// The hint footer reflects the current state so users
1691-
// discover the toggle without RTFM.
1692-
["M-p", "orchestrator_open_toggle_all_projects"],
1693-
],
1608+
[["M-n", "orchestrator_open_new_from_picker"]],
16941609
true,
16951610
true,
16961611
);
16971612

1698-
registerHandler("orchestrator_open_toggle_all_projects", () => {
1699-
if (!openDialog) return;
1700-
openDialog.showAllProjects = !openDialog.showAllProjects;
1701-
refreshOpenDialog();
1702-
});
1703-
17041613
registerHandler("orchestrator_open_new_from_picker", () => {
17051614
if (!openDialog) return;
17061615
closeOpenDialog();
@@ -3154,10 +3063,7 @@ editor.on("widget_event", (e) => {
31543063
// when possible — if the previously selected id is still in
31553064
// the new filtered set, keep it; otherwise reset to 0.
31563065
const prevId = openDialog.filteredIds[openDialog.selectedIndex];
3157-
const next = filterSessions(value, {
3158-
currentProjectOnly: !openDialog.showAllProjects,
3159-
currentProject: openDialog.currentProject,
3160-
});
3066+
const next = filterSessions(value);
31613067
openDialog.filteredIds = next;
31623068
const nextIdx = prevId !== undefined ? next.indexOf(prevId) : -1;
31633069
openDialog.selectedIndex = nextIdx >= 0 ? nextIdx : 0;

crates/fresh-editor/tests/e2e/plugins/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ pub mod live_diff;
4040
pub mod load_from_buffer;
4141
pub mod lsp_find_references;
4242
pub mod markdown_source;
43+
pub mod orchestrator_open_cross_project;
4344
pub mod package_manager;
4445
pub mod plugin;
4546
pub mod plugin_keybinding_execution;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
//! The Orchestrator Open dialog must list every session, regardless of
2+
//! which project the active window happens to point at.
3+
//!
4+
//! Sessions are inherently cross-project — each row can have its own
5+
//! `project_path` — so scoping the picker to "current project" hides
6+
//! exactly the rows the user came to switch into. The visible symptom
7+
//! is that opening the dialog, visiting a session in another project,
8+
//! then reopening the dialog changes the visible row count, because
9+
//! the dialog's notion of "current project" is recomputed from the
10+
//! newly-active window every time it opens.
11+
12+
use crate::common::harness::EditorTestHarness;
13+
use crossterm::event::{KeyCode, KeyModifiers};
14+
use fresh_core::api::PluginCommand;
15+
use fresh_core::WindowId;
16+
use serde_json::Value;
17+
use std::path::Path;
18+
19+
const WIDTH: u16 = 160;
20+
const HEIGHT: u16 = 40;
21+
22+
const LABEL_B: &str = "zebra-beta-xr";
23+
24+
fn run_palette(harness: &mut EditorTestHarness, command_name: &str) {
25+
harness
26+
.send_key(KeyCode::Char('p'), KeyModifiers::CONTROL)
27+
.unwrap();
28+
harness.render().unwrap();
29+
harness.type_text(command_name).unwrap();
30+
harness.render().unwrap();
31+
harness
32+
.send_key(KeyCode::Enter, KeyModifiers::NONE)
33+
.unwrap();
34+
harness.render().unwrap();
35+
}
36+
37+
fn set_orch_project_path(harness: &mut EditorTestHarness, project_path: &Path) {
38+
harness
39+
.editor_mut()
40+
.handle_plugin_command(PluginCommand::SetWindowState {
41+
plugin_name: "orchestrator".into(),
42+
key: "project_path".into(),
43+
value: Some(Value::String(project_path.to_string_lossy().into_owned())),
44+
})
45+
.unwrap();
46+
}
47+
48+
#[test]
49+
fn open_dialog_lists_sessions_from_all_projects() {
50+
let mut harness = EditorTestHarness::with_temp_project(WIDTH, HEIGHT).unwrap();
51+
52+
// Project A: the harness's temp project root, owned by the base
53+
// window (id 1, active at boot).
54+
let proj_a = harness.project_dir().unwrap().canonicalize().unwrap();
55+
set_orch_project_path(&mut harness, &proj_a);
56+
57+
// Project B: a separate tempdir, owned by a second window we
58+
// create explicitly. Per-session plugin state always writes to
59+
// the *active* window, so we set B active before tagging.
60+
let proj_b_dir = tempfile::TempDir::new().unwrap();
61+
let proj_b = proj_b_dir.path().canonicalize().unwrap();
62+
let win_b = harness
63+
.editor_mut()
64+
.create_window_at(proj_b.clone(), LABEL_B.into());
65+
harness.editor_mut().set_active_window(win_b);
66+
set_orch_project_path(&mut harness, &proj_b);
67+
68+
// Switch back to A so the dialog opens with currentProject = A —
69+
// the case the bug repro turns on.
70+
harness.editor_mut().set_active_window(WindowId(1));
71+
harness.render().unwrap();
72+
73+
run_palette(&mut harness, "Orchestrator: Open");
74+
harness
75+
.wait_until(|h| h.screen_to_string().contains("Sessions ("))
76+
.expect("Orchestrator Open dialog should appear");
77+
78+
let screen = harness.screen_to_string();
79+
assert!(
80+
screen.contains(LABEL_B),
81+
"Project B's session must be listed in the open dialog while the \
82+
active window is in Project A — the picker is cross-project by \
83+
design.\nScreen:\n{}",
84+
screen,
85+
);
86+
assert!(
87+
screen.contains("Sessions (2)"),
88+
"Header count must reflect every session, not the project-filtered \
89+
subset.\nScreen:\n{}",
90+
screen,
91+
);
92+
}

0 commit comments

Comments
 (0)