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
453 changes: 444 additions & 9 deletions Cargo.lock

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,16 @@ keyboard alone, and the mouse stays first-class.
jump).
- **Hosted pull requests** — browse the latest 100 GitHub or Azure DevOps PRs
for the active repository, with the active PR for your checked-out branch
opening automatically. Each PR gets a full-width workspace: read rendered
opening and being followed automatically even before the PR view is opened.
Create a PR or draft for the checked-out branch from the toolbar or command
palette, choosing its title, description, and target branch without an
implicit push. Optionally draft the editable title and description from the
committed branch delta using the configured Codex or Claude Code subscription.
Persistent Follow controls and native desktop notifications surface new
comments, review decisions, failed checks, pushes, and merged/closed state.
Refreshes keep existing content, focus, tabs, drafts, and diffs in place
while lightweight activity is revalidated in the background. Each PR gets a
full-width workspace: read rendered
Markdown descriptions and avatar-led timeline conversations, compose
top-level comments with formatting, preview, and hosted screenshot links, inspect
lazily loaded code changes in the Local Changes-style Pierre file tree,
Expand Down Expand Up @@ -118,7 +127,7 @@ keyboard alone, and the mouse stays first-class.
git / integrations / AI, in-app updates.
- **AI commit messages** — suggest subject + body from staged changes via
your ChatGPT subscription (Codex CLI) or Claude Code CLI; Settings → AI
for sign-in and provider choice.
for sign-in, provider choice, and CLI health checks.
- **Fast by design** — reads go through [gix](https://github.com/GitoxideLabs/gitoxide),
writes through git2 and your system `git`. Performance targets live in
[`PRD.md`](./PRD.md) §8 and are measured in
Expand Down
28 changes: 27 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1615,6 +1615,31 @@ code in Changes, so a refresh reflects comments created in Strand or on GitHub.
Conversation now links file-backed comments straight to that file/thread in
Changes and transfers keyboard focus to the destination.

**Followed PRs and seamless refresh shipped (2026-07-14):** Strand now follows
the checked-out branch's active GitHub or Azure DevOps PR even when the PR view
has never opened, with persistent manual follow/mute state and lightweight,
patch-free activity baselines shared across worktrees. A global two-at-a-time
monitor polls on hydration, every 60 seconds, and window focus; it coalesces
new comments/replies, review decisions, failed checks/policies, pushes, and
terminal state into native notifications, then auto-unfollows merged/closed
PRs. The PR workspace now refreshes stale-while-revalidate: existing list,
detail, focus, tabs, drafts, scroll, and patch stay mounted; rich detail reloads
only after activity changes, and the patch reloads only for a new head while a
stale copy remains read-only.

**Create pull requests shipped (2026-07-14):** The Pull Requests toolbar and
command palette now open a keyboard-operable creation dialog for the checked-out
branch. GitHub and Azure DevOps creation share one typed IPC path and support a
title, Markdown description, target branch, and draft state through the
provider CLI. A successful PR opens immediately and joins the follow monitor;
creation deliberately requires the source branch to exist remotely instead of
hiding an implicit push. The dialog can also fill or replace its editable title
and Markdown description through the configured Codex or Claude Code CLI. That
read-only suggestion uses committed changes from the target branch's merge base
to local `HEAD`; it never includes unstaged drafts or creates the PR itself.
Vendor CLI health errors remain distinct from signed-out sessions, and sign-in
is preflighted before Strand reports that the browser or CLI flow has started.

---

## 1.1+ — Post-1.0
Expand All @@ -1635,8 +1660,9 @@ Changes and transfers keyboard focus to the destination.
Same static binary as the remote-SSH `strandd` (`--stdio` mode). Designed 2026-06-12:
`docs/strand-cli.md` + task breakdown in TASKS.md.
- Plugin / extension surface
- AI features (commit message suggestions, conflict hints) — PRD Q3
- AI features (writing suggestions, conflict hints) — PRD Q3
- ☑ Commit message suggestions from staged diffs (Codex / Claude Code CLIs)
- ☑ Pull-request title/description suggestions from committed branch diffs
- ◐ Built-in PR review surface — moved into 1.0 with the GitHub/Azure read-only
foundation; GitLab/Bitbucket adapters and review/merge parity continue here.

Expand Down
33 changes: 30 additions & 3 deletions TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,26 @@ tree: watch the agent work, review fast, accept or reject safely.
arrow or j/k selects and Enter/click opens. Back restores list focus, and
refresh, open-on-host, command-palette entry, and actionable CLI/auth errors
remain available.
- ☑ Persistent followed-PR monitoring (`repo_pull_request_for_branch`,
`repo_pull_request_activity`, `stores/pullRequests.ts`,
`PullRequestMonitor`): the active branch's open PR auto-follows without the
PR view mounted; manual Follow/Unfollow, muted auto-follow keys, hosted-PR
worktree deduplication, SQLite-backed baselines, bounded two-PR polling,
native coalesced notifications, and terminal auto-unfollow survive
navigation and relaunch.
- ☑ Create pull requests from the checked-out branch
(`repo_pull_request_create`, `PullRequestCreateDialog`): the PR toolbar and
command palette create GitHub or Azure DevOps PRs with title, description,
target branch, and draft state through the signed-in provider CLI, then
open and automatically follow the result. Strand never silently pushes the
source branch as part of PR creation. **Fill with Codex/Claude Code** uses
the configured AI subscription to draft editable title/description text
from the committed merge-base diff (`repo_suggest_pull_request`).
- ☑ Seamless stale-while-revalidate refresh (`PullRequests.tsx`): populated
list/detail/tabs/drafts/scroll stay mounted during updates and failures;
lightweight activity gates rich-detail reloads, patches reload only for a
changed head, and a stale patch remains readable but cannot submit inline
comments until its replacement succeeds.
- ☑ Hosted diff and changed-file browser (`repo_pull_request_diff`,
`PullRequestChanges`): provider patches load only when Changes opens; the
keyboard-operable 22% Pierre folder tree and compact Local Changes-style
Expand Down Expand Up @@ -1335,8 +1355,10 @@ tree: watch the agent work, review fast, accept or reject safely.
where the provider exposes a boundary, safe Open in worktree / Update branch,
suggestions, and unresolved-feedback export for external agents.
- ◐ Checks render provider states as green success, yellow running, red
failure, or neutral. Azure policies, merge queue/auto-complete, and
required-review detail remain.
failure, or neutral. Azure PR policy evaluations now join readiness and
background activity when their query succeeds; incomplete policy calls
remain neutral. Merge queue/auto-complete and richer required-review detail
remain.
- ◐ Hosted PR lifecycle actions.
- ☑ Merge with provider-supported strategies (`repo_pull_request_merge`,
stale-head guard, keyboard-operable `PullRequestMergeControl`, and command-palette action).
Expand Down Expand Up @@ -1734,18 +1756,23 @@ as the remote-SSH daemon** (`--stdio` mode) — shares the `strand-ops`
extraction above as prerequisite. **Do not start before 1.0 ships**
(ROADMAP §1.1+).

### AI (commit message suggestions)
### AI writing suggestions

- ☑ Rust `ai/` module + IPC (`ai_provider_*`, `repo_suggest_commit_message`)
- ☑ Settings → AI (ChatGPT / Claude Code sign-in, custom CLI paths)
- ☑ CommitBar Suggest + palette / ⌘⇧M shortcut
- ☑ Pull-request title/description suggestions from committed merge-base diffs
(`repo_suggest_pull_request`, Create PR **Fill with Codex/Claude Code**)
- ☑ Windows CLI spawning hardened (DAN-11: `ai/bin.rs` resolves `.exe`/`.cmd`/
`.bat` only — never npm's extensionless POSIX shims — and runs batch shims
via `cmd /C`; prompts travel over stdin; null stdin + 30s/120s timeouts so
an interactive CLI prompt can't hang the spinner forever; `CREATE_NO_WINDOW`
stops per-call console flashes in the release build; CommitBar surfaces
suggest failures inline as "Suggestion failed: …" instead of a silently
disabled sparkle / mislabeled "Commit failed:")
- ☑ Broken vendor-CLI installs stay distinct from signed-out sessions
(`AiProviderStatus.error`, auth-failure classification, and `--version`
login preflight prevent false “browser opened” messages)
- ☐ Rebase reword suggestions (share CommitBar generator)
- ☐ Conflict-resolution hints — PRD Q3 follow-up

Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ tauri-plugin-process = "2"
tauri-plugin-dialog = "2"
tauri-plugin-shell = "2"
tauri-plugin-os = "2"
tauri-plugin-notification = "2"

serde.workspace = true
serde_json.workspace = true
Expand Down
1 change: 1 addition & 0 deletions crates/strand-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"core:event:default",
"dialog:default",
"os:default",
"notification:default",
"updater:default",

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

simple test

"process:default",
"sql:default",
Expand Down
40 changes: 26 additions & 14 deletions crates/strand-tauri/src/ai/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,30 @@ pub fn status(cli_override: Option<&str>) -> AiProviderStatus {
installed: false,
logged_in: false,
account_hint: None,
error: None,
};
};

let (logged_in, hint) = match run_capture(&bin, &["auth", "status"], None, None, STATUS_TIMEOUT) {
Ok(out) => parse_auth_status(&out),
Err(_) => (false, None),
};
let (logged_in, hint, error) =
match run_capture(&bin, &["auth", "status"], None, None, STATUS_TIMEOUT) {
Ok(out) => {
let (logged_in, hint) = parse_auth_status(&out);
(logged_in, hint, None)
}
Err(err) if super::is_auth_failure(&err) => (false, None, None),
Err(err) => (
false,
None,
Some(super::cli_health_error("Claude Code", &err)),
),
};

AiProviderStatus {
provider: super::AiProvider::Anthropic,
installed: true,
logged_in,
account_hint: hint,
error,
}
}

Expand Down Expand Up @@ -53,6 +64,8 @@ fn parse_auth_status(out: &str) -> (bool, Option<String>) {

pub fn login(cli_override: Option<&str>) -> Result<(), String> {
let bin = resolve_claude(cli_override).ok_or_else(not_installed)?;
run_capture(&bin, &["--version"], None, None, STATUS_TIMEOUT)
.map_err(|err| super::cli_health_error("Claude Code", &err))?;
spawn_detached(&bin, &["auth", "login"], None)
}

Expand All @@ -61,13 +74,13 @@ pub fn logout(cli_override: Option<&str>) -> Result<(), String> {
run_capture(&bin, &["auth", "logout"], None, None, STATUS_TIMEOUT).map(|_| ())
}

pub fn suggest(repo_path: &Path, prompt: &str, cli_override: Option<&str>) -> Result<String, String> {
pub fn suggest(
repo_path: &Path,
prompt: &str,
cli_override: Option<&str>,
) -> Result<String, String> {
let bin = resolve_claude(cli_override).ok_or_else(not_installed)?;

let full_prompt = format!(
"{prompt}\n\nRemember: reply with JSON only: {{\"subject\":\"...\",\"body\":\"...\"}}"
);

// The prompt travels via stdin (`claude -p` reads it there): it can
// exceed the Windows command-line ceiling and, when the CLI is an npm
// `.cmd` shim run through `cmd /C`, multi-line args would be re-parsed.
Expand All @@ -86,13 +99,13 @@ pub fn suggest(repo_path: &Path, prompt: &str, cli_override: Option<&str>) -> Re
"*",
],
Some(repo_path),
Some(&full_prompt),
Some(prompt),
SUGGEST_TIMEOUT,
) {
Ok(out) => extract_claude_print_output(out),
Err(err) => {
let logged_in = status(cli_override).logged_in;
Err(super::map_cli_failure(logged_in, "Claude Code", err))
let status = status(cli_override);
Err(super::map_cli_failure(&status, "Claude Code", err))
}
}
}
Expand Down Expand Up @@ -129,8 +142,7 @@ mod tests {

#[test]
fn auth_status_json_reports_email() {
let (logged_in, hint) =
parse_auth_status(r#"{"loggedIn":true,"email":"a@b.c"}"#);
let (logged_in, hint) = parse_auth_status(r#"{"loggedIn":true,"email":"a@b.c"}"#);
assert!(logged_in);
assert_eq!(hint.as_deref(), Some("a@b.c"));
}
Expand Down
68 changes: 57 additions & 11 deletions crates/strand-tauri/src/ai/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ pub fn status(cli_override: Option<&str>) -> AiProviderStatus {
installed: false,
logged_in: false,
account_hint: None,
error: None,
};
};

let logged_in = run_capture(&bin, &["login", "status"], None, None, STATUS_TIMEOUT)
.map(|_| true)
.unwrap_or(false);
let (logged_in, error) =
match run_capture(&bin, &["login", "status"], None, None, STATUS_TIMEOUT) {
Ok(_) => (true, None),
Err(err) if super::is_auth_failure(&err) => (false, None),
Err(err) => (false, Some(super::cli_health_error("Codex", &err))),
};

AiProviderStatus {
provider: super::AiProvider::Openai,
Expand All @@ -28,11 +32,14 @@ pub fn status(cli_override: Option<&str>) -> AiProviderStatus {
} else {
None
},
error,
}
}

pub fn login(cli_override: Option<&str>) -> Result<(), String> {
let bin = resolve_codex(cli_override).ok_or_else(not_installed)?;
run_capture(&bin, &["--version"], None, None, STATUS_TIMEOUT)
.map_err(|err| super::cli_health_error("Codex", &err))?;
spawn_detached(&bin, &["login"], None)
}

Expand All @@ -41,13 +48,13 @@ pub fn logout(cli_override: Option<&str>) -> Result<(), String> {
run_capture(&bin, &["logout"], None, None, STATUS_TIMEOUT).map(|_| ())
}

pub fn suggest(repo_path: &Path, prompt: &str, cli_override: Option<&str>) -> Result<String, String> {
pub fn suggest(
repo_path: &Path,
prompt: &str,
cli_override: Option<&str>,
) -> Result<String, String> {
let bin = resolve_codex(cli_override).ok_or_else(not_installed)?;

let full_prompt = format!(
"{prompt}\n\nRemember: reply with JSON only: {{\"subject\":\"...\",\"body\":\"...\"}}"
);

// `-` makes `codex exec` read the prompt from stdin — see the note in
// `claude::suggest` for why prompts don't travel as argv.
match run_capture(
Expand All @@ -61,13 +68,13 @@ pub fn suggest(repo_path: &Path, prompt: &str, cli_override: Option<&str>) -> Re
"-",
],
Some(repo_path),
Some(&full_prompt),
Some(prompt),
SUGGEST_TIMEOUT,
) {
Ok(out) => Ok(out),
Err(err) => {
let logged_in = status(cli_override).logged_in;
Err(super::map_cli_failure(logged_in, "Codex", err))
let status = status(cli_override);
Err(super::map_cli_failure(&status, "Codex", err))
}
}
}
Expand All @@ -77,3 +84,42 @@ fn not_installed() -> String {
"Codex CLI not found — install it from {CODEX_INSTALL} or set a custom path in Settings → AI"
)
}

#[cfg(all(test, unix))]
mod tests {
use std::os::unix::fs::PermissionsExt;

use super::*;

#[test]
fn broken_launcher_is_health_error_and_login_does_not_start() {
let path = std::env::temp_dir().join(format!(
"strand-broken-codex-{}-{}",
std::process::id(),
std::thread::current().name().unwrap_or("test")
));
std::fs::write(
&path,
"#!/bin/sh\necho 'Error: spawn /vendor/codex ENOENT' >&2\nexit 1\n",
)
.unwrap();
let mut permissions = std::fs::metadata(&path).unwrap().permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&path, permissions).unwrap();

let override_path = path.to_str().unwrap();
let status = status(Some(override_path));
assert!(status.installed);
assert!(!status.logged_in);
assert!(status
.error
.as_deref()
.is_some_and(|error| error.contains("ENOENT")));

let login_error = login(Some(override_path)).unwrap_err();
assert!(login_error.contains("ENOENT"));
assert!(login_error.contains("Reinstall or update"));

std::fs::remove_file(path).ok();
}
}
Loading
Loading