From 178cd11dd49f322b9701f3ec2484f3b7a816c6e2 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Tue, 19 May 2026 23:44:58 -0400 Subject: [PATCH] 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 --- internal/backend/backend.go | 49 ++++++- 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 | 22 ++- internal/ui/model/ui.go | 1 + internal/workspace/app_workspace.go | 7 + internal/workspace/client_workspace.go | 8 ++ internal/workspace/workspace.go | 6 + 13 files changed, 469 insertions(+), 6 deletions(-) diff --git a/internal/backend/backend.go b/internal/backend/backend.go index 4d377ac4d983c076ff86a970250c20b1d7adbe4b..dbda67f95130da304e37e0376b2bc0690ffbfb3d 100644 --- a/internal/backend/backend.go +++ b/internal/backend/backend.go @@ -32,6 +32,7 @@ var ( ErrInvalidPermissionAction = errors.New("invalid permission action") ErrUnknownCommand = errors.New("unknown command") ErrInvalidClientID = errors.New("invalid client_id") + ErrClientNotAttached = errors.New("client not attached") ) // DefaultCreateGrace is the window in which a client must open an SSE @@ -77,13 +78,18 @@ type Backend struct { // - holdTimer is non-nil iff the client created the workspace but has // not yet attached an SSE stream; it fires after createGrace and // releases the hold. +// - currentSessionID records which session this client is currently +// viewing. Empty string means the client has no session selected +// (e.g. the landing screen). Cleared automatically when the +// clientState entry is removed. // -// The two are mutually exclusive in practice (the hold timer is stopped -// the moment an SSE stream attaches), but both being zero/nil means the -// entry has been released and should be removed. +// streams and holdTimer are mutually exclusive in practice (the hold +// timer is stopped the moment an SSE stream attaches), but both being +// zero/nil means the entry has been released and should be removed. type clientState struct { - streams int - holdTimer *time.Timer + streams int + holdTimer *time.Timer + currentSessionID string } // Workspace represents a running [app.App] workspace with its @@ -468,6 +474,39 @@ func (b *Backend) DeleteWorkspace(id, clientID string) error { return b.releaseHold(id, clientID) } +// SetCurrentSession records which session the given client is +// currently viewing within the workspace. Passing an empty sessionID +// clears the client's current-session entry (e.g. the client has +// returned to the landing screen). +// +// The client must be actually attached — i.e. its [clientState] entry +// must exist and have at least one live stream. A bare creation hold +// (streams == 0) is rejected with [ErrClientNotAttached]. This +// guards against zombie writes from a client that has detached and +// against ghost presence from a hold-only client that never opened an +// SSE stream. +func (b *Backend) SetCurrentSession(workspaceID, clientID, sessionID string) error { + if _, err := validateClientID(clientID); err != nil { + return err + } + ws, ok := b.workspaces.Get(workspaceID) + if !ok { + return ErrWorkspaceNotFound + } + ws.clientsMu.Lock() + defer ws.clientsMu.Unlock() + cs, ok := ws.clients[clientID] + if !ok || cs.streams == 0 { + // No entry, or hold-only (no live stream): refuse the + // write. The presence record this is meant to feed + // should only reflect clients that can actually observe + // session events. + return ErrClientNotAttached + } + cs.currentSessionID = sessionID + return nil +} + // GetWorkspaceProto returns the proto representation of a workspace. func (b *Backend) GetWorkspaceProto(id string) (proto.Workspace, error) { ws, err := b.GetWorkspace(id) diff --git a/internal/backend/backend_test.go b/internal/backend/backend_test.go index d1a6f78abb9cc38c1f8a463ed7be548c22e332a1..0ab01b5c54f6622b2d3997ca21e59e1bb2b0ff54 100644 --- a/internal/backend/backend_test.go +++ b/internal/backend/backend_test.go @@ -951,3 +951,184 @@ func TestAttachClient_RacesWithTeardown(t *testing.T) { } } } + +// TestSetCurrentSession_BasicAttachAndSwitch verifies the happy path: +// an attached client can set its current session, a second attached +// client can target the same session, and one of them can switch to a +// different session without disturbing the other's record. +func TestSetCurrentSession_BasicAttachAndSwitch(t *testing.T) { + t.Parallel() + + b, _ := newTestBackend(t) + ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-basic") + + cidA := newClientID(t) + cidB := newClientID(t) + require.NoError(t, b.AttachClient(ws.ID, cidA)) + require.NoError(t, b.AttachClient(ws.ID, cidB)) + + require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "S1")) + ws.clientsMu.Lock() + require.Equal(t, "S1", ws.clients[cidA].currentSessionID) + ws.clientsMu.Unlock() + + require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S1")) + ws.clientsMu.Lock() + require.Equal(t, "S1", ws.clients[cidA].currentSessionID) + require.Equal(t, "S1", ws.clients[cidB].currentSessionID) + ws.clientsMu.Unlock() + + // B switches to S2; counts redistribute. + require.NoError(t, b.SetCurrentSession(ws.ID, cidB, "S2")) + ws.clientsMu.Lock() + require.Equal(t, "S1", ws.clients[cidA].currentSessionID) + require.Equal(t, "S2", ws.clients[cidB].currentSessionID) + ws.clientsMu.Unlock() + + // A clears its selection. + require.NoError(t, b.SetCurrentSession(ws.ID, cidA, "")) + ws.clientsMu.Lock() + require.Empty(t, ws.clients[cidA].currentSessionID) + require.Equal(t, "S2", ws.clients[cidB].currentSessionID) + ws.clientsMu.Unlock() + + // Drain to release the workspace. + b.DetachClient(ws.ID, cidA) + b.DetachClient(ws.ID, cidB) +} + +// TestSetCurrentSession_DetachClearsEntry verifies the implicit +// cleanup: once a client's [clientState] entry is removed (last +// stream closed), its currentSessionID is gone with it. +func TestSetCurrentSession_DetachClearsEntry(t *testing.T) { + t.Parallel() + + b, _ := newTestBackend(t) + ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-detach") + + // Anchor client so the workspace is not torn down when cid + // detaches. + anchor := newClientID(t) + require.NoError(t, b.AttachClient(ws.ID, anchor)) + + cid := newClientID(t) + require.NoError(t, b.AttachClient(ws.ID, cid)) + require.NoError(t, b.SetCurrentSession(ws.ID, cid, "S2")) + + b.DetachClient(ws.ID, cid) + + ws.clientsMu.Lock() + _, present := ws.clients[cid] + ws.clientsMu.Unlock() + require.False(t, present, "detach must remove the clientState entry along with its currentSessionID") + + // A follow-up SetCurrentSession on the gone client must be + // rejected with ErrClientNotAttached. + require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S3"), ErrClientNotAttached) + + b.DetachClient(ws.ID, anchor) +} + +// TestSetCurrentSession_RejectsHoldOnly verifies that a registered +// client whose only claim is a creation hold (streams == 0) cannot +// influence presence: SetCurrentSession returns ErrClientNotAttached +// and the entry's currentSessionID stays empty. +func TestSetCurrentSession_RejectsHoldOnly(t *testing.T) { + t.Parallel() + + b, _ := newTestBackend(t) + // Keep the grace window large so the hold survives the test. + b.createGrace = time.Hour + ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-hold") + + cid := newClientID(t) + b.registerClient(ws, cid) + + require.ErrorIs(t, b.SetCurrentSession(ws.ID, cid, "S1"), ErrClientNotAttached) + + ws.clientsMu.Lock() + require.Empty(t, ws.clients[cid].currentSessionID, "hold-only client must not write a session id") + ws.clientsMu.Unlock() + + // Drain. + require.NoError(t, b.releaseHold(ws.ID, cid)) +} + +// TestSetCurrentSession_UnknownClient verifies that a client with no +// entry at all is rejected with ErrClientNotAttached. +func TestSetCurrentSession_UnknownClient(t *testing.T) { + t.Parallel() + + b, _ := newTestBackend(t) + ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-unknown") + + require.ErrorIs(t, b.SetCurrentSession(ws.ID, newClientID(t), "S1"), ErrClientNotAttached) +} + +// TestSetCurrentSession_RejectsBadInputs covers the validation +// branches: empty/malformed client_id and unknown workspace. +func TestSetCurrentSession_RejectsBadInputs(t *testing.T) { + t.Parallel() + + b, _ := newTestBackend(t) + ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-bad") + + require.ErrorIs(t, b.SetCurrentSession(ws.ID, "", "S1"), ErrInvalidClientID) + require.ErrorIs(t, b.SetCurrentSession(ws.ID, "not-a-uuid", "S1"), ErrInvalidClientID) + + require.ErrorIs( + t, + b.SetCurrentSession("00000000-0000-0000-0000-000000000000", newClientID(t), "S1"), + ErrWorkspaceNotFound, + ) +} + +// TestSetCurrentSession_RaceWithDetach exercises concurrent +// SetCurrentSession updates from one client racing against detach +// on a second client. The final state must be self-consistent: any +// remaining clientState entries reflect a coherent +// (streams, currentSessionID) pair. +func TestSetCurrentSession_RaceWithDetach(t *testing.T) { + t.Parallel() + + b, _ := newTestBackend(t) + ws, _ := insertTestWorkspace(t, b, "/tmp/current-session-race") + + cidA := newClientID(t) + cidB := newClientID(t) + require.NoError(t, b.AttachClient(ws.ID, cidA)) + require.NoError(t, b.AttachClient(ws.ID, cidB)) + + var wg sync.WaitGroup + const updates = 200 + wg.Add(3) + go func() { + defer wg.Done() + for i := range updates { + // Errors are tolerated: once cidA detaches, + // further updates against cidA must return + // ErrClientNotAttached but never panic. + _ = b.SetCurrentSession(ws.ID, cidA, "SA") + _ = i + } + }() + go func() { + defer wg.Done() + for i := range updates { + _ = b.SetCurrentSession(ws.ID, cidB, "SB") + _ = i + } + }() + go func() { + defer wg.Done() + // Single concurrent detach of cidA partway through. + b.DetachClient(ws.ID, cidA) + }() + wg.Wait() + + ws.clientsMu.Lock() + defer ws.clientsMu.Unlock() + require.NotContains(t, ws.clients, cidA, "detached client must be gone") + require.Contains(t, ws.clients, cidB, "remaining client must still be present") + require.Equal(t, "SB", ws.clients[cidB].currentSessionID, "remaining client must keep its last set session") +} diff --git a/internal/backend/testing.go b/internal/backend/testing.go index ace37c4b2153308ad165564525bdb46a2446f0c9..7863877b1cfeae464184aa4cd921c301cddfabae 100644 --- a/internal/backend/testing.go +++ b/internal/backend/testing.go @@ -18,3 +18,15 @@ func InsertWorkspaceForTest(b *Backend, ws *Workspace) { b.pathIndex[ws.resolvedPath] = ws.ID } } + +// RegisterClientForTesting installs a creation hold for clientID on +// ws using the backend's normal registerClient path. Intended for +// tests in other packages that need to drive a hold-only client +// (streams == 0) without booting a real CreateWorkspace flow. +func RegisterClientForTesting(b *Backend, ws *Workspace, clientID string) error { + if _, err := validateClientID(clientID); err != nil { + return err + } + b.registerClient(ws, clientID) + return nil +} diff --git a/internal/client/proto.go b/internal/client/proto.go index 2130a5d66fd95c1225315681f7bd389e80d7abee..ab8b7cb7a04e5c527f333d7deaf28474def96e8f 100644 --- a/internal/client/proto.go +++ b/internal/client/proto.go @@ -86,6 +86,30 @@ func (c *Client) DeleteWorkspace(ctx context.Context, id string) error { return nil } +// SetCurrentSession reports the client's current-session selection +// for the named workspace. An empty sessionID clears the entry. The +// request carries the process-scoped client ID minted in [NewClient] +// as a query parameter so the server can route the update to the +// correct [clientState] entry. +func (c *Client) SetCurrentSession(ctx context.Context, workspaceID, sessionID string) error { + q := url.Values{"client_id": []string{c.clientID}} + rsp, err := c.post( + ctx, + fmt.Sprintf("/workspaces/%s/current-session", workspaceID), + q, + jsonBody(proto.CurrentSession{SessionID: sessionID}), + http.Header{"Content-Type": []string{"application/json"}}, + ) + if err != nil { + return fmt.Errorf("failed to set current session: %w", err) + } + defer rsp.Body.Close() + if rsp.StatusCode != http.StatusOK { + return fmt.Errorf("failed to set current session: status code %d", rsp.StatusCode) + } + return nil +} + // SubscribeEvents subscribes to server-sent events for a workspace. func (c *Client) SubscribeEvents(ctx context.Context, id string) (<-chan any, error) { events := make(chan any, 100) diff --git a/internal/proto/proto.go b/internal/proto/proto.go index a76991fb4e68e326eb9c79a9b9c7d2659d3e00be..adb13f146061cb4b17181d5a3f0ac887f39dabca 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -36,6 +36,12 @@ type ConfigChanged struct { WorkspaceID string `json:"workspace_id"` } +// CurrentSession is the request body for the per-client +// current-session endpoint. An empty SessionID clears the entry. +type CurrentSession struct { + SessionID string `json:"session_id"` +} + // AgentInfo represents information about the agent. type AgentInfo struct { IsBusy bool `json:"is_busy"` diff --git a/internal/server/multiclient_test.go b/internal/server/multiclient_test.go index bd62ee8e6e0f36c151fad8e590716966236d5ba7..3e11bd206764741b78054cbc070d3cbbfc2c3d74 100644 --- a/internal/server/multiclient_test.go +++ b/internal/server/multiclient_test.go @@ -10,9 +10,26 @@ import ( "github.com/charmbracelet/crush/internal/backend" "github.com/charmbracelet/crush/internal/proto" + "github.com/google/uuid" "github.com/stretchr/testify/require" ) +// installSyntheticWorkspace creates a synthetic [backend.Workspace] +// registered with the controller's backend, suitable for handler-level +// tests that do not need a real [app.App]. The workspace's ID is a +// fresh UUID and its path is a tempdir; teardown is the caller's +// responsibility (handlers should not rely on synthetic workspaces +// disappearing automatically). +func installSyntheticWorkspace(t *testing.T, c *controllerV1) *backend.Workspace { + t.Helper() + ws := &backend.Workspace{ + ID: uuid.New().String(), + Path: t.TempDir(), + } + backend.InsertWorkspaceForTest(c.backend, ws) + return ws +} + // newTestController builds a controllerV1 around a backend without a // real config store, suitable for handler-level 400 tests. func newTestController() *controllerV1 { @@ -105,3 +122,109 @@ func TestSubscribeEvents_RejectsMalformedClientID(t *testing.T) { require.Equal(t, http.StatusBadRequest, rec.Code) } + +// postCurrentSession is a small helper that POSTs the JSON body to +// /v1/workspaces/{id}/current-session?client_id=cid and returns the +// recorder. It does not require a real listener. +func postCurrentSession(t *testing.T, c *controllerV1, wsID, clientID, sessionID string) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(proto.CurrentSession{SessionID: sessionID}) + require.NoError(t, err) + url := "/v1/workspaces/" + wsID + "/current-session" + if clientID != "" { + url += "?client_id=" + clientID + } + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader(body)) + req.SetPathValue("id", wsID) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c.handlePostWorkspaceCurrentSession(rec, req) + return rec +} + +func TestPostCurrentSession_RejectsMissingClientID(t *testing.T) { + t.Parallel() + c := newTestController() + + body, err := json.Marshal(proto.CurrentSession{SessionID: "S1"}) + require.NoError(t, err) + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/workspaces/abc/current-session", bytes.NewReader(body)) + req.SetPathValue("id", "abc") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + c.handlePostWorkspaceCurrentSession(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestPostCurrentSession_RejectsMalformedClientID(t *testing.T) { + t.Parallel() + c := newTestController() + + rec := postCurrentSession(t, c, "abc", "not-a-uuid", "S1") + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestPostCurrentSession_RejectsBadBody(t *testing.T) { + t.Parallel() + c := newTestController() + + cid := uuid.New().String() + url := "/v1/workspaces/abc/current-session?client_id=" + cid + req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, url, bytes.NewReader([]byte("not-json"))) + req.SetPathValue("id", "abc") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + c.handlePostWorkspaceCurrentSession(rec, req) + + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestPostCurrentSession_UnknownWorkspace(t *testing.T) { + t.Parallel() + c := newTestController() + + rec := postCurrentSession(t, c, uuid.New().String(), uuid.New().String(), "S1") + require.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestPostCurrentSession_UnknownClient(t *testing.T) { + t.Parallel() + c := newTestController() + ws := installSyntheticWorkspace(t, c) + + rec := postCurrentSession(t, c, ws.ID, uuid.New().String(), "S1") + require.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestPostCurrentSession_HoldOnly(t *testing.T) { + t.Parallel() + c := newTestController() + ws := installSyntheticWorkspace(t, c) + + cid := uuid.New().String() + require.NoError(t, backend.RegisterClientForTesting(c.backend, ws, cid)) + t.Cleanup(func() { _ = c.backend.DeleteWorkspace(ws.ID, cid) }) + + rec := postCurrentSession(t, c, ws.ID, cid, "S1") + require.Equal(t, http.StatusNotFound, rec.Code, "hold-only client must be rejected") +} + +func TestPostCurrentSession_AttachedClientSucceeds(t *testing.T) { + t.Parallel() + c := newTestController() + ws := installSyntheticWorkspace(t, c) + + cid := uuid.New().String() + require.NoError(t, c.backend.AttachClient(ws.ID, cid)) + t.Cleanup(func() { c.backend.DetachClient(ws.ID, cid) }) + + rec := postCurrentSession(t, c, ws.ID, cid, "S1") + require.Equal(t, http.StatusOK, rec.Code) + + // Clearing also returns 200. + rec = postCurrentSession(t, c, ws.ID, cid, "") + require.Equal(t, http.StatusOK, rec.Code) +} diff --git a/internal/server/proto.go b/internal/server/proto.go index 1f054051bb5f6ed6ce9094cc5cffffd2e09e837f..b6e7077fe0aded2481dc3e241f44870f1ce76c01 100644 --- a/internal/server/proto.go +++ b/internal/server/proto.go @@ -151,6 +151,39 @@ func (c *controllerV1) requireClientID(w http.ResponseWriter, r *http.Request) ( return cid, true } +// handlePostWorkspaceCurrentSession records the calling client's +// current session selection for the workspace. An empty session_id +// clears the entry (e.g. the client is on the landing screen). +// +// @Summary Set current session for a client +// @Tags workspaces +// @Accept json +// @Produce json +// @Param id path string true "Workspace ID" +// @Param client_id query string true "Client ID (UUID)" +// @Param request body proto.CurrentSession true "Current session selection" +// @Success 200 +// @Failure 400 {object} proto.Error +// @Failure 404 {object} proto.Error +// @Router /workspaces/{id}/current-session [post] +func (c *controllerV1) handlePostWorkspaceCurrentSession(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + clientID, ok := c.requireClientID(w, r) + if !ok { + return + } + var req proto.CurrentSession + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + c.server.logError(r, "Failed to decode request", "error", err) + jsonError(w, http.StatusBadRequest, "failed to decode request") + return + } + if err := c.backend.SetCurrentSession(id, clientID, req.SessionID); err != nil { + c.handleError(w, r, err) + return + } +} + // handleDeleteWorkspaces deletes a workspace. // // @Summary Delete workspace @@ -1005,6 +1038,8 @@ func (c *controllerV1) handleError(w http.ResponseWriter, r *http.Request, err e status = http.StatusBadRequest case errors.Is(err, backend.ErrInvalidClientID): status = http.StatusBadRequest + case errors.Is(err, backend.ErrClientNotAttached): + status = http.StatusNotFound } c.server.logError(r, err.Error()) jsonError(w, status, err.Error()) diff --git a/internal/server/server.go b/internal/server/server.go index e8dcbe7db1311bf69ea8823c22251ddbdaadc85f..7b05db719fdfbdabfebde6a6e91f3da1fb843d3a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -113,6 +113,7 @@ func NewServer(cfg *config.ConfigStore, network, address string) *Server { mux.HandleFunc("GET /v1/workspaces", c.handleGetWorkspaces) mux.HandleFunc("POST /v1/workspaces", c.handlePostWorkspaces) mux.HandleFunc("DELETE /v1/workspaces/{id}", c.handleDeleteWorkspaces) + mux.HandleFunc("POST /v1/workspaces/{id}/current-session", c.handlePostWorkspaceCurrentSession) mux.HandleFunc("GET /v1/workspaces/{id}", c.handleGetWorkspace) mux.HandleFunc("GET /v1/workspaces/{id}/config", c.handleGetWorkspaceConfig) mux.HandleFunc("GET /v1/workspaces/{id}/events", c.handleGetWorkspaceEvents) diff --git a/internal/ui/model/session.go b/internal/ui/model/session.go index 17172d87f9f7f46d63768512055604bce8adf262..aa31009b89ac6f7d14480fb1e607560021f88a3c 100644 --- a/internal/ui/model/session.go +++ b/internal/ui/model/session.go @@ -64,8 +64,13 @@ type SessionFile struct { // the diff statistics (additions and deletions) for each file in the session. // It returns a tea.Cmd that, when executed, fetches the session data and // returns a sessionFilesLoadedMsg containing the processed session files. +// +// The returned batch also reports the new current-session selection to +// the workspace so the server can update its per-client presence map. +// That report is fire-and-forget: errors are logged at debug and the +// UI never blocks on the call. func (m *UI) loadSession(sessionID string) tea.Cmd { - return func() tea.Msg { + load := func() tea.Msg { session, err := m.com.Workspace.GetSession(context.Background(), sessionID) if err != nil { return util.ReportError(err) @@ -87,6 +92,21 @@ func (m *UI) loadSession(sessionID string) tea.Cmd { readFiles: readFiles, } } + return tea.Batch(load, m.reportCurrentSession(sessionID)) +} + +// reportCurrentSession returns a fire-and-forget tea.Cmd that +// informs the workspace which session this client is currently +// viewing. Errors are logged at debug only; the call is a hint +// for server-side presence tracking, not correctness-critical +// state. +func (m *UI) reportCurrentSession(sessionID string) tea.Cmd { + return func() tea.Msg { + if err := m.com.Workspace.SetCurrentSession(context.Background(), sessionID); err != nil { + slog.Debug("Failed to report current session", "session_id", sessionID, "error", err) + } + return nil + } } func (m *UI) loadSessionFiles(sessionID string) ([]SessionFile, error) { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index eea0d76b514f7421120120efc713bc556495af1e..e7de8fd175555c497ef31d3d84d19d87539188d5 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -3478,6 +3478,7 @@ func (m *UI) newSession() tea.Cmd { return nil }, m.loadPromptHistory(), + m.reportCurrentSession(""), ) } diff --git a/internal/workspace/app_workspace.go b/internal/workspace/app_workspace.go index 0e9460854dc59c63ffc0bcd411aa838df2b68ca1..178aa48eff5d77308eb88dab261c3c0f177b1c96 100644 --- a/internal/workspace/app_workspace.go +++ b/internal/workspace/app_workspace.go @@ -67,6 +67,13 @@ func (w *AppWorkspace) ParseAgentToolSessionID(sessionID string) (string, string return w.app.Sessions.ParseAgentToolSessionID(sessionID) } +// SetCurrentSession is a no-op in single-client local mode. The +// presence concept only matters when multiple clients can share a +// workspace via the HTTP server. +func (w *AppWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error { + return nil +} + // -- Messages -- func (w *AppWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) { diff --git a/internal/workspace/client_workspace.go b/internal/workspace/client_workspace.go index 243572e92bca2487e2f93c3fa6bb4d7aba49e0ce..09a050f4769ffbc58c2a43b516d3511a1a96c880 100644 --- a/internal/workspace/client_workspace.go +++ b/internal/workspace/client_workspace.go @@ -131,6 +131,14 @@ func (w *ClientWorkspace) ParseAgentToolSessionID(sessionID string) (string, str return parts[0], parts[1], true } +// SetCurrentSession reports the session this client is currently +// viewing to the server. Empty sessionID clears the entry. Errors +// are propagated to the caller; the TUI logs and ignores them since +// the presence record is a hint, not correctness-critical state. +func (w *ClientWorkspace) SetCurrentSession(ctx context.Context, sessionID string) error { + return w.client.SetCurrentSession(ctx, w.workspaceID(), sessionID) +} + // -- Messages -- func (w *ClientWorkspace) ListMessages(ctx context.Context, sessionID string) ([]message.Message, error) { diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 3f317f2eb25986ef2bee084f0a11b471a05e7f50..56bb764eef467620aa37754664243c6cbf5a5be5 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -67,6 +67,12 @@ type Workspace interface { DeleteSession(ctx context.Context, sessionID string) error CreateAgentToolSessionID(messageID, toolCallID string) string ParseAgentToolSessionID(sessionID string) (messageID string, toolCallID string, ok bool) + // SetCurrentSession reports the session this client is currently + // viewing. Empty sessionID clears the entry (e.g. landing screen). + // In single-client local mode this is a no-op. In client/server + // mode it informs the server's per-client presence map so other + // observers can compute attached-client counts per session. + SetCurrentSession(ctx context.Context, sessionID string) error // Messages ListMessages(ctx context.Context, sessionID string) ([]message.Message, error)