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)