diff --git a/backend/internal/httpd/apispec/openapi.yaml b/backend/internal/httpd/apispec/openapi.yaml index a279d460..72eaf6da 100644 --- a/backend/internal/httpd/apispec/openapi.yaml +++ b/backend/internal/httpd/apispec/openapi.yaml @@ -1261,6 +1261,8 @@ components: properties: activity: $ref: '#/components/schemas/DomainActivity' + branch: + type: string createdAt: format: date-time type: string diff --git a/backend/internal/httpd/controllers/dto.go b/backend/internal/httpd/controllers/dto.go index 2da2fe91..8a235a38 100644 --- a/backend/internal/httpd/controllers/dto.go +++ b/backend/internal/httpd/controllers/dto.go @@ -112,12 +112,14 @@ type CleanupSessionsQuery struct { } // SessionView is the session wire shape: the domain read model plus the -// session's attributed pull requests in the curated SessionPRFacts shape. One -// session can own many PRs (e.g. a stack), so prs is a list. The embedded -// domain.Session.PRs field is json:"-"; this curated prs is what serializes. +// display-safe branch name and the session's attributed pull requests in the +// curated SessionPRFacts shape. One session can own many PRs (e.g. a stack), so +// prs is a list. The embedded domain.Session.Metadata and domain.Session.PRs +// fields are json:"-"; these curated fields are what serialize. type SessionView struct { domain.Session - PRs []SessionPRFacts `json:"prs"` + Branch string `json:"branch,omitempty"` + PRs []SessionPRFacts `json:"prs"` } // ListSessionsResponse is the body of GET /api/v1/sessions. diff --git a/backend/internal/httpd/controllers/sessions.go b/backend/internal/httpd/controllers/sessions.go index b59d2d0a..e1473edd 100644 --- a/backend/internal/httpd/controllers/sessions.go +++ b/backend/internal/httpd/controllers/sessions.go @@ -427,7 +427,7 @@ func writeSessionPRError(w http.ResponseWriter, r *http.Request, err error) { } func sessionView(s domain.Session) SessionView { - return SessionView{Session: s, PRs: sessionPRFacts(s.PRs)} + return SessionView{Session: s, Branch: s.Metadata.Branch, PRs: sessionPRFacts(s.PRs)} } func sessionViews(sessions []domain.Session) []SessionView { diff --git a/backend/internal/httpd/controllers/sessions_test.go b/backend/internal/httpd/controllers/sessions_test.go index 1b588680..177e8c7c 100644 --- a/backend/internal/httpd/controllers/sessions_test.go +++ b/backend/internal/httpd/controllers/sessions_test.go @@ -170,6 +170,9 @@ func TestSessionsRoutes_DefaultToStubsWithoutService(t *testing.T) { func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { svc := newFakeSessionService() + s := svc.sessions["ao-1"] + s.Metadata = domain.SessionMetadata{Branch: "qa/modal-worker", WorkspacePath: "/tmp/private-worktree", RuntimeHandleID: "runtime-1", Prompt: "private prompt"} + svc.sessions["ao-1"] = s srv := newSessionTestServer(t, svc) body, status, _ := doRequest(t, srv, "GET", "/api/v1/sessions?project=ao", "") @@ -183,6 +186,22 @@ func TestSessionsAPI_ListSpawnGetAndActions(t *testing.T) { if len(list.Sessions) != 1 || list.Sessions[0].ID != "ao-1" || list.Sessions[0].Status != string(domain.StatusIdle) || list.Sessions[0].TerminalHandleID != "ao-1/terminal_0" { t.Fatalf("list = %#v", list) } + if list.Sessions[0].Branch != "qa/modal-worker" { + t.Fatalf("branch = %q, want qa/modal-worker", list.Sessions[0].Branch) + } + var rawList struct { + Sessions []map[string]any `json:"sessions"` + } + mustJSON(t, body, &rawList) + if _, ok := rawList.Sessions[0]["metadata"]; ok { + t.Fatalf("list leaked metadata: %s", body) + } + if _, ok := rawList.Sessions[0]["workspacePath"]; ok { + t.Fatalf("list leaked workspacePath: %s", body) + } + if _, ok := rawList.Sessions[0]["prompt"]; ok { + t.Fatalf("list leaked prompt: %s", body) + } body, status, _ = doRequest(t, srv, "POST", "/api/v1/sessions", `{"projectId":"ao","issueId":"ISS-1","kind":"worker","harness":"codex","prompt":"fix"}`) if status != http.StatusCreated { @@ -388,6 +407,7 @@ type sessionBody struct { Kind string `json:"kind"` Harness string `json:"harness"` DisplayName string `json:"displayName"` + Branch string `json:"branch"` Status string `json:"status"` TerminalHandleID string `json:"terminalHandleId"` } diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index 0bb8bd4a..bd8f3337 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -446,6 +446,7 @@ export interface components { }; ControllersSessionView: { activity: components["schemas"]["DomainActivity"]; + branch?: string; /** Format: date-time */ createdAt: string; displayName?: string; diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx index 309e60ec..2545a272 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.test.tsx @@ -48,6 +48,7 @@ describe("useWorkspaceQuery", () => { terminalHandleId: "term-1", displayName: "fix-bug", harness: "claude-code", + branch: "qa/modal-worker", status: "mergeable", isTerminated: false, updatedAt: "2026-06-10T16:15:04Z", @@ -81,12 +82,14 @@ describe("useWorkspaceQuery", () => { terminalHandleId: "term-1", title: "fix-bug", provider: "claude-code", + branch: "qa/modal-worker", status: "mergeable", }); expect(workspace.sessions[1]).toMatchObject({ id: "sess-2", title: "sess-2", provider: "codex", + branch: "session/sess-2", status: "working", }); }); diff --git a/frontend/src/renderer/hooks/useWorkspaceQuery.ts b/frontend/src/renderer/hooks/useWorkspaceQuery.ts index 0b6b4776..dda7d8c6 100644 --- a/frontend/src/renderer/hooks/useWorkspaceQuery.ts +++ b/frontend/src/renderer/hooks/useWorkspaceQuery.ts @@ -50,7 +50,7 @@ async function fetchWorkspaces(): Promise { title: session.displayName ?? session.issueId ?? session.id, provider: toAgentProvider(session.harness), kind: session.kind === "orchestrator" ? "orchestrator" : session.kind === "worker" ? "worker" : undefined, - branch: `session/${session.id}`, + branch: session.branch ?? `session/${session.id}`, status: toSessionStatus(session.status, session.isTerminated), createdAt: session.createdAt, updatedAt: session.updatedAt,