From a6b67f13eb23718ff3ae4d2b79360cc68496779d Mon Sep 17 00:00:00 2001 From: meh Date: Sat, 1 Aug 2026 23:55:41 +0700 Subject: [PATCH 1/2] fix: the marker's ladder stopped at active, because the backend refused Clicking the circle walks new, planning, active, questions, shipped, finished. `set_item_status` validated against a list that did not include `questions`, so the click that should move an active row asked for a status the command refused, the row did not move, and the cycle appeared to stop partway with no way to carry on or to undo a misclick. Nothing said why. The refusal went back through the promise and no further, so the panel showed a row that simply would not change. It is logged now, because a rejected write that reaches nobody is the same defect in a different place. `canceled` was missing too, for the same reason: two copies of one vocabulary, one in Rust and one in `ITEM_LADDER`, drifting apart. --- Cargo.lock | 14 +++++++------- Cargo.toml | 2 +- apps/gui/src/projects.rs | 26 +++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad71e25..0f51a09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,25 +142,25 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "az-agent" -version = "0.1.52" +version = "0.1.53" dependencies = [ "az-core", ] [[package]] name = "az-agent-proxy" -version = "0.1.52" +version = "0.1.53" dependencies = [ "az-core", ] [[package]] name = "az-core" -version = "0.1.52" +version = "0.1.53" [[package]] name = "az-gui" -version = "0.1.52" +version = "0.1.53" dependencies = [ "agent-abstraction", "az-core", @@ -183,7 +183,7 @@ dependencies = [ [[package]] name = "az-mcp-proxy" -version = "0.1.52" +version = "0.1.53" dependencies = [ "az-core", ] @@ -5551,7 +5551,7 @@ dependencies = [ [[package]] name = "wt-migrate" -version = "0.1.52" +version = "0.1.53" dependencies = [ "derive_more 2.1.1", "eyre", @@ -5565,7 +5565,7 @@ dependencies = [ [[package]] name = "wt-tools" -version = "0.1.52" +version = "0.1.53" dependencies = [ "derive_more 2.1.1", "dirs", diff --git a/Cargo.toml b/Cargo.toml index db39960..0c93950 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ members = [ ] [workspace.package] -version = "0.1.52" +version = "0.1.53" edition = "2024" publish = false diff --git a/apps/gui/src/projects.rs b/apps/gui/src/projects.rs index d61e90f..2af2e6c 100644 --- a/apps/gui/src/projects.rs +++ b/apps/gui/src/projects.rs @@ -767,10 +767,34 @@ pub async fn set_item_status( status: String, state: State<'_, AppState>, ) -> Result { + /* + * Every status the ladder can reach, including `questions` and `canceled`. + * + * `questions` was missing, and the marker's ladder walks through it, so a + * click on an `active` row asked for a status this command refused and the + * row did not move. From the outside that reads as the cycle stopping + * partway with no way to carry on or to correct a misclick, and nothing + * said why: the refusal reached the promise and not the panel. + * + * The list here and `ITEM_LADDER` in the frontend are the same vocabulary + * in two places, which is how they drifted. `ProjectStatus` is the source. + */ if !matches!( status.as_str(), - "new" | "pending" | "planning" | "active" | "shipped" | "finished" + "new" + | "pending" + | "planning" + | "active" + | "questions" + | "shipped" + | "finished" + | "canceled" ) { + crate::log!( + crate::log::Level::Error, + "items", + "refused status {status:?} for {id}: not one this app knows" + ); return Err(format!("not an item status: {status}")); } state From a01b212a52208925cb3392e736bcbc756f2d5107 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 2 Aug 2026 00:59:27 +0700 Subject: [PATCH 2/2] fix: discovery keeps the repository, and a folder can be picked Three things, one cause between them: a project whose directory is not a checkout has no repository, so no pull request can be discovered for it, and nothing said so. Discovery read the repository off the project's *open* rows. Once every known pull request settled, the repository was forgotten and discovery stopped, silently, exactly when the list looked finished. It reads every row now: a merged pull request still says where it came from. When there is no repository at all, that is logged rather than returned into silence. This project's directory is an empty folder that is not a git checkout, which is why nothing was ever discovered for it and why the panel only showed pull requests whose URLs happened to appear in a reply. And the directory can be chosen rather than typed. The panel said a picker needed the Tauri dialog plugin; it has been wired since, and `choose_data_directory` has used it all along. A typed path is how a project ends up pointed at a directory that is not a checkout in the first place. --- apps/gui/frontend/src/api/client.ts | 2 + apps/gui/frontend/src/api/index.ts | 1 + apps/gui/frontend/src/api/mock.ts | 2 + apps/gui/frontend/src/api/tauri.ts | 1 + .../src/features/project/ProjectPanel.tsx | 60 ++++++++++++++----- apps/gui/frontend/src/stores/workspace.tsx | 4 ++ apps/gui/src/main.rs | 32 ++++++++++ apps/gui/src/prs.rs | 24 +++++++- 8 files changed, 107 insertions(+), 19 deletions(-) diff --git a/apps/gui/frontend/src/api/client.ts b/apps/gui/frontend/src/api/client.ts index 920028b..61092e8 100644 --- a/apps/gui/frontend/src/api/client.ts +++ b/apps/gui/frontend/src/api/client.ts @@ -145,6 +145,8 @@ export interface AgencyZeroApi { * all — the click lands and nothing happens. */ chooseDataDirectory(): Promise; + /** A working directory for a project. Starts at home, not beside the store. */ + chooseProjectDirectory(): Promise; /** * Open the OS file picker, for the composer's Attach button. The chosen * paths land in the prompt as text — the agents read file paths in prose, diff --git a/apps/gui/frontend/src/api/index.ts b/apps/gui/frontend/src/api/index.ts index b18eb66..3e77c9a 100644 --- a/apps/gui/frontend/src/api/index.ts +++ b/apps/gui/frontend/src/api/index.ts @@ -47,6 +47,7 @@ const COMMAND_FOR: Partial> = { getDataLocation: "get_data_location", setDataLocation: "set_data_location", chooseDataDirectory: "choose_data_directory", + chooseProjectDirectory: "choose_project_directory", chooseAttachments: "choose_attachments", getWorkspaceRoot: "get_workspace_root", createWorkspaceRoot: "create_workspace_root", diff --git a/apps/gui/frontend/src/api/mock.ts b/apps/gui/frontend/src/api/mock.ts index c0772e4..e35a14c 100644 --- a/apps/gui/frontend/src/api/mock.ts +++ b/apps/gui/frontend/src/api/mock.ts @@ -445,6 +445,8 @@ export function createMockApi(): AgencyZeroApi { * they do not have. */ chooseDataDirectory: () => settle(null), + // No native panel in the preview; the typed path still works. + chooseProjectDirectory: () => settle(null), // A fixed fixture path: the preview has no OS picker to open. chooseAttachments: () => settle(["/tmp/mock-attachment.txt"]), diff --git a/apps/gui/frontend/src/api/tauri.ts b/apps/gui/frontend/src/api/tauri.ts index 5032057..49620d1 100644 --- a/apps/gui/frontend/src/api/tauri.ts +++ b/apps/gui/frontend/src/api/tauri.ts @@ -76,6 +76,7 @@ export function createTauriApi(): AgencyZeroApi { getDataLocation: () => call("get_data_location"), setDataLocation: (path) => call("set_data_location", { path }), chooseDataDirectory: () => call("choose_data_directory"), + chooseProjectDirectory: () => call("choose_project_directory"), chooseAttachments: () => call("choose_attachments"), getWorkspaceRoot: () => call("get_workspace_root"), createWorkspaceRoot: () => call("create_workspace_root"), diff --git a/apps/gui/frontend/src/features/project/ProjectPanel.tsx b/apps/gui/frontend/src/features/project/ProjectPanel.tsx index 5b65a61..0656bac 100644 --- a/apps/gui/frontend/src/features/project/ProjectPanel.tsx +++ b/apps/gui/frontend/src/features/project/ProjectPanel.tsx @@ -300,12 +300,26 @@ const IO_TONE: Record = { * in the composer, which is the note this section ends on. */ function SettingsSection(props: { project: Project }): JSX.Element { - const { state, actions } = useWorkspace(); + const { state, actions, isLive } = useWorkspace(); const [adding, setAdding] = createSignal(false); const [path, setPath] = createSignal(""); const moderatorDefault = () => state.settings?.moderator.enabled ?? true; + /** The native panel, then straight into the list: no second confirmation. */ + async function pick(): Promise { + try { + const picked = await actions.chooseProjectDirectory(); + if (picked) { + await actions.addDir(props.project.id, picked); + setAdding(false); + setPath(""); + } + } catch (cause) { + log.error(`could not choose a directory: ${describeError(cause)}`); + } + } + async function addDir(): Promise { const value = path().trim(); if (!value) return; @@ -359,22 +373,36 @@ function SettingsSection(props: { project: Project }): JSX.Element { } > {/* - A typed path rather than a native folder picker: opening one needs - the Tauri dialog plugin, which is not wired up on the Rust side yet. + Type a path or pick one. The picker matters more than it looks: a + typed path is how a project ends up pointed at a directory that is + not a checkout, and a project with no checkout can have no pull + requests discovered for it, silently. */} - setPath(event.currentTarget.value)} - onKeyDown={(event) => { - if (event.key === "Enter") void addDir(); - if (event.key === "Escape") setAdding(false); - }} - onBlur={() => void addDir()} - class="rounded-[9px] border border-primary/40 bg-base-300 px-2.5 py-[7px] font-mono text-[11.5px] text-az-body focus:outline-none" - /> +
+ + setPath(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") void addDir(); + if (event.key === "Escape") setAdding(false); + }} + onBlur={() => void addDir()} + class="min-w-0 flex-1 rounded-[9px] border border-primary/40 bg-base-300 px-2.5 py-[7px] font-mono text-[11.5px] text-az-body focus:outline-none" + /> +
diff --git a/apps/gui/frontend/src/stores/workspace.tsx b/apps/gui/frontend/src/stores/workspace.tsx index a514fff..9b5a055 100644 --- a/apps/gui/frontend/src/stores/workspace.tsx +++ b/apps/gui/frontend/src/stores/workspace.tsx @@ -1553,6 +1553,10 @@ function createWorkspace() { const picked = await client().chooseDataDirectory(); if (picked) await actions.setDataLocation(picked); }, + /** The native folder panel, for a project's working directories. */ + chooseProjectDirectory() { + return client().chooseProjectDirectory(); + }, /** Open a link in the browser. See the Rust command for the scheme rule. */ openExternal(url: string) { return client().openExternal(url); diff --git a/apps/gui/src/main.rs b/apps/gui/src/main.rs index f67e9c7..06fb5f6 100644 --- a/apps/gui/src/main.rs +++ b/apps/gui/src/main.rs @@ -38,6 +38,7 @@ const IMPLEMENTED: &[&str] = &[ "get_data_location", "set_data_location", "choose_data_directory", + "choose_project_directory", "get_workspace_root", "create_workspace_root", "list_projects", @@ -388,6 +389,36 @@ async fn choose_data_directory( Ok(picked.map(|path| path.to_string())) } +/// Ask the OS for a working directory, for a project's Settings section. +/// +/// Separate from [`choose_data_directory`] because it starts somewhere else: +/// a checkout lives under home, not beside the store. The panel asked for a +/// typed path with a note saying a picker needed the dialog plugin, which has +/// been wired since; a typed path is also how a project ends up pointed at a +/// directory that is not a checkout, which is exactly what stopped pull +/// requests being discovered. +#[tauri::command] +async fn choose_project_directory(app: AppHandle) -> Result, String> { + let mut dialog = app.dialog().file().set_title("Choose a working directory"); + if let Some(home) = dirs_home() { + dialog = dialog.set_directory(home); + } + // The callback form, never the blocking one: see `choose_data_directory`. + let (tx, rx) = tokio::sync::oneshot::channel(); + dialog.pick_folder(move |picked| { + let _ = tx.send(picked); + }); + let picked = rx + .await + .map_err(|_| "the directory picker closed without answering".to_string())?; + Ok(picked.map(|path| path.to_string())) +} + +/// The user's home, or nothing when the platform will not say. +fn dirs_home() -> Option { + std::env::var_os("HOME").map(std::path::PathBuf::from) +} + /// Ask the OS for files, for the composer's Attach button. /// /// The chosen paths land in the prompt as text, the agents take file paths @@ -982,6 +1013,7 @@ fn main() { get_data_location, set_data_location, choose_data_directory, + choose_project_directory, get_workspace_root, create_workspace_root, projects::list_projects, diff --git a/apps/gui/src/prs.rs b/apps/gui/src/prs.rs index 585e02d..28c84ca 100644 --- a/apps/gui/src/prs.rs +++ b/apps/gui/src/prs.rs @@ -241,12 +241,14 @@ fn ci_word(state: Option<&str>) -> String { pub fn refresh_project(app: AppHandle, project_id: String) { tauri::async_runtime::spawn(async move { let state = app.state::(); - let rows: Vec = state + let known: Vec = state .tables .pull_request .select_by_project_id(project_id.clone()) .execute() - .unwrap_or_default() + .unwrap_or_default(); + let rows: Vec = known + .clone() .into_iter() // Endings, and rows nobody is looking at. A settled list costs // nothing, which is what makes a short interval affordable. @@ -258,7 +260,23 @@ pub fn refresh_project(app: AppHandle, project_id: String) { * yet. That is what lets a pull request appear because it exists * rather than because someone wrote its URL in a reply. */ - let repos = repos_for(&state, &project_id, &rows).await; + /* + * Every row, not the open ones. A merged pull request still says which + * repository it belonged to, and reading only the open rows meant the + * repository was forgotten the moment the last one settled: discovery + * then had nothing to ask about and stopped, silently, exactly when + * the list looked finished. + */ + let repos = repos_for(&state, &project_id, &known).await; + if repos.is_empty() { + crate::log!( + crate::log::Level::Warn, + "prs", + "{project_id}: no repository to ask about. None of its directories is a git \ + checkout and no pull request has been recorded, so none can be discovered. \ + Add the checkout to the project's directories." + ); + } if repos.is_empty() { return; }