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
9 changes: 5 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
preview backed by recent runtime thread summaries, plus a read-only
`GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore
points. The extension now renders those restore points read-only in its Agent
View, and thread summaries include read-only workspace and branch metadata so
the VS Code Agent View can show when a thread or agent lane is on another
branch. Agent View and restore-point data now auto-refresh on a configurable
read-only interval so branch/workspace changes become visible without a
View, and thread summaries include read-only workspace, branch, current Git
head, and dirty-state metadata so the VS Code Agent View can show when a
thread or agent lane is on another branch or has changed worktree state. Agent
View and restore-point data now auto-refresh on a configurable
read-only interval so branch/workspace/status changes become visible without a
manual refresh. Agent View refreshes keep thread branch/workspace rows
independent from restore-point loading, so a snapshot-listing failure no
longer clears already-available thread metadata. This answers the VS Code GUI
Expand Down
9 changes: 5 additions & 4 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
preview backed by recent runtime thread summaries, plus a read-only
`GET /v1/snapshots` endpoint for GUI clients to inspect side-git restore
points. The extension now renders those restore points read-only in its Agent
View, and thread summaries include read-only workspace and branch metadata so
the VS Code Agent View can show when a thread or agent lane is on another
branch. Agent View and restore-point data now auto-refresh on a configurable
read-only interval so branch/workspace changes become visible without a
View, and thread summaries include read-only workspace, branch, current Git
head, and dirty-state metadata so the VS Code Agent View can show when a
thread or agent lane is on another branch or has changed worktree state. Agent
View and restore-point data now auto-refresh on a configurable
read-only interval so branch/workspace/status changes become visible without a
manual refresh. Agent View refreshes keep thread branch/workspace rows
independent from restore-point loading, so a snapshot-listing failure no
longer clears already-available thread metadata. This answers the VS Code GUI
Expand Down
94 changes: 92 additions & 2 deletions crates/tui/src/runtime_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,8 @@ struct ThreadSummary {
mode: String,
workspace: PathBuf,
branch: Option<String>,
head: Option<String>,
dirty: bool,
archived: bool,
updated_at: chrono::DateTime<Utc>,
latest_turn_id: Option<String>,
Expand All @@ -277,13 +279,22 @@ struct WorkspaceStatusResponse {
workspace: PathBuf,
git_repo: bool,
branch: Option<String>,
head: Option<String>,
dirty: bool,
staged: usize,
unstaged: usize,
untracked: usize,
ahead: Option<u32>,
behind: Option<u32>,
}

#[derive(Debug, Default)]
struct WorkspaceGitMetadata {
branch: Option<String>,
head: Option<String>,
dirty: bool,
}

#[derive(Debug, Serialize)]
struct SkillEntry {
name: String,
Expand Down Expand Up @@ -1241,13 +1252,16 @@ async fn list_threads_summary(
}
}

let workspace_git = collect_workspace_git_metadata(&thread.workspace);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Pass true to collect_workspace_git_metadata to include the dirty status check for thread summaries.

Suggested change
let workspace_git = collect_workspace_git_metadata(&thread.workspace);
let workspace_git = collect_workspace_git_metadata(&thread.workspace, true);

summaries.push(ThreadSummary {
id: thread.id,
title,
preview,
model: thread.model,
mode: thread.mode,
branch: current_git_branch(&thread.workspace),
branch: workspace_git.branch,
head: workspace_git.head,
dirty: workspace_git.dirty,
workspace: thread.workspace,
archived: thread.archived,
updated_at: thread.updated_at,
Expand Down Expand Up @@ -2000,6 +2014,8 @@ fn collect_workspace_status(workspace: &std::path::Path) -> WorkspaceStatusRespo
workspace: workspace.to_path_buf(),
git_repo: false,
branch: None,
head: None,
dirty: false,
staged: 0,
unstaged: 0,
untracked: 0,
Expand All @@ -2015,7 +2031,10 @@ fn collect_workspace_status(workspace: &std::path::Path) -> WorkspaceStatusRespo
}

status.git_repo = true;
status.branch = current_git_branch(workspace);
let metadata = collect_workspace_git_metadata(workspace);
status.branch = metadata.branch;
status.head = metadata.head;
status.dirty = metadata.dirty;

if let Some(porcelain) = run_git(workspace, &["status", "--porcelain=v1"]) {
Comment on lines +2034 to 2039
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

To avoid running the expensive git status --porcelain=v1 command twice (once inside collect_workspace_git_metadata and once directly here), pass false to collect_workspace_git_metadata to skip the dirty check, and compute status.dirty directly from the porcelain output.

Suggested change
let metadata = collect_workspace_git_metadata(workspace);
status.branch = metadata.branch;
status.head = metadata.head;
status.dirty = metadata.dirty;
if let Some(porcelain) = run_git(workspace, &["status", "--porcelain=v1"]) {
let metadata = collect_workspace_git_metadata(workspace, false);
status.branch = metadata.branch;
status.head = metadata.head;
if let Some(porcelain) = run_git(workspace, &["status", "--porcelain=v1"]) {
status.dirty = !porcelain.trim().is_empty();

for line in porcelain.lines() {
Expand Down Expand Up @@ -2049,6 +2068,22 @@ fn collect_workspace_status(workspace: &std::path::Path) -> WorkspaceStatusRespo
status
}

fn collect_workspace_git_metadata(workspace: &std::path::Path) -> WorkspaceGitMetadata {
let Some(repo_check) = run_git(workspace, &["rev-parse", "--is-inside-work-tree"]) else {
return WorkspaceGitMetadata::default();
};
if repo_check.trim() != "true" {
return WorkspaceGitMetadata::default();
}

WorkspaceGitMetadata {
branch: current_git_branch(workspace),
head: current_git_head(workspace),
dirty: run_git(workspace, &["status", "--porcelain=v1"])
.is_some_and(|porcelain| !porcelain.trim().is_empty()),
}
}
Comment on lines +2071 to +2085
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The current implementation spawns up to 5-6 separate git processes per workspace check (including redundant checks for --is-inside-work-tree and multiple rev-parse calls). This can be optimized to a single rev-parse call that queries the repository status, branch name, and short HEAD hash simultaneously, significantly improving performance.

fn collect_workspace_git_metadata(workspace: &std::path::Path, include_dirty: bool) -> WorkspaceGitMetadata {
    let Some(rev_parse) = run_git(workspace, &["rev-parse", "--is-inside-work-tree", "--abbrev-ref", "HEAD", "--short", "HEAD"]) else {
        return WorkspaceGitMetadata::default();
    };
    let mut lines = rev_parse.lines();
    let is_repo = lines.next().map(|s| s.trim() == "true").unwrap_or(false);
    if !is_repo {
        return WorkspaceGitMetadata::default();
    }
    let branch_raw = lines.next().map(|s| s.trim()).unwrap_or("");
    let head_raw = lines.next().map(|s| s.trim()).unwrap_or("");

    let branch = if branch_raw.is_empty() {
        None
    } else if branch_raw == "HEAD" {
        (!head_raw.is_empty()).then(|| format!("detached@{head_raw}"))
    } else {
        Some(branch_raw.to_string())
    };

    let head = (!head_raw.is_empty()).then(|| head_raw.to_string());
    let dirty = include_dirty && run_git(workspace, &["status", "--porcelain=v1"])
        .is_some_and(|porcelain| !porcelain.trim().is_empty());

    WorkspaceGitMetadata {
        branch,
        head,
        dirty,
    }
}


fn run_git(workspace: &std::path::Path, args: &[&str]) -> Option<String> {
let output = crate::dependencies::Git::output(args, workspace).ok()?;
if !output.status.success() {
Expand All @@ -2075,6 +2110,12 @@ fn current_git_branch(workspace: &std::path::Path) -> Option<String> {
(!short_hash.is_empty()).then(|| format!("detached@{short_hash}"))
}

fn current_git_head(workspace: &std::path::Path) -> Option<String> {
let head = run_git(workspace, &["rev-parse", "--short", "HEAD"])?;
let head = head.trim();
(!head.is_empty()).then(|| head.to_string())
}

fn resolve_skills_dir(config: &Config, workspace: &std::path::Path) -> PathBuf {
// Canonicalize the workspace once so the symlink-containment check below
// compares like-for-like. If the workspace can't be canonicalized at all
Expand Down Expand Up @@ -2459,6 +2500,43 @@ mod tests {
Ok(())
}

#[test]
fn workspace_status_reports_head_and_dirty_counts() -> Result<()> {
let tmp = tempfile::tempdir()?;
let repo = tmp.path().join("repo");
fs::create_dir_all(&repo)?;
run_test_git(&repo, &["init", "-b", "main"])?;
fs::write(repo.join("tracked.txt"), "clean\n")?;
run_test_git(&repo, &["add", "tracked.txt"])?;
run_test_git(
&repo,
&[
"-c",
"user.name=CodeWhale Test",
"-c",
"user.email=codewhale@example.invalid",
"commit",
"-m",
"init",
],
)?;

let clean = collect_workspace_status(&repo);
assert!(clean.git_repo);
assert_eq!(clean.branch.as_deref(), Some("main"));
assert!(clean.head.as_deref().is_some_and(|head| !head.is_empty()));
assert!(!clean.dirty);

fs::write(repo.join("tracked.txt"), "dirty\n")?;
fs::write(repo.join("untracked.txt"), "new\n")?;

let dirty = collect_workspace_status(&repo);
assert!(dirty.dirty);
assert_eq!(dirty.unstaged, 1);
assert_eq!(dirty.untracked, 1);
Ok(())
}

#[test]
fn session_detail_tool_use_preserves_caller_metadata() {
let detail = session_to_detail(saved_session_with_blocks(vec![
Expand Down Expand Up @@ -2949,6 +3027,10 @@ mod tests {
.as_str()
.context("missing git thread id")?
.to_string();
fs::write(
repo.join("dirty.txt"),
"worktree changed after thread spawn\n",
)?;

let plain_thread: serde_json::Value = client
.post(format!("http://{addr}/v1/threads"))
Expand Down Expand Up @@ -2979,13 +3061,21 @@ mod tests {
.find(|item| item["id"] == git_thread_id)
.context("missing git workspace summary")?;
assert_eq!(git_summary["branch"], "feature/agent");
assert!(
git_summary["head"]
.as_str()
.is_some_and(|head| !head.is_empty())
);
assert_eq!(git_summary["dirty"], true);
assert_eq!(git_summary["workspace"], repo.to_string_lossy().as_ref());

let plain_summary = summaries
.iter()
.find(|item| item["id"] == plain_thread_id)
.context("missing plain workspace summary")?;
assert_eq!(plain_summary["branch"], serde_json::Value::Null);
assert_eq!(plain_summary["head"], serde_json::Value::Null);
assert_eq!(plain_summary["dirty"], false);
assert_eq!(
plain_summary["workspace"],
non_git.to_string_lossy().as_ref()
Expand Down
4 changes: 4 additions & 0 deletions docs/RUNTIME_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,8 @@ workspace metadata:
"model": "deepseek-v4-pro",
"mode": "agent",
"branch": "feature/runtime-api",
"head": "abc1234",
"dirty": false,
"workspace": "/Users/you/projects/codewhale",
"archived": false,
"updated_at": "2026-06-06T05:43:00Z",
Expand All @@ -201,6 +203,8 @@ workspace metadata:

`branch` is resolved from the thread workspace at request time and may be
`null` when the workspace is not a Git repository or the branch cannot be read.
`head` is the current short Git commit for that workspace when available.
`dirty` is true when the workspace has staged, unstaged, or untracked changes.
`workspace` is included so editor clients can show when an agent lane is working
outside the current VS Code folder.

Expand Down
2 changes: 1 addition & 1 deletion docs/V0_9_0_RELEASE_ACCEPTANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ config source, result, and follow-up issue or PR.
| Transcript tool-collapse smoke or explicit defer | UX steward | ship | #2776 (`c76ec4752`) landed dense successful tool-run collapse with guardrails for failed/running/shell/patch/review/diff cells; focused widget coverage includes `chat_widget_collapses_dense_tool_runs_by_default`, `chat_widget_expands_dense_tool_runs_on_demand`, and `chat_widget_expanded_mode_leaves_dense_tool_runs_visible`. |
| Sidebar detail popovers smoke or explicit defer | UX steward | ship | #2778 (`3cb49233e`) added row-level hover metadata and wrapping detail popovers for truncated Work/Tasks/Agents rows; #2806 (`19f5c7aa6`) preserved current sub-agent progress in the sidebar hover text. Focused coverage includes `sidebar_hover_rows_mark_source_text_diff_as_truncated` and `subagent_hover_text_preserves_full_agent_id_and_progress`. |
| Plan review/handoff artifact smoke | Plan steward | ship | #2770 (`7ac8063b6`) added rich PlanArtifact sections through the transcript/Plan prompt path; focused coverage includes `plan_update_cell_renders_rich_artifact_metadata` and `plan_prompt_renders_rich_plan_artifact_sections`. |
| VS Code Agent View branch/workspace visibility smoke | GUI steward | ship | #2825 (`1bacaf763`) added `workspace` / `branch` metadata to `/v1/threads/summary`; #2832 (`50b773f1d`) added read-only auto-refresh so branch/workspace changes can appear without manual refresh. |
| VS Code Agent View branch/workspace visibility smoke | GUI steward | ship | #2825 (`1bacaf763`) added `workspace` / `branch` metadata to `/v1/threads/summary`; #2832 (`50b773f1d`) added read-only auto-refresh so branch/workspace changes can appear without manual refresh. The current stewardship slice extends the same read-only metadata with current Git `head` and `dirty` worktree state for editor/agent-lane visibility. |

## v0.9.0 Feature Gates

Expand Down
Loading