Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a0d1284e1 | ||
|
|
fc6f3469b0 | ||
|
|
b95b5c04c1 | ||
|
|
ad2e700391 | ||
|
|
2dcef860a6 | ||
|
|
0bb5df7245 | ||
|
|
cb070e4f84 | ||
|
|
8fde211348 |
+13
-3
@@ -106,14 +106,24 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# The runner container is root with no `nixbld` group, but the installer
|
||||||
|
# writes `build-users-group = nixbld` anyway and then fails on it, so the
|
||||||
|
# config goes in first. Flakes are off by default and `nix build .#…`
|
||||||
|
# needs them. Written again afterwards in case the installer replaced it.
|
||||||
- name: Install nix
|
- name: Install nix
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL https://nixos.org/nix/install | sh -s -- --daemon --yes
|
mkdir -p /etc/nix
|
||||||
echo "/nix/var/nix/profiles/default/bin" >> "$GITHUB_PATH"
|
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||||
|
curl -fsSL https://nixos.org/nix/install | sh -s -- --no-daemon --yes
|
||||||
|
printf 'build-users-group =\nexperimental-features = nix-command flakes\n' > /etc/nix/nix.conf
|
||||||
|
echo "$HOME/.nix-profile/bin" >> "$GITHUB_PATH"
|
||||||
|
|
||||||
|
# The installer does not enable flakes, so `nix build .#…` needs the
|
||||||
|
# features passed explicitly — a local machine that has them in
|
||||||
|
# nix.conf will build fine while CI fails without this.
|
||||||
- name: build flake packages
|
- name: build flake packages
|
||||||
run: |
|
run: |
|
||||||
nix build \
|
nix --extra-experimental-features "nix-command flakes" build \
|
||||||
.#webFrontend \
|
.#webFrontend \
|
||||||
.#maestro \
|
.#maestro \
|
||||||
.#maestro-web \
|
.#maestro-web \
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ fn print_session_table(out: &mut impl Write, sessions: &[Session], info: &Daemon
|
|||||||
ActivityState::Active => "active",
|
ActivityState::Active => "active",
|
||||||
ActivityState::Idle => "idle",
|
ActivityState::Idle => "idle",
|
||||||
ActivityState::AwaitingInput => "awaiting",
|
ActivityState::AwaitingInput => "awaiting",
|
||||||
|
ActivityState::Error => "error",
|
||||||
};
|
};
|
||||||
// Show the live foreground command alongside the spawn command when
|
// Show the live foreground command alongside the spawn command when
|
||||||
// the daemon has observed one that differs (NAP-340).
|
// the daemon has observed one that differs (NAP-340).
|
||||||
@@ -176,6 +177,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -278,7 +278,11 @@ impl SessionManager {
|
|||||||
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
self.inner.worktree_mgr.add_existing(base_repo, branch)
|
||||||
}
|
}
|
||||||
.map_err(SessionError::Worktree)?;
|
.map_err(SessionError::Worktree)?;
|
||||||
(path.clone(), Some(path), Some(base_repo.clone()))
|
// Record the repository, not whatever the request named: a
|
||||||
|
// spawn from inside a linked worktree passes that worktree as
|
||||||
|
// `base_repo`, and clients group sessions by this path (#99).
|
||||||
|
let repo = crate::worktree::main_checkout_of(base_repo);
|
||||||
|
(path.clone(), Some(path), Some(repo))
|
||||||
}
|
}
|
||||||
None => (req.cwd.clone(), None, None),
|
None => (req.cwd.clone(), None, None),
|
||||||
};
|
};
|
||||||
@@ -349,6 +353,7 @@ impl SessionManager {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: ActivityState::Active,
|
activity: ActivityState::Active,
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
// The watcher fills this in on its first blocked-work pass; a
|
// The watcher fills this in on its first blocked-work pass; a
|
||||||
@@ -568,6 +573,7 @@ impl SessionManager {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: ActivityState::Idle,
|
activity: ActivityState::Idle,
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
|
|||||||
@@ -174,6 +174,13 @@ impl WorktreeManager {
|
|||||||
/// The repository's main checkout. `--git-common-dir` points at the shared
|
/// The repository's main checkout. `--git-common-dir` points at the shared
|
||||||
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
/// `.git` for every linked worktree, so its parent is the main tree. Falls
|
||||||
/// back to the input when git can't answer.
|
/// back to the input when git can't answer.
|
||||||
|
/// The repository a path belongs to: the main checkout, even when `path` is a
|
||||||
|
/// linked worktree. Clients group sessions by this (#99), and placement uses it
|
||||||
|
/// so a worktree is never nested inside another.
|
||||||
|
pub fn main_checkout_of(path: &Path) -> PathBuf {
|
||||||
|
main_checkout(path)
|
||||||
|
}
|
||||||
|
|
||||||
fn main_checkout(path: &Path) -> PathBuf {
|
fn main_checkout(path: &Path) -> PathBuf {
|
||||||
let out = Command::new("git")
|
let out = Command::new("git")
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
|
|||||||
@@ -947,3 +947,60 @@ async fn liveness_watcher_leaves_running_sessions_alone() {
|
|||||||
|
|
||||||
fx.manager.kill(s.id, false).await.unwrap();
|
fx.manager.kill(s.id, false).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #99: clients group a session under `worktree.base_repo`, so that field has
|
||||||
|
/// to name the repository even when the spawn request pointed at a linked
|
||||||
|
/// worktree. Recording the request verbatim put the session in a folder of its
|
||||||
|
/// own — `<repo>/worktrees/<branch>` — instead of under the repo.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn worktree_base_repo_records_the_main_checkout_not_the_request() {
|
||||||
|
let fx = DaemonFixture::new();
|
||||||
|
let repo = TempDir::new().unwrap();
|
||||||
|
init_repo(repo.path());
|
||||||
|
|
||||||
|
// A linked worktree of that repo, then a spawn whose `base_repo` names it
|
||||||
|
// rather than the main checkout — what happens when the operator spawns
|
||||||
|
// from inside a worktree.
|
||||||
|
let first = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: repo.path().to_path_buf(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: repo.path().to_path_buf(),
|
||||||
|
branch: "feat/one".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create first");
|
||||||
|
let inner_worktree = first.worktree.expect("first has a worktree").path;
|
||||||
|
|
||||||
|
let second = fx
|
||||||
|
.manager
|
||||||
|
.create(CreateSessionRequest {
|
||||||
|
cwd: inner_worktree.clone(),
|
||||||
|
command: "sleep 30".into(),
|
||||||
|
env: HashMap::new(),
|
||||||
|
metadata: HashMap::new(),
|
||||||
|
worktree: Some(WorktreeSpec {
|
||||||
|
base_repo: inner_worktree.clone(),
|
||||||
|
branch: "feat/two".into(),
|
||||||
|
create_branch: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("create second");
|
||||||
|
|
||||||
|
let wt = second.worktree.expect("second has a worktree");
|
||||||
|
assert_eq!(
|
||||||
|
wt.base_repo,
|
||||||
|
repo.path()
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| repo.path().to_path_buf()),
|
||||||
|
"base_repo must be the repository, not the worktree the request named",
|
||||||
|
);
|
||||||
|
assert_ne!(wt.base_repo, inner_worktree, "the worktree is not the repo");
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ fn sample(id: Uuid) -> Session {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
current_cwd: Some(PathBuf::from("/tmp/work/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ pub struct Session {
|
|||||||
/// from daemons that predate the field.
|
/// from daemons that predate the field.
|
||||||
#[serde(default = "epoch")]
|
#[serde(default = "epoch")]
|
||||||
pub last_activity_at: DateTime<Utc>,
|
pub last_activity_at: DateTime<Utc>,
|
||||||
|
/// Wall-clock time at which the session entered its current
|
||||||
|
/// [`ActivityState`] (#102) — how long it has been `Active`, `Idle` or
|
||||||
|
/// `AwaitingInput`. Distinct from [`Self::last_activity_at`], which only
|
||||||
|
/// tracks the last observed PTY output. Populated by #104; defaults to the
|
||||||
|
/// Unix epoch on payloads from daemons that predate the field.
|
||||||
|
#[serde(default = "epoch")]
|
||||||
|
pub activity_since: DateTime<Utc>,
|
||||||
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
|
/// The pane's *current* working directory (`#{pane_current_path}`) — distinct
|
||||||
/// from [`Session::cwd`], the directory it was spawned in. The activity
|
/// from [`Session::cwd`], the directory it was spawned in. The activity
|
||||||
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
|
/// watcher refreshes it as the user `cd`s around; it drives the web UI's
|
||||||
@@ -119,6 +126,9 @@ pub enum ActivityState {
|
|||||||
Idle,
|
Idle,
|
||||||
/// Blocked on a `read()` from the pty — waiting on the user.
|
/// Blocked on a `read()` from the pty — waiting on the user.
|
||||||
AwaitingInput,
|
AwaitingInput,
|
||||||
|
/// The agent running in the session reported a failure (#102). Never
|
||||||
|
/// inferred by the daemon; only set when an agent reports it (#105).
|
||||||
|
Error,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Optional worktree request attached to a `CreateSessionRequest`.
|
/// Optional worktree request attached to a `CreateSessionRequest`.
|
||||||
@@ -537,6 +547,9 @@ mod tests {
|
|||||||
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
|
last_activity_at: DateTime::parse_from_rfc3339("2026-05-24T12:05:00Z")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.with_timezone(&Utc),
|
.with_timezone(&Utc),
|
||||||
|
activity_since: DateTime::parse_from_rfc3339("2026-05-24T12:04:30Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&Utc),
|
||||||
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
current_cwd: Some(PathBuf::from("/home/naps62/projects/maestro/crates")),
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
@@ -588,6 +601,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn activity_since_defaults_to_epoch_on_legacy_payload() {
|
||||||
|
// A daemon predating #102 omits `activity_since`; it must deserialize
|
||||||
|
// to the forward-compat default rather than failing, and a present
|
||||||
|
// value round-trips.
|
||||||
|
let json = serde_json::json!({
|
||||||
|
"id": Uuid::from_u128(1),
|
||||||
|
"cwd": "/tmp",
|
||||||
|
"command": "bash",
|
||||||
|
"status": "Running",
|
||||||
|
"created_at": "2026-05-24T12:00:00Z"
|
||||||
|
});
|
||||||
|
let s: Session = serde_json::from_value(json).unwrap();
|
||||||
|
assert_eq!(s.activity_since, epoch());
|
||||||
|
|
||||||
|
let full = sample_session();
|
||||||
|
assert_eq!(full, round_trip(&full));
|
||||||
|
}
|
||||||
|
|
||||||
|
// #102: nothing infers `Error`; only #105's agent-reported failure sets it.
|
||||||
|
#[test]
|
||||||
|
fn error_activity_state_serializes_pascal_case_and_idle_stays_default() {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(ActivityState::Error).unwrap(),
|
||||||
|
serde_json::json!("Error")
|
||||||
|
);
|
||||||
|
assert_eq!(ActivityState::default(), ActivityState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn activity_state_serializes_pascal_case_and_defaults_idle() {
|
fn activity_state_serializes_pascal_case_and_defaults_idle() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub fn session(id: Uuid, cwd: &str, command: &str) -> Session {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1127,6 +1127,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: Utc::now(),
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}],
|
}],
|
||||||
@@ -1644,6 +1645,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
|
|||||||
@@ -194,6 +194,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
}
|
}
|
||||||
@@ -228,6 +229,7 @@ mod tests {
|
|||||||
foreground: None,
|
foreground: None,
|
||||||
activity: Default::default(),
|
activity: Default::default(),
|
||||||
last_activity_at: chrono::Utc::now(),
|
last_activity_at: chrono::Utc::now(),
|
||||||
|
activity_since: chrono::Utc::now(),
|
||||||
|
|
||||||
current_cwd: None,
|
current_cwd: None,
|
||||||
blocked_work: None,
|
blocked_work: None,
|
||||||
|
|||||||
+5
-1
@@ -272,7 +272,11 @@ function showMultiView(): void {
|
|||||||
// cloned session's dir + command.
|
// cloned session's dir + command.
|
||||||
function requestOpenSpawn(): void {
|
function requestOpenSpawn(): void {
|
||||||
showMultiView();
|
showMultiView();
|
||||||
mv.openDraft();
|
// A new tab, not a split of the pane you were reading (#100). "Spawn
|
||||||
|
// similar" and the fast-forward key below deliberately keep the old
|
||||||
|
// behaviour: both act on the session in front of you, so a split beside it
|
||||||
|
// is the point.
|
||||||
|
mv.openDraft(undefined, { newTab: true });
|
||||||
}
|
}
|
||||||
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn());
|
||||||
document.addEventListener("catalog-spawn-similar", (ev) => {
|
document.addEventListener("catalog-spawn-similar", (ev) => {
|
||||||
|
|||||||
@@ -282,3 +282,54 @@ describe("multiview pane-drag swap (#73)", () => {
|
|||||||
expect(nameInputOf(leaf2).value).toBe("west");
|
expect(nameInputOf(leaf2).value).toBe("west");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("openDraft newTab (#100)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stubFetch();
|
||||||
|
document.body.replaceChildren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens its own tab instead of splitting the focused session", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
const first = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
tab.focusedLeafId = first;
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.openDraft(undefined, { newTab: true });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(2);
|
||||||
|
// The tab you were reading is untouched: still one pane, still its session.
|
||||||
|
const original = leaves(layout.tabs[0].root);
|
||||||
|
expect(original).toHaveLength(1);
|
||||||
|
expect(original[0].sessionId).toBe("s1");
|
||||||
|
// The draft is alone in the new, now-active tab.
|
||||||
|
const fresh = leaves(layout.tabs[1].root);
|
||||||
|
expect(fresh).toHaveLength(1);
|
||||||
|
expect(fresh[0].sessionId).toBeNull();
|
||||||
|
expect(fresh[0].draft).not.toBeNull();
|
||||||
|
expect(layout.activeTabId).toBe(layout.tabs[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still splits when newTab is not asked for", async () => {
|
||||||
|
const layout = emptyLayout();
|
||||||
|
const tab = layout.tabs[0];
|
||||||
|
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||||
|
const mv = makeMv(layout);
|
||||||
|
document.body.appendChild(mv.el);
|
||||||
|
mv.render();
|
||||||
|
|
||||||
|
mv.openDraft();
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(layout.tabs).toHaveLength(1);
|
||||||
|
expect(leaves(tab.root)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+12
-3
@@ -57,7 +57,7 @@ export interface MultiView {
|
|||||||
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
/** Open a draft pane (issue #62): reuse the focused pane if it is already
|
||||||
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
* empty, otherwise split the focused pane. `seed` pre-fills the form
|
||||||
* ("Spawn similar" clones a session's cwd + command). */
|
* ("Spawn similar" clones a session's cwd + command). */
|
||||||
openDraft: (seed?: Partial<Draft>) => void;
|
openDraft: (seed?: Partial<Draft>, opts?: { newTab?: boolean }) => void;
|
||||||
/** Start dragging a sidebar session into the canvas (issue #32). The
|
/** Start dragging a sidebar session into the canvas (issue #32). The
|
||||||
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
* controller owns drag-vs-tap: a tap lets the row's normal click open it, a
|
||||||
* drag shows drop zones to place it. */
|
* drag shows drop zones to place it. */
|
||||||
@@ -412,9 +412,18 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
|||||||
opts.onOpenSessionsChange?.();
|
opts.onOpenSessionsChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDraft(seed?: Partial<Draft>): void {
|
function openDraft(seed?: Partial<Draft>, opts?: { newTab?: boolean }): void {
|
||||||
const tab = activeTab(layout);
|
|
||||||
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed });
|
||||||
|
if (opts?.newTab) {
|
||||||
|
// Spawn opens its own tab rather than halving whatever you were reading
|
||||||
|
// (#100). The empty-pane reuse below still applies within a tab.
|
||||||
|
const fresh = addTab(layout);
|
||||||
|
fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true);
|
||||||
|
findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft();
|
||||||
|
render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tab = activeTab(layout);
|
||||||
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
|
||||||
// Empty tab (or stale focus): the draft becomes the first pane.
|
// Empty tab (or stale focus): the draft becomes the first pane.
|
||||||
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);
|
||||||
|
|||||||
@@ -95,3 +95,59 @@ describe("mergeLinear", () => {
|
|||||||
expect(merged.path).toBe("a/b");
|
expect(merged.path).toBe("a/b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("worktree sessions group under their repository (#99)", () => {
|
||||||
|
/** A session the daemon spawned into a worktree of `repo`. */
|
||||||
|
function worktreeSession(id: string, repo: string, wt: string): Session {
|
||||||
|
return { ...session(id, wt), worktree: { path: wt, base_repo: repo } } as Session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-level folder names, after linear chains are collapsed. */
|
||||||
|
function topFolders(root: TreeFolder): string[] {
|
||||||
|
return root.children.filter((c) => c.kind === "folder").map((c) => (c as TreeFolder).name);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("nests under the repo, not the worktree directory", () => {
|
||||||
|
const root = buildTree(
|
||||||
|
[
|
||||||
|
worktreeSession(
|
||||||
|
"a",
|
||||||
|
"/home/n/tea/agent-skills",
|
||||||
|
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"/home/n",
|
||||||
|
"",
|
||||||
|
) as TreeFolder;
|
||||||
|
|
||||||
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
|
const repo = root.children[0] as TreeFolder;
|
||||||
|
expect(repo.children).toHaveLength(1);
|
||||||
|
expect(repo.children[0].kind).toBe("leaf");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts a repo's plain and worktree sessions side by side", () => {
|
||||||
|
const root = buildTree(
|
||||||
|
[
|
||||||
|
session("plain", "/home/n/tea/agent-skills"),
|
||||||
|
worktreeSession(
|
||||||
|
"wt",
|
||||||
|
"/home/n/tea/agent-skills",
|
||||||
|
"/home/n/tea/agent-skills/worktrees/largo-clarinet",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
"/home/n",
|
||||||
|
"",
|
||||||
|
) as TreeFolder;
|
||||||
|
|
||||||
|
expect(topFolders(root)).toEqual(["~/tea/agent-skills"]);
|
||||||
|
const repo = root.children[0] as TreeFolder;
|
||||||
|
expect(repo.children).toHaveLength(2);
|
||||||
|
expect(repo.children.every((c) => c.kind === "leaf")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a session without a worktree where it is", () => {
|
||||||
|
const root = buildTree([session("a", "/home/n/tea/arr")], "/home/n", "") as TreeFolder;
|
||||||
|
expect(topFolders(root)).toEqual(["~/tea/arr"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+4
-2
@@ -2,7 +2,7 @@
|
|||||||
// so it can be tested directly.
|
// so it can be tested directly.
|
||||||
|
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import { abbreviateCwd, sessionCwd, sessionName } from "./types.js";
|
import { abbreviateCwd, sessionName, sessionTreePath } from "./types.js";
|
||||||
|
|
||||||
export interface TreeFolder {
|
export interface TreeFolder {
|
||||||
kind: "folder";
|
kind: "folder";
|
||||||
@@ -30,7 +30,9 @@ export function buildTree(sessions: Session[], home: string | undefined, filter:
|
|||||||
const lc = filter.trim().toLowerCase();
|
const lc = filter.trim().toLowerCase();
|
||||||
|
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
const cwd = sessionCwd(s);
|
// Grouped by repository, so a worktree session lands under its repo
|
||||||
|
// rather than in a folder of its own (#99).
|
||||||
|
const cwd = sessionTreePath(s);
|
||||||
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
const dir = cwd ? abbreviateCwd(cwd, home) : "";
|
||||||
if (lc) {
|
if (lc) {
|
||||||
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
const haystack = `${sessionName(s)} ${dir}`.toLowerCase();
|
||||||
|
|||||||
+30
-3
@@ -9,8 +9,9 @@ export type IsoTimestamp = string;
|
|||||||
export type SessionStatus = "Running" | "Dead";
|
export type SessionStatus = "Running" | "Dead";
|
||||||
|
|
||||||
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
/** Daemon-inferred activity dimension, orthogonal to `SessionStatus` (NAP-340).
|
||||||
* Absent on payloads from daemons that predate the field. */
|
* Absent on payloads from daemons that predate the field. `Error` is only set
|
||||||
export type ActivityState = "Active" | "Idle" | "AwaitingInput";
|
* when an agent reports failure (#105); the daemon never infers it. */
|
||||||
|
export type ActivityState = "Active" | "Idle" | "AwaitingInput" | "Error";
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: Uuid;
|
id: Uuid;
|
||||||
@@ -27,6 +28,11 @@ export interface Session {
|
|||||||
activity?: ActivityState;
|
activity?: ActivityState;
|
||||||
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
/** Wall-clock of the most recent observed PTY output (NAP-340). */
|
||||||
last_activity_at?: IsoTimestamp;
|
last_activity_at?: IsoTimestamp;
|
||||||
|
/** Wall-clock of when the session entered its current `activity` state
|
||||||
|
* (#102) — distinct from `last_activity_at`, which only tracks PTY output.
|
||||||
|
* Defaults to the Unix epoch on payloads from daemons that predate the
|
||||||
|
* field. */
|
||||||
|
activity_since?: IsoTimestamp;
|
||||||
/** The pane's *current* working directory, as the daemon's activity watcher
|
/** The pane's *current* working directory, as the daemon's activity watcher
|
||||||
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
* observed it (`#{pane_current_path}`) — distinct from `cwd` (the spawn dir).
|
||||||
* Drives the directory-based session tree. Absent until first observed. */
|
* Drives the directory-based session tree. Absent until first observed. */
|
||||||
@@ -36,6 +42,16 @@ export interface Session {
|
|||||||
* without a worktree; all counts default to 0 on the wire for older
|
* without a worktree; all counts default to 0 on the wire for older
|
||||||
* daemons. */
|
* daemons. */
|
||||||
blocked_work?: InTheWay | null;
|
blocked_work?: InTheWay | null;
|
||||||
|
/** The worktree the daemon created for this session, absent when it did not
|
||||||
|
* create one. `base_repo` is the repository's main checkout, which is what
|
||||||
|
* the session tree groups by (#99). */
|
||||||
|
worktree?: SessionWorktree | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A session's daemon-created worktree (`SessionWorktree` on the wire). */
|
||||||
|
export interface SessionWorktree {
|
||||||
|
path: string;
|
||||||
|
base_repo: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One directory's immediate subdirectories (NAP-329). */
|
/** One directory's immediate subdirectories (NAP-329). */
|
||||||
@@ -231,12 +247,23 @@ export function sessionName(s: Session): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
/** The directory a session lives in: its live `current_cwd` if the daemon has
|
||||||
* observed one, else the spawn `cwd`. Drives the directory-based session tree. */
|
* observed one, else the spawn `cwd`. */
|
||||||
export function sessionCwd(s: Session): string | undefined {
|
export function sessionCwd(s: Session): string | undefined {
|
||||||
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
if (typeof s.current_cwd === "string" && s.current_cwd) return s.current_cwd;
|
||||||
return s.cwd || undefined;
|
return s.cwd || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a session sits in the directory tree. A worktree session belongs to
|
||||||
|
* its repository, not to the worktree (#99): otherwise `~/tea/agent-skills`
|
||||||
|
* grows a `~/tea/agent-skills/worktrees/largo-clarinet` folder holding one
|
||||||
|
* session of the same name, and the repository stops being where you look for
|
||||||
|
* the work. `base_repo` is the main checkout — the daemon resolves it. */
|
||||||
|
export function sessionTreePath(s: Session): string | undefined {
|
||||||
|
const repo = s.worktree?.base_repo;
|
||||||
|
if (typeof repo === "string" && repo) return repo;
|
||||||
|
return sessionCwd(s);
|
||||||
|
}
|
||||||
|
|
||||||
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
|
/** Collapse a daemon's home dir to `~`. `home` comes from `DaemonInfo.home_dir`;
|
||||||
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
|
* absent/empty leaves the path untouched. Mirrors the CLI's `abbreviate_dir`. */
|
||||||
export function abbreviateCwd(cwd: string, home?: string): string {
|
export function abbreviateCwd(cwd: string, home?: string): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user