on main help sign in
feat(server): track which session each client is currently viewing

Each connected client can now tell the server which session it is looking at, and the server keeps that information alongside the rest of the client state. The endpoint refuses to accept updates from clients that have not opened an event stream yet, so stale or partially connected clients cannot influence the count. The TUI reports its current session whenever it loads or starts a new session. This lays the groundwork for surfacing how many clients are currently watching a session. Co-Authored-By: Charm Crush <crush@charm.land>

commit178cd11dd49f322b9701f3ec2484f3b7a816c6e2 authorChristian Rocha <christian@rocha.is> dateTue 19 May 2026, 23:44 (5 months ago) parente764240
13 files changed+469−6
internal/backend/backend.go+44−5 internal/backend/backend_test.go+181 internal/backend/testing.go+12 internal/client/proto.go+24 internal/proto/proto.go+6 internal/server/multiclient_test.go+123 internal/server/proto.go+35 internal/server/server.go+1 internal/ui/model/session.go+21−1 internal/ui/model/ui.go+1 internal/workspace/app_workspace.go+7 internal/workspace/client_workspace.go+8 internal/workspace/workspace.go+6
internal/backend/backend.go+44−5
@@ -32,6 +32,7 @@ var (
3232 ErrInvalidPermissionAction = errors.New("invalid permission action")
3333 ErrUnknownCommand = errors.New("unknown command")
3434 ErrInvalidClientID = errors.New("invalid client_id")
35+ ErrClientNotAttached = errors.New("client not attached")
3536)
3637
3738// DefaultCreateGrace is the window in which a client must open an SSE
@@ -77,13 +78,18 @@ type Backend struct {
7778// - holdTimer is non-nil iff the client created the workspace but has
7879// not yet attached an SSE stream; it fires after createGrace and
7980// releases the hold.
81+// - currentSessionID records which session this client is currently
82+// viewing. Empty string means the client has no session selected
83+// (e.g. the landing screen). Cleared automatically when the
84+// clientState entry is removed.
8085//
81−// The two are mutually exclusive in practice (the hold timer is stopped
82−// the moment an SSE stream attaches), but both being zero/nil means the
83−// entry has been released and should be removed.
86+// streams and holdTimer are mutually exclusive in practice (the hold
87+// timer is stopped the moment an SSE stream attaches), but both being
88+// zero/nil means the entry has been released and should be removed.
8489type clientState struct {
85− streams int
86− holdTimer *time.Timer
90+ streams int
91+ holdTimer *time.Timer
92+ currentSessionID string
8793}
8894
8995// Workspace represents a running [app.App] workspace with its
@@ -468,6 +474,39 @@ func (b *Backend) DeleteWorkspace(id, clientID string) error {
468474 return b.releaseHold(id, clientID)
469475}
470476
477+// SetCurrentSession records which session the given client is
478+// currently viewing within the workspace. Passing an empty sessionID
479+// clears the client's current-session entry (e.g. the client has
480+// returned to the landing screen).
481+//
482+// The client must be actually attached — i.e. its [clientState] entry
483+// must exist and have at least one live stream. A bare creation hold
484+// (streams == 0) is rejected with [ErrClientNotAttached]. This
485+// guards against zombie writes from a client that has detached and
486+// against ghost presence from a hold-only client that never opened an
487+// SSE stream.
488+func (b *Backend) SetCurrentSession(workspaceID, clientID, sessionID string) error {
489+ if _, err := validateClientID(clientID); err != nil {
490+ return err
491+ }
492+ ws, ok := b.workspaces.Get(workspaceID)
493+ if !ok {
494+ return ErrWorkspaceNotFound
495+ }
496+ ws.clientsMu.Lock()
497+ defer ws.clientsMu.Unlock()
498+ cs, ok := ws.clients[clientID]
499+ if !ok || cs.streams == 0 {
500+ // No entry, or hold-only (no live stream): refuse the
501+ // write. The presence record this is meant to feed
502+ // should only reflect clients that can actually observe
503+ // session events.
504+ return ErrClientNotAttached
505+ }
506+ cs.currentSessionID = sessionID
507+ return nil
508+}
509+
471510// GetWorkspaceProto returns the proto representation of a workspace.
472511func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
473512 ws, err := b.GetWorkspace(id)
internal/backend/backend_test.go+181
@@ -951,3 +951,184 @@ func TestAttachClient_RacesWithTeardown(t *testing.T) {
951951 }
952952 }
953953}
954+
955+// TestSetCurrentSession_BasicAttachAndSwitch verifies the happy path:
956+// an attached client can set its current session, a second attached
957+// client can target the same session, and one of them can switch to a
958+// different session without disturbing the other's record.
959+func TestSetCurrentSession_BasicAttachAndSwitch(t *testing.T) {
960+ t.Parallel()
961+
962+ b, _ := newTestBackend(t)
963+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-basic")
964+
965+ cidA := newClientID(t)
966+ cidB := newClientID(t)
967+ require.NoError(t, b.AttachClient(ws.ID, cidA))
968+ require.NoError(t, b.AttachClient(ws.ID, cidB))
969+
970+ require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1"))
971+ ws.clientsMu.Lock()
972+ require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
973+ ws.clientsMu.Unlock()
974+
975+ require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1"))
976+ ws.clientsMu.Lock()
977+ require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
978+ require.Equal(t, "S1", ws.clients[cidB].currentSessionID)
979+ ws.clientsMu.Unlock()
980+
981+ // B switches to S2; counts redistribute.
982+ require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2"))
983+ ws.clientsMu.Lock()
984+ require.Equal(t, "S1", ws.clients[cidA].currentSessionID)
985+ require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
986+ ws.clientsMu.Unlock()
987+
988+ // A clears its selection.
989+ require.NoError(t, b.SetCurrentSession(ws.ID, cidA, ""))
990+ ws.clientsMu.Lock()
991+ require.Empty(t, ws.clients[cidA].currentSessionID)
992+ require.Equal(t, "S2", ws.clients[cidB].currentSessionID)
993+ ws.clientsMu.Unlock()
994+
995+ // Drain to release the workspace.
996+ b.DetachClient(ws.ID, cidA)
997+ b.DetachClient(ws.ID, cidB)
998+}
999+
1000+// TestSetCurrentSession_DetachClearsEntry verifies the implicit
1001+// cleanup: once a client's [clientState] entry is removed (last
1002+// stream closed), its currentSessionID is gone with it.
1003+func TestSetCurrentSession_DetachClearsEntry(t *testing.T) {
1004+ t.Parallel()
1005+
1006+ b, _ := newTestBackend(t)
1007+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-detach")
1008+
1009+ // Anchor client so the workspace is not torn down when cid
1010+ // detaches.
1011+ anchor := newClientID(t)
1012+ require.NoError(t, b.AttachClient(ws.ID, anchor))
1013+
1014+ cid := newClientID(t)
1015+ require.NoError(t, b.AttachClient(ws.ID, cid))
1016+ require.NoError(t, b.SetCurrentSession(ws.ID, cid, "S2"))
1017+
1018+ b.DetachClient(ws.ID, cid)
1019+
1020+ ws.clientsMu.Lock()
1021+ _, present := ws.clients[cid]
1022+ ws.clientsMu.Unlock()
1023+ require.False(t, present, "detach must remove the clientState entry along with its currentSessionID")
1024+
1025+ // A follow-up SetCurrentSession on the gone client must be
1026+ // rejected with ErrClientNotAttached.
1027+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S3"), ErrClientNotAttached)
1028+
1029+ b.DetachClient(ws.ID, anchor)
1030+}
1031+
1032+// TestSetCurrentSession_RejectsHoldOnly verifies that a registered
1033+// client whose only claim is a creation hold (streams == 0) cannot
1034+// influence presence: SetCurrentSession returns ErrClientNotAttached
1035+// and the entry's currentSessionID stays empty.
1036+func TestSetCurrentSession_RejectsHoldOnly(t *testing.T) {
1037+ t.Parallel()
1038+
1039+ b, _ := newTestBackend(t)
1040+ // Keep the grace window large so the hold survives the test.
1041+ b.createGrace = time.Hour
1042+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-hold")
1043+
1044+ cid := newClientID(t)
1045+ b.registerClient(ws, cid)
1046+
1047+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S1"), ErrClientNotAttached)
1048+
1049+ ws.clientsMu.Lock()
1050+ require.Empty(t, ws.clients[cid].currentSessionID, "hold-only client must not write a session id")
1051+ ws.clientsMu.Unlock()
1052+
1053+ // Drain.
1054+ require.NoError(t, b.releaseHold(ws.ID, cid))
1055+}
1056+
1057+// TestSetCurrentSession_UnknownClient verifies that a client with no
1058+// entry at all is rejected with ErrClientNotAttached.
1059+func TestSetCurrentSession_UnknownClient(t *testing.T) {
1060+ t.Parallel()
1061+
1062+ b, _ := newTestBackend(t)
1063+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-unknown")
1064+
1065+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, newClientID(t), "S1"), ErrClientNotAttached)
1066+}
1067+
1068+// TestSetCurrentSession_RejectsBadInputs covers the validation
1069+// branches: empty/malformed client_id and unknown workspace.
1070+func TestSetCurrentSession_RejectsBadInputs(t *testing.T) {
1071+ t.Parallel()
1072+
1073+ b, _ := newTestBackend(t)
1074+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-bad")
1075+
1076+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, "", "S1"), ErrInvalidClientID)
1077+ require.ErrorIs(t, b.SetCurrentSession(ws.ID, "not-a-uuid", "S1"), ErrInvalidClientID)
1078+
1079+ require.ErrorIs(
1080+ t,
1081+ b.SetCurrentSession("00000000-0000-0000-0000-000000000000", newClientID(t), "S1"),
1082+ ErrWorkspaceNotFound,
1083+ )
1084+}
1085+
1086+// TestSetCurrentSession_RaceWithDetach exercises concurrent
1087+// SetCurrentSession updates from one client racing against detach
1088+// on a second client. The final state must be self-consistent: any
1089+// remaining clientState entries reflect a coherent
1090+// (streams, currentSessionID) pair.
1091+func TestSetCurrentSession_RaceWithDetach(t *testing.T) {
1092+ t.Parallel()
1093+
1094+ b, _ := newTestBackend(t)
1095+ ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-race")
1096+
1097+ cidA := newClientID(t)
1098+ cidB := newClientID(t)
1099+ require.NoError(t, b.AttachClient(ws.ID, cidA))
1100+ require.NoError(t, b.AttachClient(ws.ID, cidB))
1101+
1102+ var wg sync.WaitGroup
1103+ const updates = 200
1104+ wg.Add(3)
1105+ go func() {
1106+ defer wg.Done()
1107+ for i := range updates {
1108+ // Errors are tolerated: once cidA detaches,
1109+ // further updates against cidA must return
1110+ // ErrClientNotAttached but never panic.
1111+ _ = b.SetCurrentSession(ws.ID, cidA, "SA")
1112+ _ = i
1113+ }
1114+ }()
1115+ go func() {
1116+ defer wg.Done()
1117+ for i := range updates {
1118+ _ = b.SetCurrentSession(ws.ID, cidB, "SB")
1119+ _ = i
1120+ }
1121+ }()
1122+ go func() {
1123+ defer wg.Done()
1124+ // Single concurrent detach of cidA partway through.
1125+ b.DetachClient(ws.ID, cidA)
1126+ }()
1127+ wg.Wait()
1128+
1129+ ws.clientsMu.Lock()
1130+ defer ws.clientsMu.Unlock()
1131+ require.NotContains(t, ws.clients, cidA, "detached client must be gone")
1132+ require.Contains(t, ws.clients, cidB, "remaining client must still be present")
1133+ require.Equal(t, "SB", ws.clients[cidB].currentSessionID, "remaining client must keep its last set session")
1134+}
internal/backend/testing.go+12
@@ -18,3 +18,15 @@ func InsertWorkspaceForTest(b *Backend, ws *Workspace) {
1818 b.pathIndex[ws.resolvedPath] = ws.ID
1919 }
2020}
21+
22+// RegisterClientForTesting installs a creation hold for clientID on
23+// ws using the backend's normal registerClient path. Intended for
24+// tests in other packages that need to drive a hold-only client
25+// (streams == 0) without booting a real CreateWorkspace flow.
26+func RegisterClientForTesting(b *Backend, ws *Workspace, clientID string) error {
27+ if _, err := validateClientID(clientID); err != nil {
28+ return err
29+ }
30+ b.registerClient(ws, clientID)
31+ return nil
32+}
internal/client/proto.go+24
@@ -86,6 +86,30 @@ func (c *Client) DeleteWorkspace(ctx context.Context, id string) error {
8686 return nil
8787}
8888
89+// SetCurrentSession reports the client's current-session selection
90+// for the named workspace. An empty sessionID clears the entry. The
91+// request carries the process-scoped client ID minted in [NewClient]
92+// as a query parameter so the server can route the update to the
93+// correct [clientState] entry.
94+func (c *Client) SetCurrentSession(ctx context.Context, workspaceID, sessionID string) error {
95+ q := url.Values{"client_id": []string{c.clientID}}
96+ rsp, err := c.post(
97+ ctx,
98+ fmt.Sprintf("/workspaces/%s/current-session", workspaceID),
99+ q,
100+ jsonBody(proto.CurrentSession{SessionID: sessionID}),
101+ http.Header{"Content-Type": []string{"application/json"}},
102+ )
103+ if err != nil {
104+ return fmt.Errorf("failed to set current session: %w", err)
105+ }
106+ defer rsp.Body.Close()
107+ if rsp.StatusCode != http.StatusOK {
108+ return fmt.Errorf("failed to set current session: status code %d", rsp.StatusCode)
109+ }
110+ return nil
111+}
112+
89113// SubscribeEvents subscribes to server-sent events for a workspace.
90114func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
91115 events := make(chan any, 100)
internal/proto/proto.go+6
@@ -36,6 +36,12 @@ type ConfigChanged struct {
3636 WorkspaceID string `json:"workspace_id"`
3737}
3838
39+// CurrentSession is the request body for the per-client
40+// current-session endpoint. An empty SessionID clears the entry.
41+type CurrentSession struct {
42+ SessionID string `json:"session_id"`
43+}
44+
3945// AgentInfo represents information about the agent.
4046type AgentInfo struct {
4147 IsBusy bool `json:"is_busy"`
internal/server/multiclient_test.go+123
@@ -10,9 +10,26 @@ import (
1010
1111 "github.com/charmbracelet/crush/internal/backend"
1212 "github.com/charmbracelet/crush/internal/proto"
13+ "github.com/google/uuid"
1314 "github.com/stretchr/testify/require"
1415)
1516
17+// installSyntheticWorkspace creates a synthetic [backend.Workspace]
18+// registered with the controller's backend, suitable for handler-level
19+// tests that do not need a real [app.App]. The workspace's ID is a
20+// fresh UUID and its path is a tempdir; teardown is the caller's
21+// responsibility (handlers should not rely on synthetic workspaces
22+// disappearing automatically).
23+func installSyntheticWorkspace(t *testing.T, c *controllerV1) *backend.Workspace {
24+ t.Helper()
25+ ws := &backend.Workspace{
26+ ID: uuid.New().String(),
27+ Path: t.TempDir(),
28+ }
29+ backend.InsertWorkspaceForTest(c.backend, ws)
30+ return ws
31+}
32+
1633// newTestController builds a controllerV1 around a backend without a
1734// real config store, suitable for handler-level 400 tests.
1835func newTestController() *controllerV1 {
@@ -105,3 +122,109 @@ func TestSubscribeEvents_RejectsMalformedClientID(t *testing.T) {
105122
106123 require.Equal(t, http.StatusBadRequest, rec.Code)
107124}
125+
126+// postCurrentSession is a small helper that POSTs the JSON body to
127+// /v1/workspaces/{id}/current-session?client_id=cid and returns the
128+// recorder. It does not require a real listener.
129+func postCurrentSession(t *testing.T, c *controllerV1, wsID, clientID, sessionID string) *httptest.ResponseRecorder {
130+ t.Helper()
131+ body, err := json.Marshal(proto.CurrentSession{SessionID: sessionID})
132+ require.NoError(t, err)
133+ url := "/v1/workspaces/" + wsID + "/current-session"
134+ if clientID != "" {
135+ url += "?client_id=" + clientID
136+ }
137+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body))
138+ req.SetPathValue("id", wsID)
139+ req.Header.Set("Content-Type", "application/json")
140+ rec := httptest.NewRecorder()
141+ c.handlePostWorkspaceCurrentSession(rec, req)
142+ return rec
143+}
144+
145+func TestPostCurrentSession_RejectsMissingClientID(t *testing.T) {
146+ t.Parallel()
147+ c := newTestController()
148+
149+ body, err := json.Marshal(proto.CurrentSession{SessionID: "S1"})
150+ require.NoError(t, err)
151+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/workspaces/abc/current-session", bytes.NewReader(body))
152+ req.SetPathValue("id", "abc")
153+ req.Header.Set("Content-Type", "application/json")
154+ rec := httptest.NewRecorder()
155+
156+ c.handlePostWorkspaceCurrentSession(rec, req)
157+
158+ require.Equal(t, http.StatusBadRequest, rec.Code)
159+}
160+
161+func TestPostCurrentSession_RejectsMalformedClientID(t *testing.T) {
162+ t.Parallel()
163+ c := newTestController()
164+
165+ rec := postCurrentSession(t, c, "abc", "not-a-uuid", "S1")
166+ require.Equal(t, http.StatusBadRequest, rec.Code)
167+}
168+
169+func TestPostCurrentSession_RejectsBadBody(t *testing.T) {
170+ t.Parallel()
171+ c := newTestController()
172+
173+ cid := uuid.New().String()
174+ url := "/v1/workspaces/abc/current-session?client_id=" + cid
175+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader([]byte("not-json")))
176+ req.SetPathValue("id", "abc")
177+ req.Header.Set("Content-Type", "application/json")
178+ rec := httptest.NewRecorder()
179+
180+ c.handlePostWorkspaceCurrentSession(rec, req)
181+
182+ require.Equal(t, http.StatusBadRequest, rec.Code)
183+}
184+
185+func TestPostCurrentSession_UnknownWorkspace(t *testing.T) {
186+ t.Parallel()
187+ c := newTestController()
188+
189+ rec := postCurrentSession(t, c, uuid.New().String(), uuid.New().String(), "S1")
190+ require.Equal(t, http.StatusNotFound, rec.Code)
191+}
192+
193+func TestPostCurrentSession_UnknownClient(t *testing.T) {
194+ t.Parallel()
195+ c := newTestController()
196+ ws := installSyntheticWorkspace(t, c)
197+
198+ rec := postCurrentSession(t, c, ws.ID, uuid.New().String(), "S1")
199+ require.Equal(t, http.StatusNotFound, rec.Code)
200+}
201+
202+func TestPostCurrentSession_HoldOnly(t *testing.T) {
203+ t.Parallel()
204+ c := newTestController()
205+ ws := installSyntheticWorkspace(t, c)
206+
207+ cid := uuid.New().String()
208+ require.NoError(t, backend.RegisterClientForTesting(c.backend, ws, cid))
209+ t.Cleanup(func() { _ = c.backend.DeleteWorkspace(ws.ID, cid) })
210+
211+ rec := postCurrentSession(t, c, ws.ID, cid, "S1")
212+ require.Equal(t, http.StatusNotFound, rec.Code, "hold-only client must be rejected")
213+}
214+
215+func TestPostCurrentSession_AttachedClientSucceeds(t *testing.T) {
216+ t.Parallel()
217+ c := newTestController()
218+ ws := installSyntheticWorkspace(t, c)
219+
220+ cid := uuid.New().String()
221+ require.NoError(t, c.backend.AttachClient(ws.ID, cid))
222+ t.Cleanup(func() { c.backend.DetachClient(ws.ID, cid) })
223+
224+ rec := postCurrentSession(t, c, ws.ID, cid, "S1")
225+ require.Equal(t, http.StatusOK, rec.Code)
226+
227+ // Clearing also returns 200.
228+ rec = postCurrentSession(t, c, ws.ID, cid, "")
229+ require.Equal(t, http.StatusOK, rec.Code)
230+}
internal/server/proto.go+35
@@ -151,6 +151,39 @@ func (c *controllerV1) requireClientID(w http.ResponseWriter, r *http.Request) (
151151 return cid, true
152152}
153153
154+// handlePostWorkspaceCurrentSession records the calling client's
155+// current session selection for the workspace. An empty session_id
156+// clears the entry (e.g. the client is on the landing screen).
157+//
158+// @Summary Set current session for a client
159+// @Tags workspaces
160+// @Accept json
161+// @Produce json
162+// @Param id path string true "Workspace ID"
163+// @Param client_id query string true "Client ID (UUID)"
164+// @Param request body proto.CurrentSession true "Current session selection"
165+// @Success 200
166+// @Failure 400 {object} proto.Error
167+// @Failure 404 {object} proto.Error
168+// @Router /workspaces/{id}/current-session [post]
169+func (c *controllerV1) handlePostWorkspaceCurrentSession(w http.ResponseWriter, r *http.Request) {
170+ id := r.PathValue("id")
171+ clientID, ok := c.requireClientID(w, r)
172+ if !ok {
173+ return
174+ }
175+ var req proto.CurrentSession
176+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
177+ c.server.logError(r, "Failed to decode request", "error", err)
178+ jsonError(w, http.StatusBadRequest, "failed to decode request")
179+ return
180+ }
181+ if err := c.backend.SetCurrentSession(id, clientID, req.SessionID); err != nil {
182+ c.handleError(w, r, err)
183+ return
184+ }
185+}
186+
154187// handleDeleteWorkspaces deletes a workspace.
155188//
156189// @Summary Delete workspace
@@ -1005,6 +1038,8 @@ func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err e
10051038 status = http.StatusBadRequest
10061039 case errors.Is(err, backend.ErrInvalidClientID):
10071040 status = http.StatusBadRequest
1041+ case errors.Is(err, backend.ErrClientNotAttached):
1042+ status = http.StatusNotFound
10081043 }
10091044 c.server.logError(r, err.Error())
10101045 jsonError(w, status, err.Error())
internal/server/server.go+1
@@ -113,6 +113,7 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
113113 mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces)
114114 mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
115115 mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces)
116+ mux.HandleFunc("POST /v1/workspaces/{id}/current-session", c.handlePostWorkspaceCurrentSession)
116117 mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace)
117118 mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig)
118119 mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
internal/ui/model/session.go+21−1
@@ -64,8 +64,13 @@ type SessionFile struct {
6464// the diff statistics (additions and deletions) for each file in the session.
6565// It returns a tea.Cmd that, when executed, fetches the session data and
6666// returns a sessionFilesLoadedMsg containing the processed session files.
67+//
68+// The returned batch also reports the new current-session selection to
69+// the workspace so the server can update its per-client presence map.
70+// That report is fire-and-forget: errors are logged at debug and the
71+// UI never blocks on the call.
6772func (m *UI) loadSession(sessionID string) tea.Cmd {
68− return func() tea.Msg {
73+ load := func() tea.Msg {
6974 session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
7075 if err != nil {
7176 return util.ReportError(err)
@@ -87,6 +92,21 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
8792 readFiles: readFiles,
8893 }
8994 }
95+ return tea.Batch(load, m.reportCurrentSession(sessionID))
96+}
97+
98+// reportCurrentSession returns a fire-and-forget tea.Cmd that
99+// informs the workspace which session this client is currently
100+// viewing. Errors are logged at debug only; the call is a hint
101+// for server-side presence tracking, not correctness-critical
102+// state.
103+func (m *UI) reportCurrentSession(sessionID string) tea.Cmd {
104+ return func() tea.Msg {
105+ if err := m.com.Workspace.SetCurrentSession(context.Background(), sessionID); err != nil {
106+ slog.Debug("Failed to report current session", "session_id", sessionID, "error", err)
107+ }
108+ return nil
109+ }
90110}
91111
92112func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
internal/ui/model/ui.go+1
@@ -3478,6 +3478,7 @@ func (m *UI) newSession() tea.Cmd {
34783478 return nil
34793479 },
34803480 m.loadPromptHistory(),
3481+ m.reportCurrentSession(""),
34813482 )
34823483}
34833484
internal/workspace/app_workspace.go+7
@@ -67,6 +67,13 @@ func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string
6767 return w.app.Sessions.ParseAgentToolSessionID(sessionID)
6868}
6969
70+// SetCurrentSession is a no-op in single-client local mode. The
71+// presence concept only matters when multiple clients can share a
72+// workspace via the HTTP server.
73+func (w *AppWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
74+ return nil
75+}
76+
7077// -- Messages --
7178
7279func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
internal/workspace/client_workspace.go+8
@@ -131,6 +131,14 @@ func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, str
131131 return parts[0], parts[1], true
132132}
133133
134+// SetCurrentSession reports the session this client is currently
135+// viewing to the server. Empty sessionID clears the entry. Errors
136+// are propagated to the caller; the TUI logs and ignores them since
137+// the presence record is a hint, not correctness-critical state.
138+func (w *ClientWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error {
139+ return w.client.SetCurrentSession(ctx, w.workspaceID(), sessionID)
140+}
141+
134142// -- Messages --
135143
136144func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
internal/workspace/workspace.go+6
@@ -67,6 +67,12 @@ type Workspace interface {
6767 DeleteSession(ctx context.Context, sessionID string) error
6868 CreateAgentToolSessionID(messageID, toolCallID string) string
6969 ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool)
70+ // SetCurrentSession reports the session this client is currently
71+ // viewing. Empty sessionID clears the entry (e.g. landing screen).
72+ // In single-client local mode this is a no-op. In client/server
73+ // mode it informs the server's per-client presence map so other
74+ // observers can compute attached-client counts per session.
75+ SetCurrentSession(ctx context.Context, sessionID string) error
7076
7177 // Messages
7278 ListMessages(ctx context.Context, sessionID string) ([]message.Message, error)