on main help sign in
feat(server): track which session each client is currently viewing
178cd11Christian Rocha
internal/backend/backend.go+44−5
@@ -32,6 +32,7 @@ var (
32 ErrInvalidPermissionAction = errors.New("invalid permission action")
32 ErrInvalidPermissionAction = errors.New("invalid permission action")
33 ErrUnknownCommand = errors.New("unknown command")
33 ErrUnknownCommand = errors.New("unknown command")
34 ErrInvalidClientID = errors.New("invalid client_id")
34 ErrInvalidClientID = errors.New("invalid client_id")
35+ ErrClientNotAttached = errors.New("client not attached")
35)
36)
36
37
37// DefaultCreateGrace is the window in which a client must open an SSE
38// DefaultCreateGrace is the window in which a client must open an SSE
@@ -77,13 +78,18 @@ type Backend struct {
77// - holdTimer is non-nil iff the client created the workspace but has
78// - holdTimer is non-nil iff the client created the workspace but has
78// not yet attached an SSE stream; it fires after createGrace and
79// not yet attached an SSE stream; it fires after createGrace and
79// releases the hold.
80// 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.
80//
85//
81−// The two are mutually exclusive in practice (the hold timer is stopped
86+// streams and holdTimer are mutually exclusive in practice (the hold
82−// the moment an SSE stream attaches), but both being zero/nil means the
87+// timer is stopped the moment an SSE stream attaches), but both being
83−// entry has been released and should be removed.
88+// zero/nil means the entry has been released and should be removed.
84type clientState struct {
89type clientState struct {
85− streams int
90+ streams int
86− holdTimer *time.Timer
91+ holdTimer *time.Timer
92+ currentSessionID string
87}
93}
88
94
89// Workspace represents a running [app.App] workspace with its
95// Workspace represents a running [app.App] workspace with its
@@ -468,6 +474,39 @@ func (b *Backend) DeleteWorkspace(id, clientID string) error {
468 return b.releaseHold(id, clientID)
474 return b.releaseHold(id, clientID)
469}
475}
470
476
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+
471// GetWorkspaceProto returns the proto representation of a workspace.
510// GetWorkspaceProto returns the proto representation of a workspace.
472func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
511func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) {
473 ws, err := b.GetWorkspace(id)
512 ws, err := b.GetWorkspace(id)
internal/backend/backend_test.go+181
@@ -951,3 +951,184 @@ func TestAttachClient_RacesWithTeardown(t *testing.T) {
951 }
951 }
952 }
952 }
953}
953}
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) {
18 b.pathIndex[ws.resolvedPath] = ws.ID
18 b.pathIndex[ws.resolvedPath] = ws.ID
19 }
19 }
20}
20}
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 {
86 return nil
86 return nil
87}
87}
88
88
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+
89// SubscribeEvents subscribes to server-sent events for a workspace.
113// SubscribeEvents subscribes to server-sent events for a workspace.
90func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
114func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) {
91 events := make(chan any, 100)
115 events := make(chan any, 100)
internal/proto/proto.go+6
@@ -36,6 +36,12 @@ type ConfigChanged struct {
36 WorkspaceID string `json:"workspace_id"`
36 WorkspaceID string `json:"workspace_id"`
37}
37}
38
38
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+
39// AgentInfo represents information about the agent.
45// AgentInfo represents information about the agent.
40type AgentInfo struct {
46type AgentInfo struct {
41 IsBusy bool `json:"is_busy"`
47 IsBusy bool `json:"is_busy"`
internal/server/multiclient_test.go+123
@@ -10,9 +10,26 @@ import (
10
10
11 "github.com/charmbracelet/crush/internal/backend"
11 "github.com/charmbracelet/crush/internal/backend"
12 "github.com/charmbracelet/crush/internal/proto"
12 "github.com/charmbracelet/crush/internal/proto"
13+ "github.com/google/uuid"
13 "github.com/stretchr/testify/require"
14 "github.com/stretchr/testify/require"
14)
15)
15
16
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+
16// newTestController builds a controllerV1 around a backend without a
33// newTestController builds a controllerV1 around a backend without a
17// real config store, suitable for handler-level 400 tests.
34// real config store, suitable for handler-level 400 tests.
18func newTestController() *controllerV1 {
35func newTestController() *controllerV1 {
@@ -105,3 +122,109 @@ func TestSubscribeEvents_RejectsMalformedClientID(t *testing.T) {
105
122
106 require.Equal(t, http.StatusBadRequest, rec.Code)
123 require.Equal(t, http.StatusBadRequest, rec.Code)
107}
124}
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) (
151 return cid, true
151 return cid, true
152}
152}
153
153
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+
154// handleDeleteWorkspaces deletes a workspace.
187// handleDeleteWorkspaces deletes a workspace.
155//
188//
156// @Summary Delete workspace
189// @Summary Delete workspace
@@ -1005,6 +1038,8 @@ func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err e
1005 status = http.StatusBadRequest
1038 status = http.StatusBadRequest
1006 case errors.Is(err, backend.ErrInvalidClientID):
1039 case errors.Is(err, backend.ErrInvalidClientID):
1007 status = http.StatusBadRequest
1040 status = http.StatusBadRequest
1041+ case errors.Is(err, backend.ErrClientNotAttached):
1042+ status = http.StatusNotFound
1008 }
1043 }
1009 c.server.logError(r, err.Error())
1044 c.server.logError(r, err.Error())
1010 jsonError(w, status, err.Error())
1045 jsonError(w, status, err.Error())
internal/server/server.go+1
@@ -113,6 +113,7 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server {
113 mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces)
113 mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces)
114 mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
114 mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces)
115 mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces)
115 mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces)
116+ mux.HandleFunc("POST /v1/workspaces/{id}/current-session", c.handlePostWorkspaceCurrentSession)
116 mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace)
117 mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace)
117 mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig)
118 mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig)
118 mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
119 mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents)
internal/ui/model/session.go+21−1
@@ -64,8 +64,13 @@ type SessionFile struct {
64// the diff statistics (additions and deletions) for each file in the session.
64// the diff statistics (additions and deletions) for each file in the session.
65// It returns a tea.Cmd that, when executed, fetches the session data and
65// It returns a tea.Cmd that, when executed, fetches the session data and
66// returns a sessionFilesLoadedMsg containing the processed session files.
66// 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.
67func (m *UI) loadSession(sessionID string) tea.Cmd {
72func (m *UI) loadSession(sessionID string) tea.Cmd {
68− return func() tea.Msg {
73+ load := func() tea.Msg {
69 session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
74 session, err := m.com.Workspace.GetSession(context.Background(), sessionID)
70 if err != nil {
75 if err != nil {
71 return util.ReportError(err)
76 return util.ReportError(err)
@@ -87,6 +92,21 @@ func (m *UI) loadSession(sessionID string) tea.Cmd {
87 readFiles: readFiles,
92 readFiles: readFiles,
88 }
93 }
89 }
94 }
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+ }
90}
110}
91
111
92func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
112func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) {
internal/ui/model/ui.go+1
@@ -3478,6 +3478,7 @@ func (m *UI) newSession() tea.Cmd {
3478 return nil
3478 return nil
3479 },
3479 },
3480 m.loadPromptHistory(),
3480 m.loadPromptHistory(),
3481+ m.reportCurrentSession(""),
3481 )
3482 )
3482}
3483}
3483
3484
internal/workspace/app_workspace.go+7
@@ -67,6 +67,13 @@ func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string
67 return w.app.Sessions.ParseAgentToolSessionID(sessionID)
67 return w.app.Sessions.ParseAgentToolSessionID(sessionID)
68}
68}
69
69
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+
70// -- Messages --
77// -- Messages --
71
78
72func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
79func (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
131 return parts[0], parts[1], true
131 return parts[0], parts[1], true
132}
132}
133
133
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+
134// -- Messages --
142// -- Messages --
135
143
136func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
144func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) {
internal/workspace/workspace.go+6
@@ -67,6 +67,12 @@ type Workspace interface {
67 DeleteSession(ctx context.Context, sessionID string) error
67 DeleteSession(ctx context.Context, sessionID string) error
68 CreateAgentToolSessionID(messageID, toolCallID string) string
68 CreateAgentToolSessionID(messageID, toolCallID string) string
69 ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool)
69 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
70
76
71 // Messages
77 // Messages
72 ListMessages(ctx context.Context, sessionID string) ([]message.Message, error)
78 ListMessages(ctx context.Context, sessionID string) ([]message.Message, error)