feat(server): track which session each client is currently viewing

Christian Rocha and Charm Crush created

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>

Change summary

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(-)

Detailed changes

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)

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")
+}

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
+}

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)

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"`

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)
+}

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())

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)

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) {

internal/ui/model/ui.go 🔗

@@ -3478,6 +3478,7 @@ func (m *UI) newSession() tea.Cmd {
 			return nil
 		},
 		m.loadPromptHistory(),
+		m.reportCurrentSession(""),
 	)
 }
 

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) {

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) {

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)