diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 85ed0e165..60ebf99e3 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -69,6 +69,12 @@ jobs: run: pnpm install --frozen-lockfile - name: Build backend and web + # NEXT_PUBLIC_KANDEV_E2E_MOCK=true bakes the WS event-accounting hook + # into the FE bundle (see lib/ws/client.ts). Next.js inlines NEXT_PUBLIC_* + # at build time, so without this flag the hook dead-eliminates and the + # fixture's WS-drop check has nothing to read. + env: + NEXT_PUBLIC_KANDEV_E2E_MOCK: "true" run: make build-backend build-web # Linux/amd64 helper binaries (agentctl + mock-agent) bind-mounted into @@ -133,6 +139,13 @@ jobs: # previously ran on. env: NODE_OPTIONS: --dns-result-order=ipv4first + # Enforce WS event accounting: any event the backend sent that the FE + # never processed (per-connection or per-session seq gap), and any + # session-routed event a bridge handler failed to apply to the TQ cache, + # fails the test that observed it. Requires the FE bundle built with + # NEXT_PUBLIC_KANDEV_E2E_MOCK=true (see the build job) so the accounting + # hooks are present. See docs/specs/ws-event-accounting/plan.md. + KANDEV_E2E_WS_ASSERT: "1" strategy: fail-fast: false matrix: diff --git a/AGENTS.md b/AGENTS.md index df2e9ce23..dcfc7e556 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ ``` apps/ ├── backend/ # Go backend (orchestrator, lifecycle, agentctl, WS gateway) -├── web/ # Next.js frontend (SSR + WS + Zustand) +├── web/ # Next.js frontend (SSR + WS + TanStack Query + Zustand) ├── cli/ # CLI tool (TypeScript) ├── landing/ # Landing page └── packages/ # Shared packages/types @@ -37,7 +37,7 @@ Architecture notes and per-area conventions live alongside the code they describ - `apps/backend/internal/agentctl/AGENTS.md` — agentctl HTTP server: route groups, adapter model, ACP protocol. - `apps/backend/internal/agentctl/server/api/AGENTS.md` — reverse-proxy body rewriting (`Accept-Encoding`), iframe-blocking header stripping. - `apps/backend/internal/integrations/AGENTS.md` — adding a new third-party integration (Jira/Linear pattern, both backend and frontend halves). The `/add-integration` skill mirrors this for scaffolding new integrations. -- `apps/web/AGENTS.md` — Next.js frontend: shadcn imports, SSR-hydrate-store data flow, store slice structure (incl. `office`), WS format, component conventions, TS lint limits. +- `apps/web/AGENTS.md` — Next.js frontend: shadcn imports, TanStack Query data flow (queries + WS→cache bridges + ring-buffered streams), client-only Zustand slices (transitional mirrors), WS format, component conventions, TS lint limits. --- diff --git a/apps/backend/cmd/kandev/e2e_reset.go b/apps/backend/cmd/kandev/e2e_reset.go index 73430b5b9..1d15ab202 100644 --- a/apps/backend/cmd/kandev/e2e_reset.go +++ b/apps/backend/cmd/kandev/e2e_reset.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "os" + "strconv" "strings" "github.com/gin-gonic/gin" @@ -11,10 +12,20 @@ import ( "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/common/logger" + gateways "github.com/kandev/kandev/internal/gateway/websocket" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" ) +// errorField is the JSON key for error responses across every E2E endpoint. +// Extracted to satisfy goconst; the value is part of the public contract with +// the FE test harness so don't rename without updating the consumers. +const errorField = "error" + +// errUnknownConnection is the 404 message body for connection-id lookups +// against the WS gateway's ring buffer. Extracted to satisfy goconst. +const errUnknownConnection = "unknown connection_id" + // registerE2EResetRoutes registers the E2E test-only endpoints. // The endpoints are available when KANDEV_MOCK_AGENT is "true" or "only" (dev/E2E modes). func registerE2EResetRoutes( @@ -22,6 +33,7 @@ func registerE2EResetRoutes( repo *sqliterepo.Repository, taskSvc *taskservice.Service, automationSvc *automation.Service, + hub *gateways.Hub, log *logger.Logger, ) { mockMode := os.Getenv("KANDEV_MOCK_AGENT") @@ -35,10 +47,79 @@ func registerE2EResetRoutes( // workflow path (e.g. improve-kandev) without depending on the real // bootstrap endpoint, which clones from GitHub and shells out to gh. api.POST("/hidden-workflow", handleE2ECreateHiddenWorkflow(taskSvc, log)) + // WS send-log inspector: lets E2E tests diff what the BE sent vs what + // the FE received per WS connection. Gaps in the FE-side seq sequence + // against this server-of-record indicate a real WS regression rather + // than a noisy UI test. + api.GET("/ws-sent", handleE2EWsSent(hub)) log.Info("registered E2E endpoints (test-only)") } +// e2eWsSentResponse matches the contract the FE-side accountant consumes. +// Keep field names stable — changes here require a matching FE update. +type e2eWsSentResponse struct { + ConnectionID string `json:"connection_id"` + Events []gateways.WsSentEvent `json:"events"` + MaxSeq int64 `json:"max_seq"` +} + +func handleE2EWsSent(hub *gateways.Hub) gin.HandlerFunc { + return func(c *gin.Context) { + connectionID := c.Query("connection_id") + if connectionID == "" { + c.JSON(http.StatusBadRequest, gin.H{errorField: "connection_id is required"}) + return + } + var sinceSeq int64 + if raw := c.Query("since_seq"); raw != "" { + parsed, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{errorField: "since_seq must be an integer"}) + return + } + sinceSeq = parsed + } + + // Optional per-session filter (Workstream 1): when set, returns only + // entries whose stamped SessionID matches, sorted by SessionSeq + // ascending. MaxSeq in the response then carries the max SessionSeq + // for the filter (the per-session counter, not per-connection). The + // FE per-session diff uses this to catch cross-session misrouting + // that per-connection seq alone cannot detect. + if sessionID := c.Query("session_id"); sessionID != "" { + events, maxSessionSeq, ok := hub.GetSentEventsForSession(connectionID, sessionID) + if !ok { + c.JSON(http.StatusNotFound, gin.H{errorField: errUnknownConnection}) + return + } + if events == nil { + events = []gateways.WsSentEvent{} + } + c.JSON(http.StatusOK, e2eWsSentResponse{ + ConnectionID: connectionID, + Events: events, + MaxSeq: maxSessionSeq, + }) + return + } + + events, maxSeq, ok := hub.GetSentEventsFor(connectionID, sinceSeq) + if !ok { + c.JSON(http.StatusNotFound, gin.H{errorField: errUnknownConnection}) + return + } + if events == nil { + events = []gateways.WsSentEvent{} + } + c.JSON(http.StatusOK, e2eWsSentResponse{ + ConnectionID: connectionID, + Events: events, + MaxSeq: maxSeq, + }) + } +} + func handleE2EReset( repo *sqliterepo.Repository, taskSvc *taskservice.Service, @@ -96,7 +177,7 @@ func handleE2EReset( tasks, total, err := repo.ListTasksByWorkspace(ctx, workspaceID, "", "", "", 1, resetPageSize, true, true, false, false) if err != nil { log.Error("e2e reset: failed to list tasks", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{errorField: err.Error()}) return } if total > resetPageSize { @@ -116,7 +197,7 @@ func handleE2EReset( // would create orphan rows visible to subsequent tests. log.Error("e2e reset: failed to delete task", zap.String("task_id", t.ID), zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{errorField: err.Error()}) return } deletedTasks++ @@ -125,14 +206,14 @@ func handleE2EReset( deletedWorkflows, err := repo.DeleteWorkflowsByWorkspace(ctx, workspaceID, keepWorkflowIDs) if err != nil { log.Error("e2e reset: failed to delete workflows", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{errorField: err.Error()}) return } deletedAutomations, autoErr := deleteAutomationsForReset(ctx, automationSvc, workspaceID) if autoErr != nil { log.Error("e2e reset: failed to delete automations", zap.Error(autoErr)) - c.JSON(http.StatusInternalServerError, gin.H{"error": autoErr.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{errorField: autoErr.Error()}) return } @@ -164,7 +245,7 @@ func handleE2ECreateHiddenWorkflow(taskSvc *taskservice.Service, log *logger.Log return func(c *gin.Context) { var body e2eHiddenWorkflowRequest if err := c.ShouldBindJSON(&body); err != nil || body.WorkspaceID == "" || body.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id and name are required"}) + c.JSON(http.StatusBadRequest, gin.H{errorField: "workspace_id and name are required"}) return } workflow, err := taskSvc.CreateWorkflow(c.Request.Context(), &taskservice.CreateWorkflowRequest{ @@ -174,7 +255,7 @@ func handleE2ECreateHiddenWorkflow(taskSvc *taskservice.Service, log *logger.Log }) if err != nil { log.Error("e2e: failed to create hidden workflow", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + c.JSON(http.StatusInternalServerError, gin.H{errorField: err.Error()}) return } c.JSON(http.StatusCreated, gin.H{ diff --git a/apps/backend/cmd/kandev/e2e_ws_sent_test.go b/apps/backend/cmd/kandev/e2e_ws_sent_test.go new file mode 100644 index 000000000..d30165a07 --- /dev/null +++ b/apps/backend/cmd/kandev/e2e_ws_sent_test.go @@ -0,0 +1,297 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "testing/synctest" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + gateways "github.com/kandev/kandev/internal/gateway/websocket" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func newTestE2EHub(t *testing.T) (*gateways.Hub, context.CancelFunc) { + t.Helper() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatalf("logger: %v", err) + } + hub := gateways.NewHub(nil, log) + ctx, cancel := context.WithCancel(context.Background()) + go hub.Run(ctx) + // The caller owns `cancel`: tests that drive registration run inside a + // synctest bubble and `defer cancel()` so the Run goroutine exits before + // the bubble closes (synctest requires all bubbled goroutines to finish). + return hub, cancel +} + +// registerAndWait registers a client and blocks until the hub's Run goroutine +// has committed it to the client map, i.e. until the ws-sent endpoint can +// resolve the connection. hub.Register() pushes onto an unbuffered channel and +// returns before Run finishes the (locked) map write. Rather than poll, we use +// synctest.Wait() to drain the Run goroutine until it is durably blocked again +// — at which point the registration has landed, deterministically and with no +// real-time waiting. MUST be called from inside a synctest.Test bubble. +func registerAndWait(t *testing.T, hub *gateways.Hub, client *gateways.Client) { + t.Helper() + hub.Register(client) + synctest.Wait() + if _, _, ok := hub.GetSentEventsFor(client.ID, 0); !ok { + t.Fatalf("client %q was not registered after synctest.Wait()", client.ID) + } +} + +func mustNotif(t *testing.T, action string) *ws.Message { + t.Helper() + m, err := ws.NewNotification(action, map[string]string{"k": "v"}) + if err != nil { + t.Fatalf("build notif: %v", err) + } + return m +} + +// setupE2EWsSentRouter builds a gin engine with the ws-sent route mounted. +// Skips the test if KANDEV_MOCK_AGENT isn't set in the gate-on case. +func setupE2EWsSentRouter(t *testing.T, hub *gateways.Hub) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + r := gin.New() + api := r.Group("/api/v1/e2e") + api.GET("/ws-sent", handleE2EWsSent(hub)) + return r +} + +// TestE2EWsSent_UnknownConnection400_or_404 covers the input-validation paths: +// missing connection_id → 400, unknown connection_id → 404. +func TestE2EWsSent_UnknownConnection(t *testing.T) { + // No client registration here, so no synctest bubble is needed — the + // validation paths never touch the Run goroutine's register channel. + hub, cancel := newTestE2EHub(t) + defer cancel() + r := setupE2EWsSentRouter(t, hub) + + cases := []struct { + name string + query string + wantCode int + }{ + {name: "missing connection_id", query: "", wantCode: http.StatusBadRequest}, + {name: "unknown connection_id", query: "?connection_id=ghost", wantCode: http.StatusNotFound}, + {name: "bad since_seq", query: "?connection_id=ghost&since_seq=nope", wantCode: http.StatusBadRequest}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/e2e/ws-sent"+tc.query, nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != tc.wantCode { + t.Errorf("status=%d, want %d, body=%s", w.Code, tc.wantCode, w.Body.String()) + } + }) + } +} + +// TestE2EWsSent_ReturnsRingBufferForKnownClient drives a real client through +// the public Hub.Register channel and verifies the endpoint exposes that +// client's ring buffer. This is the happy path the FE accountant relies on. +func TestE2EWsSent_ReturnsRingBufferForKnownClient(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + hub, cancel := newTestE2EHub(t) + defer cancel() + r := setupE2EWsSentRouter(t, hub) + + // Register a client via the hub's public seam — the only stable way to + // land a *Client in hub.clients from outside the gateway package. + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + client := gateways.NewClient("conn-e2e", nil, hub, log) + registerAndWait(t, hub, client) + + // SubscribeToUser and BroadcastToUser are synchronous (they stamp + write to + // the client's ring buffer before returning), so no post-broadcast wait is + // needed once registration has landed. + hub.SubscribeToUser(client, "u1") + for i := 1; i <= 3; i++ { + hub.BroadcastToUser("u1", mustNotif(t, "evt")) + } + + // subtests are flattened into scoped blocks: synctest bubbles disallow + // t.Run, and the ring-buffer reads must happen before defer cancel() + // tears down the client registration on hub shutdown. + + // returns all when since_seq omitted + { + req := httptest.NewRequest(http.MethodGet, "/api/v1/e2e/ws-sent?connection_id=conn-e2e", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d, body=%s", w.Code, w.Body.String()) + } + var resp e2eWsSentResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.ConnectionID != "conn-e2e" { + t.Errorf("connection_id=%q, want conn-e2e", resp.ConnectionID) + } + if resp.MaxSeq != 3 { + t.Errorf("max_seq=%d, want 3", resp.MaxSeq) + } + if len(resp.Events) != 3 { + t.Fatalf("len(events)=%d, want 3 (body=%s)", len(resp.Events), w.Body.String()) + } + for i, e := range resp.Events { + if e.Seq != int64(i+1) { + t.Errorf("events[%d].Seq=%d, want %d", i, e.Seq, i+1) + } + if e.Action != "evt" { + t.Errorf("events[%d].Action=%q, want evt", i, e.Action) + } + } + } + + // filters by since_seq + { + req := httptest.NewRequest(http.MethodGet, "/api/v1/e2e/ws-sent?connection_id=conn-e2e&since_seq=2", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + var resp e2eWsSentResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Events) != 1 || resp.Events[0].Seq != 3 { + t.Errorf("events=%+v, want one entry seq=3", resp.Events) + } + if resp.MaxSeq != 3 { + t.Errorf("max_seq=%d, want 3", resp.MaxSeq) + } + } + }) +} + +// TestE2EWsSent_SessionFilter_ReturnsOnlyMatchingEvents covers the +// Workstream 1 `session_id` query param: the endpoint should narrow the ring +// buffer to events whose backend-stamped SessionID matches the filter, sorted +// by SessionSeq ascending. `max_seq` carries the max SessionSeq for that +// (connection, session) pair, NOT the per-connection max. +func TestE2EWsSent_SessionFilter_ReturnsOnlyMatchingEvents(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + hub, cancel := newTestE2EHub(t) + defer cancel() + r := setupE2EWsSentRouter(t, hub) + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + client := gateways.NewClient("conn-sess", nil, hub, log) + registerAndWait(t, hub, client) + + // SubscribeToSession and BroadcastToSession are synchronous, so the ring + // buffer is fully populated once these calls return. + // Subscribe to two sessions and broadcast a mixed stream: + // session A: 2 events + // session B: 1 event + // session A: 1 event + hub.SubscribeToSession(client, "sa") + hub.SubscribeToSession(client, "sb") + hub.BroadcastToSession("sa", mustNotif(t, "a.1")) + hub.BroadcastToSession("sa", mustNotif(t, "a.2")) + hub.BroadcastToSession("sb", mustNotif(t, "b.1")) + hub.BroadcastToSession("sa", mustNotif(t, "a.3")) + + // subtests flattened into scoped blocks (synctest bans t.Run; reads must + // run before defer cancel() tears down the registration). + + // session A returns only a.* sorted by session_seq + { + req := httptest.NewRequest( + http.MethodGet, + "/api/v1/e2e/ws-sent?connection_id=conn-sess&session_id=sa", + nil, + ) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d, body=%s", w.Code, w.Body.String()) + } + var resp e2eWsSentResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Events) != 3 { + t.Fatalf("len(events)=%d, want 3 (body=%s)", len(resp.Events), w.Body.String()) + } + for i, e := range resp.Events { + if e.SessionID != "sa" { + t.Errorf("events[%d].SessionID=%q, want sa", i, e.SessionID) + } + wantSessionSeq := int64(i + 1) + if e.SessionSeq != wantSessionSeq { + t.Errorf("events[%d].SessionSeq=%d, want %d", i, e.SessionSeq, wantSessionSeq) + } + } + // MaxSeq carries the max SessionSeq for session A (3), not the + // per-connection max (which would be 4 — a.1, a.2, b.1, a.3). + if resp.MaxSeq != 3 { + t.Errorf("max_seq=%d, want 3 (max SessionSeq for sa)", resp.MaxSeq) + } + } + + // session B returns only b.* with monotonic session_seq + { + req := httptest.NewRequest( + http.MethodGet, + "/api/v1/e2e/ws-sent?connection_id=conn-sess&session_id=sb", + nil, + ) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + var resp e2eWsSentResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Events) != 1 { + t.Fatalf("len(events)=%d, want 1", len(resp.Events)) + } + if resp.Events[0].SessionSeq != 1 { + t.Errorf("events[0].SessionSeq=%d, want 1", resp.Events[0].SessionSeq) + } + if resp.MaxSeq != 1 { + t.Errorf("max_seq=%d, want 1", resp.MaxSeq) + } + } + + // without session_id, returns the full per-connection log + { + req := httptest.NewRequest( + http.MethodGet, + "/api/v1/e2e/ws-sent?connection_id=conn-sess", + nil, + ) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("status=%d", w.Code) + } + var resp e2eWsSentResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + // 4 broadcasts × 1 subscribing client = 4 stamped frames. + if len(resp.Events) != 4 { + t.Errorf("len(events)=%d, want 4 (per-connection backward-compat)", len(resp.Events)) + } + if resp.MaxSeq != 4 { + t.Errorf("max_seq=%d, want 4 (per-connection max)", resp.MaxSeq) + } + } + }) +} diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 3a476d8f3..cf613696f 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -794,7 +794,7 @@ func registerSecondaryRoutes( if p.services.Automation != nil { automationSvc = p.services.Automation.Service } - registerE2EResetRoutes(p.router, p.taskRepo, p.taskSvc, automationSvc, p.log) + registerE2EResetRoutes(p.router, p.taskRepo, p.taskSvc, automationSvc, p.gateway.Hub, p.log) if officetestharness.Enabled() { var officeAgentSvc *officeagents.AgentService diff --git a/apps/backend/internal/gateway/websocket/client.go b/apps/backend/internal/gateway/websocket/client.go index f9a039dfb..161164d54 100644 --- a/apps/backend/internal/gateway/websocket/client.go +++ b/apps/backend/internal/gateway/websocket/client.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "sync" + "sync/atomic" "time" "github.com/gorilla/websocket" @@ -42,6 +43,15 @@ type Client struct { mu sync.RWMutex closed bool logger *logger.Logger + + // seqCounter is the per-connection monotonic counter stamped onto every + // outbound envelope. First message has seq=1; zero means "unstamped" and + // is reserved for raw byte writes (which shouldn't happen in practice). + seqCounter atomic.Int64 + // sentLog records recent outbound envelopes for E2E gap detection. The + // FE compares its received-seq list to this server-side log to surface + // any dropped frames as real WS regressions instead of silent UI bugs. + sentLog *wsSentLog } // NewClient creates a new WebSocket client @@ -57,6 +67,7 @@ func NewClient(id string, conn *websocket.Conn, hub *Hub, log *logger.Logger) *C userSubscriptions: make(map[string]bool), runSubscriptions: make(map[string]bool), logger: log.WithFields(zap.String("client_id", id)), + sentLog: newWsSentLog(), } } @@ -450,14 +461,81 @@ func (c *Client) handleSessionUnfocus(msg *ws.Message) { c.sendMessage(resp) } -// sendMessage sends a message to the client -func (c *Client) sendMessage(msg *ws.Message) { +// sendMessage stamps the envelope with a monotonic per-connection seq and the +// connection ID, records it in the ring buffer, then enqueues it for the write +// pump. This is the single seam every outbound envelope MUST go through — +// missing one path means E2E tests see false-positive gaps. +// +// Mutates msg.Seq and msg.ConnectionID. For broadcasts that fan a single +// envelope to many clients, use sendStampedCopy instead so each client gets +// its own stamped envelope without clobbering the shared input. +func (c *Client) sendMessage(msg *ws.Message) bool { + return c.sendMessageForSession("", msg) +} + +// sendMessageForSession is sendMessage that additionally stamps a per-session +// monotonic SessionSeq using the hub's session-seq counter for the given +// sessionID. Pass "" to stamp only the per-connection seq (handshake, +// connection-wide notifications, task-routed and run-routed broadcasts whose +// routing key isn't a session). +func (c *Client) sendMessageForSession(sessionID string, msg *ws.Message) bool { + data, ok := c.stampAndMarshalForSession(sessionID, msg) + if !ok { + return false + } + return c.sendBytes(data) +} + +// sendStampedCopy clones the envelope, stamps the copy with this connection's +// seq + ID, and sends it. Used by Hub broadcast helpers (BroadcastToTask, +// BroadcastToUser, BroadcastToRun, broadcastMessage) so a single input +// *ws.Message fanned to N clients yields N distinct seq stamps without races +// on the shared envelope. Does NOT stamp SessionSeq — use +// sendStampedCopyForSession for session-routed broadcasts. +func (c *Client) sendStampedCopy(msg *ws.Message) bool { + return c.sendStampedCopyForSession("", msg) +} + +// sendStampedCopyForSession clones the envelope and stamps it with both the +// per-connection seq and the per-session SessionSeq for sessionID. Used by +// BroadcastToSession so cross-session misrouting (event for A delivered to +// B's handler) is detectable as a per-session-seq gap on the receiver. +func (c *Client) sendStampedCopyForSession(sessionID string, msg *ws.Message) bool { + if msg == nil { + return false + } + clone := *msg + return c.sendMessageForSession(sessionID, &clone) +} + +// stampAndMarshalForSession increments the per-connection seq counter, mutates +// the envelope to carry (seq, connection_id), optionally stamps a per-session +// SessionSeq, appends the entry to the ring buffer, and marshals to JSON. +// Returns the bytes plus a marshal-ok flag; caller drops on !ok. When sessionID +// is "" the per-session counter is skipped and SessionSeq stays zero (omitted +// from the JSON wire format). +// +// We stamp BEFORE Append so the ring buffer's max_seq matches what the client +// will actually see on the wire. Order: per-connection then per-session, so the +// ring buffer and the wire frame agree on both seqs. +func (c *Client) stampAndMarshalForSession(sessionID string, msg *ws.Message) ([]byte, bool) { + seq := c.seqCounter.Add(1) + msg.Seq = seq + msg.ConnectionID = c.ID + var sessionSeq int64 + if sessionID != "" && c.hub != nil { + sessionSeq = c.hub.nextSessionSeq(sessionID) + msg.SessionSeq = sessionSeq + } data, err := json.Marshal(msg) if err != nil { c.logger.Error("Failed to marshal message", zap.Error(err)) - return + return nil, false + } + if c.sentLog != nil { + c.sentLog.Append(seq, sessionSeq, sessionID, string(msg.Type), msg.Action, time.Now().UTC()) } - c.sendBytes(data) + return data, true } func (c *Client) sendBytes(data []byte) bool { diff --git a/apps/backend/internal/gateway/websocket/client_seq_test.go b/apps/backend/internal/gateway/websocket/client_seq_test.go new file mode 100644 index 000000000..5bbd74308 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/client_seq_test.go @@ -0,0 +1,199 @@ +package websocket + +import ( + "encoding/json" + "sync" + "testing" + "time" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +// TestSendMessage_StampsMonotonicSeq verifies that each outbound envelope +// receives a per-connection seq starting at 1 and incrementing by one. This +// is the contract E2E gap-detection depends on. +func TestSendMessage_StampsMonotonicSeq(t *testing.T) { + c := newTestClient("conn-seq") + + for i := 1; i <= 5; i++ { + msg, err := ws.NewNotification("test.event", map[string]int{"i": i}) + if err != nil { + t.Fatalf("build msg: %v", err) + } + if !c.sendMessage(msg) { + t.Fatalf("sendMessage %d returned false", i) + } + if msg.Seq != int64(i) { + t.Errorf("envelope.Seq=%d, want %d", msg.Seq, i) + } + if msg.ConnectionID != "conn-seq" { + t.Errorf("envelope.ConnectionID=%q, want conn-seq", msg.ConnectionID) + } + } + + // Frames on the wire must carry the seq too. + for i := 1; i <= 5; i++ { + select { + case raw := <-c.send: + var m ws.Message + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("decode frame %d: %v", i, err) + } + if m.Seq != int64(i) { + t.Errorf("frame %d Seq=%d, want %d", i, m.Seq, i) + } + if m.ConnectionID != "conn-seq" { + t.Errorf("frame %d ConnectionID=%q, want conn-seq", i, m.ConnectionID) + } + default: + t.Fatalf("expected frame %d on send channel", i) + } + } +} + +// TestSendStampedCopy_DoesNotMutateInput guards the broadcast fan-out path — +// the input *ws.Message is shared across many client.sendStampedCopy calls, +// so stamping must not leak into the shared envelope. +func TestSendStampedCopy_DoesNotMutateInput(t *testing.T) { + c1 := newTestClient("c1") + c2 := newTestClient("c2") + + msg, err := ws.NewNotification("shared.event", map[string]string{"k": "v"}) + if err != nil { + t.Fatalf("build msg: %v", err) + } + + if !c1.sendStampedCopy(msg) { + t.Fatal("sendStampedCopy c1 returned false") + } + if !c2.sendStampedCopy(msg) { + t.Fatal("sendStampedCopy c2 returned false") + } + + if msg.Seq != 0 { + t.Errorf("input msg.Seq mutated to %d (broadcast must not modify shared envelope)", msg.Seq) + } + if msg.ConnectionID != "" { + t.Errorf("input msg.ConnectionID mutated to %q (broadcast must not modify shared envelope)", msg.ConnectionID) + } +} + +// TestSendMessage_ConcurrentStampsStayMonotonic guards the atomic Add path — +// concurrent sends should produce contiguous distinct seqs. +func TestSendMessage_ConcurrentStampsStayMonotonic(t *testing.T) { + c := newTestClient("c-concurrent") + // Resize send buffer so concurrent sends don't drop. + c.send = make(chan []byte, 200) + + const N = 100 + var wg sync.WaitGroup + for range N { + wg.Add(1) + go func() { + defer wg.Done() + msg, _ := ws.NewNotification("e", nil) + c.sendMessage(msg) + }() + } + wg.Wait() + + seen := make(map[int64]bool, N) + for range N { + raw := <-c.send + var m ws.Message + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("decode: %v", err) + } + if m.Seq < 1 || m.Seq > int64(N) { + t.Errorf("seq=%d out of expected range [1,%d]", m.Seq, N) + } + if seen[m.Seq] { + t.Errorf("seq=%d emitted twice", m.Seq) + } + seen[m.Seq] = true + } + if len(seen) != N { + t.Errorf("got %d distinct seqs, want %d", len(seen), N) + } +} + +// TestWsSentLog_Append_StoresOldestToNewest exercises the basic ring case +// where the buffer is not yet full. +func TestWsSentLog_Append_StoresOldestToNewest(t *testing.T) { + l := newWsSentLogWithCapacity(4) + base := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) + for i := 1; i <= 3; i++ { + l.Append(int64(i), 0, "", "notification", "a.b", base.Add(time.Duration(i)*time.Second)) + } + + entries := l.Since(0) + if len(entries) != 3 { + t.Fatalf("len=%d, want 3", len(entries)) + } + for i, e := range entries { + want := int64(i + 1) + if e.Seq != want { + t.Errorf("entries[%d].Seq=%d, want %d", i, e.Seq, want) + } + } + if got := l.Max(); got != 3 { + t.Errorf("Max=%d, want 3", got) + } +} + +// TestWsSentLog_RingDiscardsOldestWhenFull is the critical invariant: a +// connection that lives long enough should still report at least the last +// 5000 events without leaking memory. +func TestWsSentLog_RingDiscardsOldestWhenFull(t *testing.T) { + const capacity = 5 + l := newWsSentLogWithCapacity(capacity) + base := time.Date(2026, 5, 28, 0, 0, 0, 0, time.UTC) + for i := 1; i <= 12; i++ { + l.Append(int64(i), 0, "", "notification", "a.b", base) + } + + entries := l.Since(0) + if len(entries) != capacity { + t.Fatalf("len=%d, want %d", len(entries), capacity) + } + // Newest five = seqs 8..12, oldest first. + for i, e := range entries { + want := int64(8 + i) + if e.Seq != want { + t.Errorf("entries[%d].Seq=%d, want %d", i, e.Seq, want) + } + } + if got := l.Max(); got != 12 { + t.Errorf("Max=%d, want 12", got) + } +} + +// TestWsSentLog_SinceFiltersStrictlyGreater confirms the since_seq query +// semantics — the contract the E2E endpoint exposes. +func TestWsSentLog_SinceFiltersStrictlyGreater(t *testing.T) { + l := newWsSentLogWithCapacity(8) + for i := 1; i <= 5; i++ { + l.Append(int64(i), 0, "", "notification", "a.b", time.Now()) + } + + cases := []struct { + since int64 + wantLen int + wantMin int64 + }{ + {since: 0, wantLen: 5, wantMin: 1}, + {since: 2, wantLen: 3, wantMin: 3}, + {since: 5, wantLen: 0, wantMin: 0}, + {since: 99, wantLen: 0, wantMin: 0}, + } + for _, tc := range cases { + got := l.Since(tc.since) + if len(got) != tc.wantLen { + t.Errorf("Since(%d) len=%d, want %d", tc.since, len(got), tc.wantLen) + continue + } + if tc.wantLen > 0 && got[0].Seq != tc.wantMin { + t.Errorf("Since(%d)[0].Seq=%d, want %d", tc.since, got[0].Seq, tc.wantMin) + } + } +} diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go index aa1b18347..b0c2cf224 100644 --- a/apps/backend/internal/gateway/websocket/hub.go +++ b/apps/backend/internal/gateway/websocket/hub.go @@ -3,8 +3,8 @@ package websocket import ( "context" - "encoding/json" "sync" + "time" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" @@ -52,6 +52,19 @@ type Hub struct { // like session.launch. It still cancels on server shutdown. dispatchCtx context.Context + // sessionSeqs holds the per-session monotonic counter used to stamp + // SessionSeq on session-routed outbound envelopes (Phase 2 WS + // accounting). The value type is *atomic.Int64 so stamping is + // lock-free. Entries are created on SubscribeToSession and removed + // by sessionSubscriberCounts dropping to zero — see hub_session_seq.go. + sessionSeqs sync.Map // map[string]*atomic.Int64 + // sessionSubscriberCounts tracks how many clients are currently + // subscribed to each session ID so the per-session counter can be + // dropped when the last subscriber leaves. Independent of + // sessionSubscribers (which is a per-session set of *Client used for + // fan-out) to keep the lifecycle decoupled from the focus tracker. + sessionSubscriberCounts sync.Map // map[string]*atomic.Int64 + mu sync.RWMutex logger *logger.Logger } @@ -118,6 +131,20 @@ func (h *Hub) closeAllClients() { h.sessionMode.focusByClient = make(map[string]map[*Client]bool) h.mu.Unlock() + // Drain the session-seq lifecycle maps. sync.Map can't be reset by + // reassigning a new instance because consumers hold method receivers, so + // walk both maps and Delete the keys we see. Hub shutdown is a single- + // threaded event from the harness's perspective so we don't race with + // concurrent Sub/Unsub here. + h.sessionSeqs.Range(func(key, _ any) bool { + h.sessionSeqs.Delete(key) + return true + }) + h.sessionSubscriberCounts.Range(func(key, _ any) bool { + h.sessionSubscriberCounts.Delete(key) + return true + }) + h.stopAllPendingTransitions() } @@ -142,9 +169,14 @@ func (h *Hub) removeClient(client *Client) { // Disconnect can change mode either way: removing the last subscriber drops // to paused, removing the last focuser drops fast → slow. affectedSessions := make([]string, 0, len(client.sessionSubscriptions)+len(client.sessionFocus)) + // Also capture the per-session-seq lifecycle decrements so they happen + // outside the hub lock (sync.Map is internally synchronized but we still + // shouldn't hold h.mu longer than necessary). + sessionSeqDecrements := make([]string, 0, len(client.sessionSubscriptions)) for sessionID := range client.sessionSubscriptions { removeClientFromSubscriberMap(h.sessionSubscribers, sessionID, client) affectedSessions = append(affectedSessions, sessionID) + sessionSeqDecrements = append(sessionSeqDecrements, sessionID) } for sessionID := range client.sessionFocus { removeClientFromSubscriberMap(h.sessionMode.focusByClient, sessionID, client) @@ -158,6 +190,13 @@ func (h *Hub) removeClient(client *Client) { } h.mu.Unlock() + // Decrement session-seq lifecycle counters outside the hub lock. Each + // decrement deletes the counter entry when it reaches zero, ensuring + // disconnects without explicit session.unsubscribe still drain the maps. + for _, sessionID := range sessionSeqDecrements { + h.decSessionSubscribers(sessionID) + } + for _, sessionID := range dedupStrings(affectedSessions) { h.recomputeSessionMode(sessionID) } @@ -194,22 +233,18 @@ func removeClientFromSubscriberMap(subscribers map[string]map[*Client]bool, key } } -// broadcastMessage sends a message to relevant clients +// broadcastMessage sends a message to every connected client. Each gets its +// own marshalled frame because seq is stamped per-connection — we can't share +// a pre-marshalled buffer the way we did before seq accounting landed. func (h *Hub) broadcastMessage(msg *ws.Message) { - data, err := json.Marshal(msg) - if err != nil { - h.logger.Error("Failed to marshal broadcast message", zap.Error(err)) - return - } - h.mu.RLock() - defer h.mu.RUnlock() - - // For now, broadcast to all clients - // TODO: Add topic-based routing for task-specific notifications + clients := make([]*Client, 0, len(h.clients)) for client := range h.clients { - client.sendBytes(data) + clients = append(clients, client) } + h.mu.RUnlock() + + h.fanoutToClients(msg, clients) } // Register adds a client to the hub @@ -239,34 +274,42 @@ func (h *Hub) getSubscribersLocked(m map[string]map[*Client]bool, id string) []* return clients } -// sendToClients delivers a pre-marshalled message to a list of clients. -func (h *Hub) sendToClients(data []byte, clients []*Client, action string) { +// fanoutToClients stamps a per-connection seq onto a clone of msg for each +// client and enqueues the marshalled bytes. Used by non-session routing paths +// (BroadcastToTask, BroadcastToUser, BroadcastToRun, broadcastMessage) where +// the routing key isn't a session ID. Marshals per client (cost: O(N)) +// because seq must be unique per connection. +func (h *Hub) fanoutToClients(msg *ws.Message, clients []*Client) { + h.fanoutToClientsForSession("", msg, clients) +} + +// fanoutToClientsForSession is fanoutToClients that additionally stamps a +// per-session SessionSeq on each clone using the hub's session-seq counter +// for sessionID. Used by BroadcastToSession so the per-session stream a +// single client receives is a strictly monotonic seq sequence — cross-session +// misrouting becomes a SessionSeq gap on the receiver. +func (h *Hub) fanoutToClientsForSession(sessionID string, msg *ws.Message, clients []*Client) { for _, client := range clients { - if client.sendBytes(data) { + if client.sendStampedCopyForSession(sessionID, msg) { h.logger.Debug("Sent message to client", zap.String("client_id", client.ID), - zap.String("action", action)) + zap.String("action", msg.Action)) } else { h.logger.Warn("Client send buffer full, dropping message", zap.String("client_id", client.ID), - zap.String("action", action)) + zap.String("action", msg.Action)) } } } // BroadcastToTask sends a notification to clients subscribed to a specific task func (h *Hub) BroadcastToTask(taskID string, msg *ws.Message) { - data, err := json.Marshal(msg) - if err != nil { - h.logger.Error("Failed to marshal message", zap.Error(err)) - return - } clients := h.getSubscribersLocked(h.taskSubscribers, taskID) h.logger.Debug("BroadcastToTask", zap.String("task_id", taskID), zap.String("action", msg.Action), zap.Int("subscriber_count", len(clients))) - h.sendToClients(data, clients, msg.Action) + h.fanoutToClients(msg, clients) } // getSessionRecipientsLocked returns the deduped set of clients that should @@ -304,32 +347,22 @@ func (h *Hub) getSessionRecipientsLocked(sessionID string) []*Client { // BroadcastToSession sends a notification to clients subscribed to OR focused on // a specific session. See getSessionRecipientsLocked for why focus is included. func (h *Hub) BroadcastToSession(sessionID string, msg *ws.Message) { - data, err := json.Marshal(msg) - if err != nil { - h.logger.Error("Failed to marshal message", zap.Error(err)) - return - } clients := h.getSessionRecipientsLocked(sessionID) h.logger.Debug("BroadcastToSession", zap.String("session_id", sessionID), zap.String("action", msg.Action), zap.Int("recipient_count", len(clients))) - h.sendToClients(data, clients, msg.Action) + h.fanoutToClientsForSession(sessionID, msg, clients) } // BroadcastToUser sends a notification to clients subscribed to a specific user func (h *Hub) BroadcastToUser(userID string, msg *ws.Message) { - data, err := json.Marshal(msg) - if err != nil { - h.logger.Error("Failed to marshal message", zap.Error(err)) - return - } clients := h.getSubscribersLocked(h.userSubscribers, userID) h.logger.Debug("BroadcastToUser", zap.String("user_id", userID), zap.String("action", msg.Action), zap.Int("subscriber_count", len(clients))) - h.sendToClients(data, clients, msg.Action) + h.fanoutToClients(msg, clients) } // SubscribeToTask subscribes a client to task notifications @@ -354,10 +387,19 @@ func (h *Hub) SubscribeToSession(client *Client, sessionID string) { if _, ok := h.sessionSubscribers[sessionID]; !ok { h.sessionSubscribers[sessionID] = make(map[*Client]bool) } + alreadySubscribed := client.sessionSubscriptions[sessionID] h.sessionSubscribers[sessionID][client] = true client.sessionSubscriptions[sessionID] = true h.mu.Unlock() + // Only bump the lifecycle counter on a fresh subscribe — a redundant + // session.subscribe (e.g. resubscribe-after-reconnect when state is + // already there) must not skew the counter and leak a session_seq + // entry past last-unsubscribe. + if !alreadySubscribed { + h.incSessionSubscribers(sessionID) + } + h.logger.Debug("Client subscribed to session", zap.String("client_id", client.ID), zap.String("session_id", sessionID)) @@ -368,6 +410,7 @@ func (h *Hub) SubscribeToSession(client *Client, sessionID string) { // UnsubscribeFromSession unsubscribes a client from session notifications func (h *Hub) UnsubscribeFromSession(client *Client, sessionID string) { h.mu.Lock() + wasSubscribed := client.sessionSubscriptions[sessionID] delete(client.sessionSubscriptions, sessionID) if clients, ok := h.sessionSubscribers[sessionID]; ok { delete(clients, client) @@ -377,6 +420,10 @@ func (h *Hub) UnsubscribeFromSession(client *Client, sessionID string) { } h.mu.Unlock() + if wasSubscribed { + h.decSessionSubscribers(sessionID) + } + h.recomputeSessionMode(sessionID) } @@ -412,17 +459,12 @@ func (h *Hub) UnsubscribeFromUser(client *Client, userID string) { // BroadcastToRun sends a notification to clients subscribed to a specific office run id. func (h *Hub) BroadcastToRun(runID string, msg *ws.Message) { - data, err := json.Marshal(msg) - if err != nil { - h.logger.Error("Failed to marshal message", zap.Error(err)) - return - } clients := h.getSubscribersLocked(h.runSubscribers, runID) h.logger.Debug("BroadcastToRun", zap.String("run_id", runID), zap.String("action", msg.Action), zap.Int("subscriber_count", len(clients))) - h.sendToClients(data, clients, msg.Action) + h.fanoutToClients(msg, clients) } // SubscribeToRun subscribes a client to office run-event notifications. @@ -476,6 +518,77 @@ func (h *Hub) GetClientCount() int { return len(h.clients) } +// WsSentEvent is the public shape exposed by GetSentEventsFor — mirrors the +// internal ring buffer entry but lives on Hub so external callers (the E2E +// endpoint, tests) don't have to reach into unexported types. +// SessionSeq and SessionID are non-zero/non-empty for events routed to a +// specific session (BroadcastToSession); zero/empty for connection-wide +// notifications. The E2E ws-sent endpoint exposes a `session_id` filter that +// returns just those entries sorted by SessionSeq ascending — that's the +// authoritative per-session stream a single client should have observed. +type WsSentEvent struct { + Seq int64 `json:"seq"` + SessionSeq int64 `json:"session_seq,omitempty"` + SessionID string `json:"session_id,omitempty"` + Type string `json:"type"` + Action string `json:"action"` + SentAt time.Time `json:"sent_at"` +} + +// GetSentEventsFor returns the recorded outbound envelopes for the given +// connection ID with seq > sinceSeq, plus the max seq ever stamped on that +// connection. The bool is false when no such connection is registered. +// +// Used by the E2E /api/v1/e2e/ws-sent endpoint so tests can diff the FE's +// received-seq list against the BE's authoritative send log. Pass sinceSeq=0 +// to dump the whole ring buffer (last 5000 events). +func (h *Hub) GetSentEventsFor(connectionID string, sinceSeq int64) ([]WsSentEvent, int64, bool) { + client, ok := h.getClientByID(connectionID) + if !ok || client.sentLog == nil { + return nil, 0, false + } + entries := client.sentLog.Since(sinceSeq) + return entries, client.sentLog.Max(), true +} + +// GetSentEventsForSession is GetSentEventsFor narrowed to a single session ID. +// Returns only entries whose stamped SessionID matches, sorted by SessionSeq +// ascending — i.e. the authoritative per-session stream a single subscriber +// should have observed. The second return value is the max SessionSeq stamped +// for this (connection, session) pair. +// +// Used by the E2E /api/v1/e2e/ws-sent endpoint when the `session_id` query +// param is set: per-session gap detection that the per-connection seq cannot +// catch on its own (cross-session misrouting). +func (h *Hub) GetSentEventsForSession(connectionID, sessionID string) ([]WsSentEvent, int64, bool) { + client, ok := h.getClientByID(connectionID) + if !ok || client.sentLog == nil { + return nil, 0, false + } + entries := client.sentLog.SinceForSession(0, sessionID) + var maxSessionSeq int64 + for _, e := range entries { + if e.SessionSeq > maxSessionSeq { + maxSessionSeq = e.SessionSeq + } + } + return entries, maxSessionSeq, true +} + +// getClientByID returns the registered client with the given ID. Multiple +// connections cannot share an ID under current handler wiring (the ID is +// derived from a server-side random token), so first-match is enough. +func (h *Hub) getClientByID(id string) (*Client, bool) { + h.mu.RLock() + defer h.mu.RUnlock() + for c := range h.clients { + if c.ID == id { + return c, true + } + } + return nil, false +} + // GetDispatcher returns the message dispatcher func (h *Hub) GetDispatcher() *ws.Dispatcher { return h.dispatcher diff --git a/apps/backend/internal/gateway/websocket/hub_session_mode_test.go b/apps/backend/internal/gateway/websocket/hub_session_mode_test.go index 401fa4c0f..1b00da7bb 100644 --- a/apps/backend/internal/gateway/websocket/hub_session_mode_test.go +++ b/apps/backend/internal/gateway/websocket/hub_session_mode_test.go @@ -35,7 +35,9 @@ func newTestClient(id string) *Client { sessionSubscriptions: map[string]bool{}, sessionFocus: map[string]bool{}, userSubscriptions: map[string]bool{}, + runSubscriptions: map[string]bool{}, logger: log, + sentLog: newWsSentLog(), } } diff --git a/apps/backend/internal/gateway/websocket/hub_session_seq.go b/apps/backend/internal/gateway/websocket/hub_session_seq.go new file mode 100644 index 000000000..63a2e34d6 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/hub_session_seq.go @@ -0,0 +1,91 @@ +package websocket + +import ( + "sync/atomic" +) + +// nextSessionSeq returns the next monotonic session_seq for sessionID. If no +// counter exists yet, one is created lazily (race-free via sync.Map.LoadOrStore). +// The returned counter starts at 1 for the first stamped event. +// +// Called from stampAndMarshalForSession on every outbound envelope that is +// routed to a specific session (BroadcastToSession and the session-routed +// fan-out paths). +// A connection-wide notification (no sessionID at stamp time) gets a zero +// SessionSeq, which is correctly omitted from the JSON wire format. +func (h *Hub) nextSessionSeq(sessionID string) int64 { + if sessionID == "" { + return 0 + } + v, ok := h.sessionSeqs.Load(sessionID) + if !ok { + v, _ = h.sessionSeqs.LoadOrStore(sessionID, &atomic.Int64{}) + } + return v.(*atomic.Int64).Add(1) +} + +// incSessionSubscribers bumps the per-session subscriber count and eagerly +// creates the matching session_seq counter so the lifecycle invariant +// (subscriberCount > 0 ⇒ sessionSeqs has an entry) holds even before any +// event is broadcast on the session. Called by SubscribeToSession after the +// client is added to sessionSubscribers. +func (h *Hub) incSessionSubscribers(sessionID string) { + if sessionID == "" { + return + } + v, ok := h.sessionSubscriberCounts.Load(sessionID) + if !ok { + v, _ = h.sessionSubscriberCounts.LoadOrStore(sessionID, &atomic.Int64{}) + } + v.(*atomic.Int64).Add(1) + // Eagerly create the per-session counter so a subscribe immediately + // reflects in sessionSeqs. Without this, the test for + // "subscribe → disconnect drains both maps" would race a lazy create + // against the immediate decrement on disconnect. + if _, exists := h.sessionSeqs.Load(sessionID); !exists { + h.sessionSeqs.LoadOrStore(sessionID, &atomic.Int64{}) + } +} + +// decSessionSubscribers decrements the per-session subscriber count. When it +// reaches zero, the counter entry AND the matching session_seq counter are +// deleted so a long-lived hub doesn't accumulate one *atomic.Int64 per session +// ever seen. Called by UnsubscribeFromSession and by removeClient's cleanup +// loop (a client disconnecting without an explicit unsubscribe). +func (h *Hub) decSessionSubscribers(sessionID string) { + if sessionID == "" { + return + } + v, ok := h.sessionSubscriberCounts.Load(sessionID) + if !ok { + return + } + n := v.(*atomic.Int64).Add(-1) + if n <= 0 { + h.sessionSubscriberCounts.Delete(sessionID) + h.sessionSeqs.Delete(sessionID) + } +} + +// sessionSeqCountForTest returns the number of live per-session counters. +// Test-only helper used by the lifecycle regression test. +func (h *Hub) sessionSeqCountForTest() int { + n := 0 + h.sessionSeqs.Range(func(_, _ any) bool { + n++ + return true + }) + return n +} + +// sessionSubscriberCountForTest returns the number of live subscriber-count +// entries. Test-only helper paired with sessionSeqCountForTest to confirm +// both maps drain together. +func (h *Hub) sessionSubscriberCountForTest() int { + n := 0 + h.sessionSubscriberCounts.Range(func(_, _ any) bool { + n++ + return true + }) + return n +} diff --git a/apps/backend/internal/gateway/websocket/hub_session_seq_test.go b/apps/backend/internal/gateway/websocket/hub_session_seq_test.go new file mode 100644 index 000000000..ebc8d90cb --- /dev/null +++ b/apps/backend/internal/gateway/websocket/hub_session_seq_test.go @@ -0,0 +1,339 @@ +package websocket + +import ( + "encoding/json" + "sync" + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +// subscribeClientLockedForTest registers a client on a sessionSubscribers +// entry without touching focus/mode bookkeeping. The session_seq lifecycle +// only depends on the subscription, not on focus. +func subscribeClientForTest(t *testing.T, h *Hub, c *Client, sessionID string) { + t.Helper() + c.hub = h + h.mu.Lock() + h.clients[c] = true + h.mu.Unlock() + h.SubscribeToSession(c, sessionID) +} + +// TestHub_BroadcastToSession_StampsIndependentSessionSeqs is the core +// per-session sequencing contract. Two sessions broadcasting concurrently to +// the same client must produce two strictly monotonic SessionSeq streams that +// are independent of each other — interleaving per-connection seq, monotonic +// per-session seq per session. +func TestHub_BroadcastToSession_StampsIndependentSessionSeqs(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c-multi") + c.send = make(chan []byte, 64) + subscribeClientForTest(t, h, c, "sess-A") + subscribeClientForTest(t, h, c, "sess-B") + + // Interleave broadcasts across A and B. + for i := range 5 { + msgA, _ := ws.NewNotification("a.evt", map[string]int{"i": i}) + h.BroadcastToSession("sess-A", msgA) + msgB, _ := ws.NewNotification("b.evt", map[string]int{"i": i}) + h.BroadcastToSession("sess-B", msgB) + } + + // Read all 10 frames off the send channel and group by session. + type framed struct { + seq int64 + sessionSeq int64 + action string + } + frames := make([]framed, 0, 10) + for range 10 { + raw := <-c.send + var m ws.Message + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("decode: %v", err) + } + // SessionID isn't on the envelope wire format — derive it from the + // authoritative ring buffer for the assertion below. + frames = append(frames, framed{ + seq: m.Seq, + sessionSeq: m.SessionSeq, + action: m.Action, + }) + } + + // Verify per-connection seq is strictly monotonic across BOTH sessions + // (1..10) — this is the per-connection invariant from Phase 1. + for i, f := range frames { + want := int64(i + 1) + if f.seq != want { + t.Errorf("frame %d: connection seq=%d, want %d", i, f.seq, want) + } + if f.sessionSeq <= 0 { + t.Errorf("frame %d: SessionSeq=%d, want > 0 (session-routed event)", i, f.sessionSeq) + } + } + + // Cross-check against the ring buffer: per-session streams must be 1..5 + // for both A and B independently. + for _, sid := range []string{"sess-A", "sess-B"} { + entries, maxSessionSeq, ok := h.GetSentEventsForSession(c.ID, sid) + if !ok { + t.Fatalf("session %q: GetSentEventsForSession ok=false", sid) + } + if maxSessionSeq != 5 { + t.Errorf("session %q: maxSessionSeq=%d, want 5", sid, maxSessionSeq) + } + if len(entries) != 5 { + t.Fatalf("session %q: len(entries)=%d, want 5", sid, len(entries)) + } + for i, e := range entries { + wantSeq := int64(i + 1) + if e.SessionSeq != wantSeq { + t.Errorf("session %q entries[%d].SessionSeq=%d, want %d", sid, i, e.SessionSeq, wantSeq) + } + if e.SessionID != sid { + t.Errorf("session %q entries[%d].SessionID=%q, want %q", sid, i, e.SessionID, sid) + } + } + } +} + +// TestHub_BroadcastToSession_SharedCounterAcrossFanout covers the +// hub-level-counter contract: the session_seq counter lives on the Hub, not +// on the client, so each fan-out clone consumes a distinct SessionSeq value. +// With two clients both subscribed to the same session, a single broadcast +// produces two stamped frames whose SessionSeqs span the next two counter +// values. (Per-(connection, session) gap detection on the FE is the primary +// design driver — multi-subscriber per-session is not a guaranteed-monotonic +// case from a single client's perspective, and that's acceptable: production +// has one tab per user per session.) +func TestHub_BroadcastToSession_SharedCounterAcrossFanout(t *testing.T) { + h := newTestHub(t) + a := newTestClient("ca") + b := newTestClient("cb") + a.send = make(chan []byte, 4) + b.send = make(chan []byte, 4) + subscribeClientForTest(t, h, a, "sess-shared") + subscribeClientForTest(t, h, b, "sess-shared") + + msg, _ := ws.NewNotification("shared.evt", nil) + h.BroadcastToSession("sess-shared", msg) + + for _, c := range []*Client{a, b} { + entries, _, ok := h.GetSentEventsForSession(c.ID, "sess-shared") + if !ok || len(entries) == 0 { + t.Fatalf("client %s: no entries", c.ID) + } + } + + // The shared counter is at 2 (one Add per fan-out client). + v, ok := h.sessionSeqs.Load("sess-shared") + if !ok { + t.Fatal("session_seq counter not created") + } + if got := v.(interface{ Load() int64 }).Load(); got != 2 { + t.Errorf("session_seq counter=%d, want 2 (one Add per fan-out client)", got) + } +} + +// TestHub_SessionSeq_CounterLifecycle is the leak-prevention regression test +// the plan calls out: 1000 subscribe/unsubscribe cycles must leave both the +// session_seq counter map AND the subscriber-count map at len 0. +func TestHub_SessionSeq_CounterLifecycle(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c-lifecycle") + h.mu.Lock() + h.clients[c] = true + h.mu.Unlock() + c.hub = h + + const cycles = 1000 + for i := range cycles { + // Use a unique sessionID per cycle so a stale entry from one cycle + // can't masquerade as the next cycle's entry. + sid := "sess-cycle-" + intToStr(i) + h.SubscribeToSession(c, sid) + h.UnsubscribeFromSession(c, sid) + } + + if got := h.sessionSeqCountForTest(); got != 0 { + t.Errorf("sessionSeqs leaked: len=%d, want 0", got) + } + if got := h.sessionSubscriberCountForTest(); got != 0 { + t.Errorf("sessionSubscriberCounts leaked: len=%d, want 0", got) + } +} + +// TestHub_SessionSeq_CounterDeletedOnDisconnect covers the implicit-cleanup +// path: a client that disconnects without explicit session.unsubscribe still +// drains the lifecycle maps via removeClient. +func TestHub_SessionSeq_CounterDeletedOnDisconnect(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c-disconnect") + c.send = make(chan []byte, 8) + subscribeClientForTest(t, h, c, "sess-disco") + + if got := h.sessionSeqCountForTest(); got != 1 { + t.Errorf("after subscribe sessionSeqs len=%d, want 1", got) + } + + h.removeClient(c) + + if got := h.sessionSeqCountForTest(); got != 0 { + t.Errorf("after disconnect sessionSeqs len=%d, want 0", got) + } + if got := h.sessionSubscriberCountForTest(); got != 0 { + t.Errorf("after disconnect sessionSubscriberCounts len=%d, want 0", got) + } +} + +// TestHub_SessionSeq_TwoSubscribersOneUnsubscribe verifies the refcount: as +// long as ONE subscriber remains, the counter stays alive so its in-flight +// SessionSeq stream doesn't reset to 1 mid-conversation. +func TestHub_SessionSeq_TwoSubscribersOneUnsubscribe(t *testing.T) { + h := newTestHub(t) + a := newTestClient("ka") + b := newTestClient("kb") + a.send = make(chan []byte, 4) + b.send = make(chan []byte, 4) + subscribeClientForTest(t, h, a, "sess-rc") + subscribeClientForTest(t, h, b, "sess-rc") + + // Burn a few session_seq values. + for range 3 { + msg, _ := ws.NewNotification("e", nil) + h.BroadcastToSession("sess-rc", msg) + } + + // Unsubscribe one — the counter must still be live with its current value. + h.UnsubscribeFromSession(a, "sess-rc") + + v, ok := h.sessionSeqs.Load("sess-rc") + if !ok { + t.Fatal("session_seq counter dropped while b is still subscribed") + } + // 3 broadcasts × 2 fan-out clients = 6 Add calls; after the first + // unsubscribe the counter remains at 6 (subsequent broadcasts go only to b). + if got := v.(interface{ Load() int64 }).Load(); got != 6 { + t.Errorf("session_seq counter=%d, want 6", got) + } + + // Final unsubscribe drops both maps. + h.UnsubscribeFromSession(b, "sess-rc") + if got := h.sessionSeqCountForTest(); got != 0 { + t.Errorf("after final unsubscribe sessionSeqs len=%d, want 0", got) + } +} + +// TestHub_GetSentEventsForSession_ConnectionWideEventsAreFiltered ensures the +// per-session view only returns session-routed events. A connection-wide +// notification (BroadcastToTask, broadcast) emitted in between must not +// appear in the session filter even though it has a per-connection seq. +func TestHub_GetSentEventsForSession_ConnectionWideEventsAreFiltered(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c-mixed") + c.send = make(chan []byte, 16) + subscribeClientForTest(t, h, c, "sess-mix") + + // 1: session-routed. + a, _ := ws.NewNotification("s.evt", nil) + h.BroadcastToSession("sess-mix", a) + // 2: connection-wide (BroadcastToTask with no subscribers reaches no one + // — use the lower-level helper to stamp without filtering). + wide, _ := ws.NewNotification("conn.evt", nil) + c.sendStampedCopy(wide) + // 3: session-routed again. + b, _ := ws.NewNotification("s.evt", nil) + h.BroadcastToSession("sess-mix", b) + + entries, maxSessionSeq, ok := h.GetSentEventsForSession(c.ID, "sess-mix") + if !ok { + t.Fatal("ok=false") + } + if len(entries) != 2 { + t.Fatalf("len(entries)=%d, want 2 (only session-routed)", len(entries)) + } + if maxSessionSeq != 2 { + t.Errorf("maxSessionSeq=%d, want 2", maxSessionSeq) + } + for i, e := range entries { + if e.SessionSeq != int64(i+1) { + t.Errorf("entries[%d].SessionSeq=%d, want %d", i, e.SessionSeq, i+1) + } + if e.SessionID != "sess-mix" { + t.Errorf("entries[%d].SessionID=%q, want sess-mix", i, e.SessionID) + } + } + + // The full per-connection log still has all three entries. + all, _, _ := h.GetSentEventsFor(c.ID, 0) + if len(all) != 3 { + t.Errorf("len(all)=%d, want 3 (session + connection + session)", len(all)) + } +} + +// TestHub_BroadcastToSession_ConcurrentBroadcastsStayMonotonic guards the +// atomic counter under concurrent fan-out — every emitted SessionSeq is +// distinct, contiguous, and within [1, N]. +func TestHub_BroadcastToSession_ConcurrentBroadcastsStayMonotonic(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c-concurrent-session") + c.send = make(chan []byte, 512) + subscribeClientForTest(t, h, c, "sess-conc") + + const N = 200 + var wg sync.WaitGroup + for range N { + wg.Add(1) + go func() { + defer wg.Done() + msg, _ := ws.NewNotification("e", nil) + h.BroadcastToSession("sess-conc", msg) + }() + } + wg.Wait() + + entries, maxSessionSeq, ok := h.GetSentEventsForSession(c.ID, "sess-conc") + if !ok { + t.Fatal("ok=false") + } + if len(entries) != N { + t.Fatalf("len(entries)=%d, want %d", len(entries), N) + } + if maxSessionSeq != int64(N) { + t.Errorf("maxSessionSeq=%d, want %d", maxSessionSeq, N) + } + seen := make(map[int64]bool, N) + for _, e := range entries { + if e.SessionSeq < 1 || e.SessionSeq > int64(N) { + t.Errorf("SessionSeq=%d out of range [1,%d]", e.SessionSeq, N) + } + if seen[e.SessionSeq] { + t.Errorf("SessionSeq=%d emitted twice", e.SessionSeq) + } + seen[e.SessionSeq] = true + } + if len(seen) != N { + t.Errorf("got %d distinct SessionSeqs, want %d", len(seen), N) + } +} + +func intToStr(i int) string { + if i == 0 { + return "0" + } + digits := []byte{} + neg := i < 0 + if neg { + i = -i + } + for i > 0 { + digits = append([]byte{byte('0' + i%10)}, digits...) + i /= 10 + } + if neg { + digits = append([]byte{'-'}, digits...) + } + return string(digits) +} diff --git a/apps/backend/internal/gateway/websocket/hub_ws_sent_test.go b/apps/backend/internal/gateway/websocket/hub_ws_sent_test.go new file mode 100644 index 000000000..03734cc43 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/hub_ws_sent_test.go @@ -0,0 +1,131 @@ +package websocket + +import ( + "testing" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +// TestHub_GetSentEventsFor_UnknownConnection returns ok=false rather than an +// empty list so callers (the E2E endpoint) can distinguish "unknown" from +// "known but quiet". +func TestHub_GetSentEventsFor_UnknownConnection(t *testing.T) { + h := newTestHub(t) + events, maxSeq, ok := h.GetSentEventsFor("does-not-exist", 0) + if ok { + t.Errorf("ok=true, want false; events=%v maxSeq=%d", events, maxSeq) + } + if events != nil { + t.Errorf("events=%v, want nil", events) + } + if maxSeq != 0 { + t.Errorf("maxSeq=%d, want 0", maxSeq) + } +} + +// TestHub_GetSentEventsFor_ReturnsRingBufferContents covers the happy path: +// register a client, push frames through sendMessage, read the log back. +func TestHub_GetSentEventsFor_ReturnsRingBufferContents(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c-known") + c.hub = h + h.mu.Lock() + h.clients[c] = true + h.mu.Unlock() + + for i := 1; i <= 3; i++ { + msg, _ := ws.NewNotification("evt", map[string]int{"i": i}) + c.sendMessage(msg) + } + + events, maxSeq, ok := h.GetSentEventsFor("c-known", 0) + if !ok { + t.Fatal("ok=false for known connection") + } + if maxSeq != 3 { + t.Errorf("maxSeq=%d, want 3", maxSeq) + } + if len(events) != 3 { + t.Fatalf("len(events)=%d, want 3", len(events)) + } + for i, e := range events { + if e.Seq != int64(i+1) { + t.Errorf("events[%d].Seq=%d, want %d", i, e.Seq, i+1) + } + if e.Action != "evt" { + t.Errorf("events[%d].Action=%q, want evt", i, e.Action) + } + if e.Type != string(ws.MessageTypeNotification) { + t.Errorf("events[%d].Type=%q, want notification", i, e.Type) + } + if e.SentAt.IsZero() { + t.Errorf("events[%d].SentAt is zero", i) + } + } +} + +// TestHub_GetSentEventsFor_SinceFilter mirrors the endpoint's incremental +// polling behavior — the FE accountant pulls deltas, not the whole buffer. +func TestHub_GetSentEventsFor_SinceFilter(t *testing.T) { + h := newTestHub(t) + c := newTestClient("c-since") + h.mu.Lock() + h.clients[c] = true + h.mu.Unlock() + + for range 5 { + msg, _ := ws.NewNotification("evt", nil) + c.sendMessage(msg) + } + + events, maxSeq, ok := h.GetSentEventsFor("c-since", 3) + if !ok { + t.Fatal("ok=false") + } + if maxSeq != 5 { + t.Errorf("maxSeq=%d, want 5", maxSeq) + } + if len(events) != 2 { + t.Fatalf("len=%d, want 2 (seqs 4,5)", len(events)) + } + if events[0].Seq != 4 || events[1].Seq != 5 { + t.Errorf("got seqs=[%d,%d], want [4,5]", events[0].Seq, events[1].Seq) + } +} + +// TestHub_BroadcastToTask_StampsEachClientIndependently verifies the +// fan-out path correctly assigns per-connection seqs. Two clients each +// subscribed should get seq=1 individually rather than 1 then 2. +func TestHub_BroadcastToTask_StampsEachClientIndependently(t *testing.T) { + h := newTestHub(t) + a := newTestClient("a") + b := newTestClient("b") + h.mu.Lock() + h.clients[a] = true + h.clients[b] = true + h.taskSubscribers["t1"] = map[*Client]bool{a: true, b: true} + a.subscriptions["t1"] = true + b.subscriptions["t1"] = true + h.mu.Unlock() + + msg, _ := ws.NewNotification("task.evt", nil) + h.BroadcastToTask("t1", msg) + + for _, c := range []*Client{a, b} { + events, maxSeq, ok := h.GetSentEventsFor(c.ID, 0) + if !ok { + t.Fatalf("client %s not registered", c.ID) + } + if maxSeq != 1 { + t.Errorf("client %s maxSeq=%d, want 1", c.ID, maxSeq) + } + if len(events) != 1 || events[0].Seq != 1 { + t.Errorf("client %s events=%+v, want one entry with seq=1", c.ID, events) + } + } + + // The shared input envelope must NOT have been mutated. + if msg.Seq != 0 { + t.Errorf("input msg.Seq=%d, broadcast leaked stamp into shared envelope", msg.Seq) + } +} diff --git a/apps/backend/internal/gateway/websocket/ws_sent_log.go b/apps/backend/internal/gateway/websocket/ws_sent_log.go new file mode 100644 index 000000000..461bdb06f --- /dev/null +++ b/apps/backend/internal/gateway/websocket/ws_sent_log.go @@ -0,0 +1,121 @@ +package websocket + +import ( + "sort" + "sync" + "time" +) + +// wsSentLogCapacity is the maximum number of recent outbound envelopes retained +// per connection. The ring buffer overwrites the oldest entry when full. +const wsSentLogCapacity = 5000 + +// wsSentEntry is a record of one outbound envelope written to a connection. +// Aliased to the public WsSentEvent (declared in hub.go) so callers can avoid +// a per-element struct conversion when reading back via Hub.GetSentEventsFor. +type wsSentEntry = WsSentEvent + +// wsSentLog is a bounded ring buffer of recent outbound envelopes for a single +// connection. Used by the E2E /api/v1/e2e/ws-sent endpoint so tests can verify +// the FE received every seq the BE sent — gaps indicate WS regressions. +type wsSentLog struct { + mu sync.RWMutex + entries []wsSentEntry // ring; len == capacity once warm + head int // next write index + size int // current number of entries (≤ cap) + maxSeq int64 +} + +// newWsSentLog returns a log with the default capacity. +func newWsSentLog() *wsSentLog { + return newWsSentLogWithCapacity(wsSentLogCapacity) +} + +// newWsSentLogWithCapacity is exposed for tests that want a smaller buffer. +func newWsSentLogWithCapacity(capacity int) *wsSentLog { + return &wsSentLog{entries: make([]wsSentEntry, capacity)} +} + +// Append records an outbound envelope. Discards the oldest entry when full. +// sessionSeq and sessionID are zero/"" for envelopes that aren't routed to a +// specific session (handshake, connection-wide notifications, etc.). +func (l *wsSentLog) Append(seq, sessionSeq int64, sessionID, msgType, action string, sentAt time.Time) { + l.mu.Lock() + defer l.mu.Unlock() + l.entries[l.head] = wsSentEntry{ + Seq: seq, + SessionSeq: sessionSeq, + SessionID: sessionID, + Type: msgType, + Action: action, + SentAt: sentAt, + } + l.head = (l.head + 1) % len(l.entries) + if l.size < len(l.entries) { + l.size++ + } + if seq > l.maxSeq { + l.maxSeq = seq + } +} + +// Since returns all entries with seq > sinceSeq, in ascending seq order. Pass +// sinceSeq=0 to get everything currently in the buffer. +func (l *wsSentLog) Since(sinceSeq int64) []wsSentEntry { + l.mu.RLock() + defer l.mu.RUnlock() + if l.size == 0 { + return nil + } + out := make([]wsSentEntry, 0, l.size) + start := 0 + if l.size == len(l.entries) { + start = l.head // oldest entry sits at head when full + } + for i := range l.size { + e := l.entries[(start+i)%len(l.entries)] + if e.Seq > sinceSeq { + out = append(out, e) + } + } + // Defensive sort: capture order is monotonic, but if a future caller + // stamps out-of-order this keeps the response stable for tests. + sort.Slice(out, func(i, j int) bool { return out[i].Seq < out[j].Seq }) + return out +} + +// Max returns the highest seq ever appended. +func (l *wsSentLog) Max() int64 { + l.mu.RLock() + defer l.mu.RUnlock() + return l.maxSeq +} + +// SinceForSession returns entries for the given sessionID with +// SessionSeq > sinceSessionSeq, sorted by SessionSeq ascending. Entries with +// an empty SessionID (connection-wide notifications, task/run-routed +// broadcasts) are skipped so the result is exactly the per-session stream a +// subscriber should have observed. +func (l *wsSentLog) SinceForSession(sinceSessionSeq int64, sessionID string) []wsSentEntry { + l.mu.RLock() + defer l.mu.RUnlock() + if l.size == 0 || sessionID == "" { + return nil + } + out := make([]wsSentEntry, 0, l.size) + start := 0 + if l.size == len(l.entries) { + start = l.head + } + for i := range l.size { + e := l.entries[(start+i)%len(l.entries)] + if e.SessionID != sessionID { + continue + } + if e.SessionSeq > sinceSessionSeq { + out = append(out, e) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].SessionSeq < out[j].SessionSeq }) + return out +} diff --git a/apps/backend/pkg/websocket/message.go b/apps/backend/pkg/websocket/message.go index 6947f6294..061008204 100644 --- a/apps/backend/pkg/websocket/message.go +++ b/apps/backend/pkg/websocket/message.go @@ -16,14 +16,28 @@ const ( MessageTypeError MessageType = "error" ) -// Message is the base envelope for all WebSocket messages +// Message is the base envelope for all WebSocket messages. +// +// Seq and ConnectionID are populated by the gateway at write time (per-connection +// monotonic counter starting at 1, plus the connection ID). They are purely +// additive — older clients that don't know about them simply ignore the fields. +// E2E tests use them to detect dropped WS events: any seq gap is a regression. +// +// SessionSeq is a per-session monotonic counter stamped at write time for +// session-routed events (BroadcastToSession). It is absent (zero) on +// connection-wide notifications and on task/run-routed broadcasts whose +// routing key isn't a session. Phase 2 of the WS accounting work uses it to +// detect cross-session misrouting that per-connection seq cannot see. type Message struct { - ID string `json:"id,omitempty"` - Type MessageType `json:"type"` - Action string `json:"action"` - Payload json.RawMessage `json:"payload"` - Timestamp time.Time `json:"timestamp"` - Metadata map[string]string `json:"metadata,omitempty"` + ID string `json:"id,omitempty"` + Type MessageType `json:"type"` + Action string `json:"action"` + Payload json.RawMessage `json:"payload"` + Timestamp time.Time `json:"timestamp"` + Metadata map[string]string `json:"metadata,omitempty"` + Seq int64 `json:"seq,omitempty"` + SessionSeq int64 `json:"session_seq,omitempty"` + ConnectionID string `json:"connection_id,omitempty"` } // EnsureMetadata lazily initializes and returns the Metadata map. diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 8120436bf..eb3a73450 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -270,6 +270,12 @@ importers: '@tabler/icons-react': specifier: ^3.36.1 version: 3.36.1(react@19.2.3) + '@tanstack/react-query': + specifier: ^5.100.14 + version: 5.100.14(react@19.2.3) + '@tanstack/react-query-devtools': + specifier: ^5.100.14 + version: 5.100.14(@tanstack/react-query@5.100.14(react@19.2.3))(react@19.2.3) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2890,6 +2896,23 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tanstack/query-core@5.100.14': + resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} + + '@tanstack/query-devtools@5.100.14': + resolution: {integrity: sha512-g96SmSSQecYTYcyuAMRXr895GplJv01UGt7qttQWPOUyZ5EGz5tbRc589bMc2m5BsPFD6O0PCEAHdbDYNP6UBw==} + + '@tanstack/react-query-devtools@5.100.14': + resolution: {integrity: sha512-JkP5VDgKOw3t/QSA1OABRHEqx8BuNs5MfvZRooNqdvN57SzTuGq3fKR1a2IH5rqa5HDLUm+FOXUEnB9ueHiLzg==} + peerDependencies: + '@tanstack/react-query': ^5.100.14 + react: ^18 || ^19 + + '@tanstack/react-query@5.100.14': + resolution: {integrity: sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -9454,6 +9477,21 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 + '@tanstack/query-core@5.100.14': {} + + '@tanstack/query-devtools@5.100.14': {} + + '@tanstack/react-query-devtools@5.100.14(@tanstack/react-query@5.100.14(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/query-devtools': 5.100.14 + '@tanstack/react-query': 5.100.14(react@19.2.3) + react: 19.2.3 + + '@tanstack/react-query@5.100.14(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.100.14 + react: 19.2.3 + '@tanstack/react-table@8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/table-core': 8.21.3 diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 6f0b0917c..82abb7091 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -22,14 +22,44 @@ import { Dialog } from "@kandev/ui/dialog"; ## Data Flow Pattern (Critical) +- SSR prefetch hydrates the TanStack Query cache (via `HydrationBoundary` / dehydrated state) for server-owned data. +- WS events flow into the cache via per-domain bridges (`lib/query/bridge/*.ts`) — registered from `QueryBridge` in `lib/query/provider.tsx`. +- Components read via `useQuery(queryOptions.foo(...))` or via domain hooks in `hooks/domains//`. Never call fetch APIs directly in components. +- Zustand still owns client-only state (active IDs, UI toggles, layout) and serves as the transitional mirror for not-yet-migrated server state. +- **Migrating a domain is not done when the bridge writes to TQ — the UI consumer must also _read_ from `useQuery`, not the Zustand mirror.** "Bridge wrote, UI doesn't read" is a silent-desync bug class: WS events update the TQ cache but the component still selects from Zustand, so the UI only refreshes on reload (the "agent messages need a refresh" bug). When migrating, flip the consumer and the writer together. +- **Panels rendering agentctl-poll-driven data (e.g. `gitStatus`, pushed by a 3s-fast/30s-slow workspace poll) must resync on dockview tab activation.** There's a cold-start race where the `session.focus`→fast-poll push is lost, leaving content stale up to 30s. See `useResyncOnTabActivate` (editor) and `useResyncGitStatusOnTabActivate` (diff panel, via `client.refreshSessionData()`) in `components/task/dockview-shared.tsx` — copy that pattern for any new poll-backed panel. + +## Query Layer + +TanStack Query is the canonical server-state layer. Migrated domains: features, comments, integrations, automations, workspace, settings, jira, linear, github, gitlab, kanban, office, session, session-runtime. (`features` and `automations` Zustand slices were fully removed; the rest are transitional mirrors.) + ```text -SSR Fetch -> Hydrate Store -> Components Read Store -> Hooks Subscribe +lib/query/ +├── keys.ts # qk.* typed key factories (single source of truth for cache keys) +├── query-options/ # per-domain queryOptions() — used in useQuery + SSR prefetch +├── bridge/ # WS→TQ-cache handlers (transitional, registered from QueryBridge) +├── streams/ # ring buffers for high-frequency streams (shell/process/terminal) +├── client.ts +└── provider.tsx # QueryProvider + QueryBridge ``` -**Never fetch data directly in components.** +**Bridge contract:** each `bridge/.ts` mirrors `lib/ws/handlers/.ts` but writes into the TQ cache via `queryClient.setQueryData` instead of Zustand. Bridges are deleted as consumers finish reading from queries instead of the Zustand mirror. + +**Streams:** high-frequency output (shell, process, terminal) bypasses TQ and uses the ring-buffer registry in `lib/query/streams/ring.ts` — TQ's per-chunk notify is a perf cliff at thousands of chunks/sec. + +## WS event accounting + +E2E enforces that every backend→FE WS event is observably received, parsed, and applied. Enabled in CI on the e2e shards via `KANDEV_E2E_WS_ASSERT=1`; the hooks are baked into the bundle only when `NEXT_PUBLIC_KANDEV_E2E_MOCK=true` (set by the e2e build). It fails a test when: + +- the FE never processed an event the backend sent (a per-connection or per-session `seq` gap — receipt layer, `lib/ws/ws-account.ts`), or +- a session-routed event ran a bridge handler that did NOT mutate the TQ cache (apply layer, the bridge audit in `lib/query/bridge/index.ts`). + +**Adding a new WS message type:** register it in BOTH `lib/ws/handlers/.ts` (Zustand mirror) AND `lib/query/bridge/.ts` wrapped via `wrapBridgeHandler(qc, action, fn)` — OR add the action to `BRIDGE_SKIPPED_ACTIONS` / `BRIDGE_SKIPPED_PREFIXES` (keep the copy in `lib/query/bridge-audit-diff.ts` in sync) with an inline reason. Legitimate skip categories: control-plane / subscription acks, request/response acks (the receipt layer skips `type !== "notification"`), ring-buffer streams (handled outside TQ), and documented Zustand-only events. Invalidation-only handlers (`invalidateQueries`/`removeQueries`) count as "applied" — `wrapBridgeHandler` spies the cache-write methods, so it works even when the invalidated key isn't cached yet. ## Store Structure (Domain Slices) +These slices hold **client-only state** (active IDs, UI toggles, layout) and serve as transitional mirrors for server state not yet fully migrated to TanStack Query. + ```text lib/state/ ├── store.ts # Root composition @@ -40,6 +70,7 @@ lib/state/ │ ├── session-runtime/ # shell, processes, git, context │ ├── workspace/ # workspaces, repos, branches │ ├── settings/ # executors, agents, editors, prompts (incl. userSettings) +│ ├── office/ # office agents, skills, projects, dashboard, issues │ ├── comments/ # code review diff comments │ ├── github/ # GitHub PRs, reviews │ └── ui/ # preview, connection, active state, sidebar views @@ -49,6 +80,7 @@ hooks/domains/{kanban,session,workspace,settings,comments,github}/ # Domain-org lib/api/domains/ # API clients ├── kanban-api, session-api, workspace-api, settings-api, process-api ├── plan-api, queue-api, workflow-api, stats-api, github-api +├── office-api, office-extended-api, tree-api ├── user-shell-api, debug-api, secrets-api, sprites-api, vscode-api ├── health-api, utility-api ``` @@ -61,7 +93,7 @@ lib/api/domains/ # API clients **Hydration:** `lib/state/hydration/merge-strategies.ts` has `deepMerge()`, `mergeSessionMap()`, `mergeLoadingState()` to avoid overwriting live client state. Pass `activeSessionId` to protect active sessions. -**Hooks Pattern:** Hooks in `hooks/domains/` encapsulate WS subscription + store selection. WS client deduplicates subscriptions automatically. +**Hooks Pattern:** Hooks in `hooks/domains/` wrap `useQuery(queryOptions.*)` (or the domain's `query-options` factory). The WS client still deduplicates subscriptions; the cache those subscriptions write into is now TanStack Query (via bridges), not Zustand. ## WebSockets diff --git a/apps/web/app/actions/features.ts b/apps/web/app/actions/features.ts index f8578ec00..c253c2b0a 100644 --- a/apps/web/app/actions/features.ts +++ b/apps/web/app/actions/features.ts @@ -1,8 +1,7 @@ "use server"; import { getBackendConfig } from "@/lib/config"; -import { defaultFeaturesState } from "@/lib/state/slices/features/features-slice"; -import type { FeatureFlags } from "@/lib/state/slices/features/types"; +import { defaultFeatureFlags, type FeatureFlags } from "@/lib/features"; const { apiBaseUrl } = getBackendConfig(); @@ -11,7 +10,7 @@ const { apiBaseUrl } = getBackendConfig(); // store. Falls back to defaults (all-off) when the backend is unreachable, // so a dev-server restart doesn't crash page rendering. // -// Parsing is type-driven via `defaultFeaturesState.features`: every key +// Parsing is type-driven via `defaultFeatureFlags`: every key // declared on FeatureFlags is read from the response and Boolean-coerced; // missing or non-bool values fall through to the default. Adding a flag // is therefore one entry in FeatureFlags + the default — no edit here. @@ -19,7 +18,7 @@ const { apiBaseUrl } = getBackendConfig(); // See docs/decisions/0007-runtime-feature-flags.md. export async function getFeatureFlagsAction(): Promise { const url = `${apiBaseUrl}/api/v1/features`; - const defaults = defaultFeaturesState.features; + const defaults = defaultFeatureFlags; try { const response = await fetch(url, { cache: "no-store" }); if (!response.ok) { diff --git a/apps/web/app/github/page.tsx b/apps/web/app/github/page.tsx index c0630c1e6..da02d4430 100644 --- a/apps/web/app/github/page.tsx +++ b/apps/web/app/github/page.tsx @@ -15,7 +15,7 @@ import type { Workspace, UserSettingsResponse, } from "@/lib/types/http"; -import type { AppState } from "@/lib/state/store"; +import type { SsrInitialState } from "@/lib/ssr/initial-state"; export default async function GitHubPage() { let workspaces: Workspace[] = []; @@ -52,7 +52,7 @@ export default async function GitHubPage() { const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); - const initialState: Partial = { + const initialState: SsrInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null }, workflows: { items: workflows.map((w) => ({ diff --git a/apps/web/app/gitlab/page.tsx b/apps/web/app/gitlab/page.tsx index 5bbe39086..cc25238aa 100644 --- a/apps/web/app/gitlab/page.tsx +++ b/apps/web/app/gitlab/page.tsx @@ -4,7 +4,7 @@ import { StateHydrator } from "@/components/state-hydrator"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { GitLabPageClient } from "./gitlab-page-client"; import type { Workspace, UserSettingsResponse } from "@/lib/types/http"; -import type { AppState } from "@/lib/state/store"; +import type { SsrInitialState } from "@/lib/ssr/initial-state"; // Minimal SSR entrypoint for /gitlab. v1 surface: list the current user's // open MRs + issues and let them click through to GitLab. The browse-and- @@ -29,7 +29,7 @@ export default async function GitLabPage() { const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); - const initialState: Partial = { + const initialState: SsrInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null }, userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, }; diff --git a/apps/web/app/jira/page.tsx b/apps/web/app/jira/page.tsx index daf99a1af..d143ee8fd 100644 --- a/apps/web/app/jira/page.tsx +++ b/apps/web/app/jira/page.tsx @@ -8,7 +8,7 @@ import { StateHydrator } from "@/components/state-hydrator"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { JiraPageClient } from "./jira-page-client"; import type { Workflow, WorkflowStep, Workspace, UserSettingsResponse } from "@/lib/types/http"; -import type { AppState } from "@/lib/state/store"; +import type { SsrInitialState } from "@/lib/ssr/initial-state"; export default async function JiraPage() { let workspaces: Workspace[] = []; @@ -40,7 +40,7 @@ export default async function JiraPage() { const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); - const initialState: Partial = { + const initialState: SsrInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null }, workflows: { items: workflows.map((w) => ({ diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 31a6159ce..7b2e82abf 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import { ThemeProvider } from "@/components/theme-provider"; +import { QueryProvider, QueryBridge } from "@/lib/query/provider"; import { StateProvider } from "@/components/state-provider"; import { WebSocketConnector } from "@/components/ws-connector"; import { ToastProvider } from "@/components/toast-provider"; @@ -16,7 +17,9 @@ import { ConfigChatProvider } from "@/components/config-chat/config-chat-provide import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge"; import { SidebarViewsSyncBridge } from "@/components/sidebar-views-sync-bridge"; import { LogBufferBridge } from "@/components/log-buffer-bridge"; -import { getFeatureFlagsAction } from "@/app/actions/features"; +import { dehydrate } from "@tanstack/react-query"; +import { makeQueryClient } from "@/lib/query/client"; +import { featuresQueryOptions } from "@/lib/query/query-options/features"; export const metadata: Metadata = { title: "Kandev - AI Kanban", @@ -42,11 +45,14 @@ export default async function RootLayout({ const apiPort = process.env.NEXT_PUBLIC_KANDEV_API_PORT ?? null; const debugMode = process.env.NEXT_PUBLIC_KANDEV_DEBUG === "true"; - // SSR-fetch the deployment's feature flags so the entire client tree - // (including the sidebar nav and gated routes) renders with the correct - // visibility on the first paint. Falls back to all-off when the backend - // is unreachable. See docs/decisions/0007-runtime-feature-flags.md. - const features = await getFeatureFlagsAction(); + // SSR-prefetch the deployment's feature flags into a per-request + // QueryClient so the entire client tree (sidebar nav, gated routes) + // renders with the correct visibility on the first paint. Falls back + // to all-off when the backend is unreachable. See + // docs/decisions/0007-runtime-feature-flags.md. + const ssrQueryClient = makeQueryClient(); + await ssrQueryClient.prefetchQuery(featuresQueryOptions()); + const dehydratedState = dehydrate(ssrQueryClient); return ( @@ -66,29 +72,32 @@ export default async function RootLayout({ }} /> ) : null} - - - - - - - - - - - - - - - - {children} - - - - - - - + + + + + + + + + + + + + + + + + + {children} + + + + + + + + ); diff --git a/apps/web/app/linear/page.tsx b/apps/web/app/linear/page.tsx index 0e0327c87..c99e105f6 100644 --- a/apps/web/app/linear/page.tsx +++ b/apps/web/app/linear/page.tsx @@ -8,7 +8,7 @@ import { StateHydrator } from "@/components/state-hydrator"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { LinearPageClient } from "./linear-page-client"; import type { Workflow, WorkflowStep, Workspace, UserSettingsResponse } from "@/lib/types/http"; -import type { AppState } from "@/lib/state/store"; +import type { SsrInitialState } from "@/lib/ssr/initial-state"; export default async function LinearPage() { let workspaces: Workspace[] = []; @@ -40,7 +40,7 @@ export default async function LinearPage() { const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); - const initialState: Partial = { + const initialState: SsrInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null }, workflows: { items: workflows.map((w) => ({ diff --git a/apps/web/app/office/agents/[id]/channels/page.tsx b/apps/web/app/office/agents/[id]/channels/page.tsx index bb4328221..4d7c496d0 100644 --- a/apps/web/app/office/agents/[id]/channels/page.tsx +++ b/apps/web/app/office/agents/[id]/channels/page.tsx @@ -1,14 +1,21 @@ "use client"; import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { AgentChannelsTab } from "../components/agent-channels-tab"; type Props = { params: Promise<{ id: string }> }; export default function AgentChannelsPage({ params }: Props) { const { id } = use(params); - const agent = useAppStore((s) => s.office.agentProfiles.find((a) => a.id === id)); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agent } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + select: (agents) => agents.find((a) => a.id === id), + }); if (!agent) return null; return ; } diff --git a/apps/web/app/office/agents/[id]/components/agent-config-cli-card.tsx b/apps/web/app/office/agents/[id]/components/agent-config-cli-card.tsx index 21d57d199..8f53f154e 100644 --- a/apps/web/app/office/agents/[id]/components/agent-config-cli-card.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-config-cli-card.tsx @@ -1,17 +1,18 @@ "use client"; import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { useAppStore } from "@/components/state-provider"; +import { settingsQueryOptions } from "@/lib/query/query-options/settings"; import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles"; +import { useUpsertAgentProfileOption } from "@/hooks/domains/settings/use-settings-reads"; import { CliProfileEditor } from "@/components/agent/cli-profile-editor"; import type { AgentProfile } from "@/lib/types/agent-profile"; -import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import type { AgentProfileOption } from "@/lib/types/settings"; type Props = { agentProfileId: string; @@ -26,9 +27,8 @@ type Props = { */ export function AgentConfigCliCard({ agentProfileId, currentAgent, onAgentProfileChange }: Props) { const healthy = useHealthyAgentProfiles(agentProfileId); - const settingsAgents = useAppStore((s) => s.settingsAgents.items); - const setAgentProfiles = useAppStore((s) => s.setAgentProfiles); - const agentProfilesState = useAppStore((s) => s.agentProfiles.items); + const { data: settingsAgents = [] } = useQuery({ ...settingsQueryOptions.agents() }); + const upsertAgentProfile = useUpsertAgentProfileOption(); const linkedProfile = useMemo( () => findProfile(settingsAgents, agentProfileId) ?? currentAgent, @@ -92,7 +92,7 @@ export function AgentConfigCliCard({ agentProfileId, currentAgent, onAgentProfil profile={linkedProfile} onClose={() => setEditorMode("closed")} onSaved={(saved) => { - optimisticUpsert(setAgentProfiles, agentProfilesState, settingsAgents, saved); + upsertAgentProfile(saved); onAgentProfileChange(saved.id); setEditorMode("closed"); }} @@ -103,7 +103,7 @@ export function AgentConfigCliCard({ agentProfileId, currentAgent, onAgentProfil mode="create" onClose={() => setEditorMode("closed")} onSaved={(saved) => { - optimisticUpsert(setAgentProfiles, agentProfilesState, settingsAgents, saved); + upsertAgentProfile(saved); onAgentProfileChange(saved.id); setEditorMode("closed"); }} @@ -136,20 +136,6 @@ function findProfile( return undefined; } -function optimisticUpsert( - setAgentProfiles: (next: AgentProfileOption[]) => void, - current: AgentProfileOption[], - agents: { id: string; name: string }[], - saved: AgentProfile, -) { - const stub = agents.find((a) => a.id === saved.agentId) ?? { - id: saved.agentId ?? "", - name: saved.agentId ?? "", - }; - const option = toAgentProfileOption(stub, saved); - setAgentProfiles([...current.filter((p) => p.id !== option.id), option]); -} - function ProfileSummary({ option, onEdit, diff --git a/apps/web/app/office/agents/[id]/components/agent-configuration-tab.test.tsx b/apps/web/app/office/agents/[id]/components/agent-configuration-tab.test.tsx index 1bb329fd5..df625139f 100644 --- a/apps/web/app/office/agents/[id]/components/agent-configuration-tab.test.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-configuration-tab.test.tsx @@ -1,10 +1,32 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { StateProvider } from "@/components/state-provider"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; -import { defaultOfficeState } from "@/lib/state/slices/office/office-slice"; +import type { AgentProfile as OfficeAgent } from "@/lib/state/slices/office/types"; import { AgentConfigurationTab } from "./agent-configuration-tab"; +import { createTestQueryClient } from "@/test-utils/render-with-query"; +import { qk } from "@/lib/query/keys"; + +const WS_ID = "ws-1"; + +function renderWithProfiles( + ui: React.ReactElement, + profileOptions: unknown[], + officeAgents: OfficeAgent[] = [], +) { + const client = createTestQueryClient(); + client.setQueryData(qk.settings.agentProfiles(), profileOptions); + // AgentConfigurationTab reads workspace agents from TanStack Query + // (officeQueryOptions.agents), not the (removed) Zustand office mirror. + client.setQueryData(qk.office.agents(WS_ID), officeAgents); + return render( + + {ui} + , + ); +} // Mock toast so the act-like hooks don't error and we don't need the toast // provider tree for these isolated tests. @@ -52,20 +74,9 @@ const PROFILE_OPTION = { describe("AgentConfigurationTab", () => { it("renders the CLI configuration card with the linked profile summary", () => { - render( - - - , - ); + renderWithProfiles(, [PROFILE_OPTION], [baseAgent]); expect(screen.getByText("CLI Configuration")).toBeTruthy(); - // Linked profile is surfaced with the CLI client badge. expect(screen.getByText(CLAUDE_AGENT_ID)).toBeTruthy(); }); @@ -76,34 +87,14 @@ describe("AgentConfigurationTab", () => { agentId: CLAUDE_AGENT_ID, agentDisplayName: "Claude", }; - render( - - - , - ); + renderWithProfiles(, [PROFILE_OPTION], [orphan]); expect(screen.queryByText(/no cli profile selected/i)).toBeNull(); expect(screen.getByText("Claude")).toBeTruthy(); }); it("shows create-agent capability for CEO agents", () => { - render( - - - , - ); + renderWithProfiles(, [PROFILE_OPTION], [baseAgent]); expect(screen.getByTestId("agent-capability-preview").textContent).toContain("Create agent"); }); @@ -115,17 +106,7 @@ describe("AgentConfigurationTab", () => { name: "Worker", role: "worker" as const, }; - render( - - - , - ); + renderWithProfiles(, [PROFILE_OPTION], [worker]); expect(screen.getByTestId("agent-capability-preview").textContent).not.toContain( "Create agent", diff --git a/apps/web/app/office/agents/[id]/components/agent-configuration-tab.tsx b/apps/web/app/office/agents/[id]/components/agent-configuration-tab.tsx index 895ed5867..cab439877 100644 --- a/apps/web/app/office/agents/[id]/components/agent-configuration-tab.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-configuration-tab.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; @@ -10,6 +11,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; import { updateAgentProfile } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import { qk } from "@/lib/query/keys"; import type { AgentProfile, AgentRole } from "@/lib/state/slices/office/types"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; import { AgentConfigCliCard } from "./agent-config-cli-card"; @@ -70,9 +73,13 @@ function initialForm(agent: AgentProfile): FormState { } export function AgentConfigurationTab({ agent }: AgentConfigurationTabProps) { - const meta = useAppStore((s) => s.office.meta); - const updateStore = useAppStore((s) => s.updateOfficeAgentProfile); - const allOfficeAgents = useAppStore((s) => s.office.agentProfiles); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const qc = useQueryClient(); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); + const { data: allOfficeAgents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); const roles = meta?.roles.map((r) => ({ id: r.id, label: r.label })) ?? FALLBACK_ROLES; const executorTypes = @@ -104,7 +111,7 @@ export function AgentConfigurationTab({ agent }: AgentConfigurationTabProps) { executorPreference: form.executorType ? { type: form.executorType } : undefined, }; await updateAgentProfile(agent.id, update); - updateStore(agent.id, update); + if (workspaceId) void qc.invalidateQueries({ queryKey: qk.office.agents(workspaceId) }); setDirty(false); toast.success("Agent configuration updated"); } catch (err) { @@ -112,7 +119,7 @@ export function AgentConfigurationTab({ agent }: AgentConfigurationTabProps) { } finally { setSaving(false); } - }, [agent.id, form, updateStore]); + }, [agent.id, form, workspaceId, qc]); return (
diff --git a/apps/web/app/office/agents/[id]/components/agent-overview-tab.tsx b/apps/web/app/office/agents/[id]/components/agent-overview-tab.tsx index c28e75538..a66459e44 100644 --- a/apps/web/app/office/agents/[id]/components/agent-overview-tab.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-overview-tab.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; @@ -10,6 +11,8 @@ import { IconRefresh } from "@tabler/icons-react"; import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; import { updateAgentProfile, getAgentUtilization } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import { qk } from "@/lib/query/keys"; import type { AgentProfile, AgentRole, ProviderUsage } from "@/lib/state/slices/office/types"; import { UtilizationBars } from "@/app/office/components/utilization-bars"; @@ -217,9 +220,13 @@ const FALLBACK_EXECUTOR_TYPES = [ ]; export function AgentOverviewTab({ agent }: AgentOverviewTabProps) { - const agents = useAppStore((s) => s.office.agentProfiles); - const meta = useAppStore((s) => s.office.meta); - const updateStore = useAppStore((s) => s.updateOfficeAgentProfile); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const qc = useQueryClient(); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const roles = meta?.roles.map((r) => ({ id: r.id, label: r.label })) ?? FALLBACK_ROLES; const executorTypes = @@ -245,13 +252,7 @@ export function AgentOverviewTab({ agent }: AgentOverviewTabProps) { maxConcurrentSessions: maxConcurrent, executorPreference: executorType ? { type: executorType } : undefined, } as Partial); - updateStore(agent.id, { - name, - role, - budgetMonthlyCents: Math.round(budget * 100), - maxConcurrentSessions: maxConcurrent, - executorPreference: executorType ? { type: executorType } : undefined, - }); + if (workspaceId) void qc.invalidateQueries({ queryKey: qk.office.agents(workspaceId) }); setDirty(false); toast.success("Agent updated"); } catch (err) { @@ -259,7 +260,7 @@ export function AgentOverviewTab({ agent }: AgentOverviewTabProps) { } finally { setSaving(false); } - }, [agent.id, name, role, budget, maxConcurrent, executorType, updateStore]); + }, [agent.id, name, role, budget, maxConcurrent, executorType, workspaceId, qc]); const reportsToAgent = agents.find((a) => a.id === agent.reportsTo); diff --git a/apps/web/app/office/agents/[id]/components/agent-permissions-tab.tsx b/apps/web/app/office/agents/[id]/components/agent-permissions-tab.tsx index 103bb5497..b62a94aa7 100644 --- a/apps/web/app/office/agents/[id]/components/agent-permissions-tab.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-permissions-tab.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Switch } from "@kandev/ui/switch"; import { Label } from "@kandev/ui/label"; @@ -10,6 +11,8 @@ import { Button } from "@kandev/ui/button"; import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; import { updateAgentProfile } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import { qk } from "@/lib/query/keys"; import type { AgentProfile } from "@/lib/state/slices/office/types"; type AgentPermissionsTabProps = { @@ -17,8 +20,9 @@ type AgentPermissionsTabProps = { }; export function AgentPermissionsTab({ agent }: AgentPermissionsTabProps) { - const meta = useAppStore((s) => s.office.meta); - const updateStore = useAppStore((s) => s.updateOfficeAgentProfile); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const qc = useQueryClient(); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const permDefs = meta?.permissions ?? []; const roleDefaults = meta?.permissionDefaults?.[agent.role] ?? {}; @@ -40,7 +44,7 @@ export function AgentPermissionsTab({ agent }: AgentPermissionsTabProps) { await updateAgentProfile(agent.id, { permissions: perms, } as Partial); - updateStore(agent.id, { permissions: perms }); + if (workspaceId) void qc.invalidateQueries({ queryKey: qk.office.agents(workspaceId) }); setDirty(false); toast.success("Permissions updated"); } catch (err) { @@ -48,7 +52,7 @@ export function AgentPermissionsTab({ agent }: AgentPermissionsTabProps) { } finally { setSaving(false); } - }, [agent.id, perms, updateStore]); + }, [agent.id, perms, workspaceId, qc]); const isDefault = (key: string) => { const current = perms[key]; diff --git a/apps/web/app/office/agents/[id]/components/agent-runs-tab.test.tsx b/apps/web/app/office/agents/[id]/components/agent-runs-tab.test.tsx index 2938107c2..69f98743e 100644 --- a/apps/web/app/office/agents/[id]/components/agent-runs-tab.test.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-runs-tab.test.tsx @@ -1,16 +1,30 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { StateProvider } from "@/components/state-provider"; +import { createTestQueryClient } from "@/test-utils/render-with-query"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import { AgentRunsTab } from "./agent-runs-tab"; -// Hoisted mock so the listRuns import is replaced before the component -// imports it. Tests configure the mock per-case. +function renderTab(agent: AgentProfile) { + const client = createTestQueryClient(); + return render( + + + + + , + ); +} + +// Hoisted mock so the listRuns import is replaced before the query options +// module imports it. Tests configure the mock per-case. The runs query +// (officeQueryOptions.runs) calls listRuns from office-runs-api. const listRunsMock = vi.hoisted(() => vi.fn()); -vi.mock("@/lib/api/domains/office-api", async () => { - const actual = await vi.importActual( - "@/lib/api/domains/office-api", +vi.mock("@/lib/api/domains/office-runs-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/office-runs-api", ); return { ...actual, @@ -64,11 +78,7 @@ describe("AgentRunsTab", () => { ], }); - render( - - - , - ); + renderTab(ceo); // The CEO's run should appear; the other agent's run should not. await waitFor(() => { @@ -82,11 +92,7 @@ describe("AgentRunsTab", () => { it("renders the empty state when no runs match the agent", async () => { listRunsMock.mockResolvedValueOnce({ runs: [] }); - render( - - - , - ); + renderTab(ceo); await waitFor(() => { expect(screen.getByText(/no runs yet/i)).toBeTruthy(); diff --git a/apps/web/app/office/agents/[id]/components/agent-runs-tab.tsx b/apps/web/app/office/agents/[id]/components/agent-runs-tab.tsx index b01b887c9..c41e511a2 100644 --- a/apps/web/app/office/agents/[id]/components/agent-runs-tab.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-runs-tab.tsx @@ -1,11 +1,11 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconClock, IconRun } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import { listRuns } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { AgentProfile, Run } from "@/lib/state/slices/office/types"; import { timeAgo } from "@/lib/utils/time"; @@ -56,31 +56,19 @@ function CancelReasonBadge({ reason }: { reason: string }) { export function AgentRunsTab({ agent }: AgentRunsTabProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const [runs, setRuns] = useState([]); - const [loading, setLoading] = useState(true); - const fetchRuns = useCallback(async () => { - if (!workspaceId) return; - try { - const res = await listRuns(workspaceId); - const agentRuns = (res.runs ?? []).filter((w) => w.agent_profile_id === agent.id); - setRuns(agentRuns); - } catch { - // Silently handle - empty state will show - } finally { - setLoading(false); - } - }, [workspaceId, agent.id]); - - useEffect(() => { - void fetchRuns(); - }, [fetchRuns]); - - // Refresh runs reactively when runs change. The office WS handler - // triggers "runs" on office.run.queued and the agent-session - // path triggers "agents" on session.state_changed. - useOfficeRefetch("runs", fetchRuns); - useOfficeRefetch("agents", fetchRuns); + // Runs come from TanStack Query; the office WS bridge invalidates + // `qk.office.runs(wsId)` on office.run.queued / office.run.processed so + // this list refreshes reactively without a manual refetch trigger. + const { data: allRuns = [], isPending } = useQuery({ + ...officeQueryOptions.runs(workspaceId ?? ""), + enabled: !!workspaceId, + }); + const runs = useMemo( + () => allRuns.filter((w) => w.agent_profile_id === agent.id), + [allRuns, agent.id], + ); + const loading = isPending && !!workspaceId; if (loading) { return ( diff --git a/apps/web/app/office/agents/[id]/components/agent-skills-tab.tsx b/apps/web/app/office/agents/[id]/components/agent-skills-tab.tsx index 932ccb588..de6f06d80 100644 --- a/apps/web/app/office/agents/[id]/components/agent-skills-tab.tsx +++ b/apps/web/app/office/agents/[id]/components/agent-skills-tab.tsx @@ -1,53 +1,33 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useState, useCallback } from "react"; import Link from "next/link"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Checkbox } from "@kandev/ui/checkbox"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; -import { listSkills, updateAgentProfile } from "@/lib/api/domains/office-api"; +import { updateAgentProfile } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import { qk } from "@/lib/query/keys"; import type { AgentProfile } from "@/lib/state/slices/office/types"; type AgentSkillsTabProps = { agent: AgentProfile; }; -/** - * Hydrate the office skills store on mount. The workspace Skills page - * populates it as a side effect of viewing, but a user landing - * directly on /office/agents//skills wouldn't have run that path - * yet. Hitting listSkills also triggers the backend's lazy per- - * workspace system-skill sync, so a fresh workspace shows the - * bundled set on first visit. - */ -function useHydrateSkills() { - const setSkills = useAppStore((s) => s.setSkills); - const workspaceId = useAppStore((s) => s.workspaces.activeId); - useEffect(() => { - if (!workspaceId) return; - let cancelled = false; - listSkills(workspaceId) - .then((res) => { - if (!cancelled) setSkills(res.skills ?? []); - }) - .catch(() => { - // Non-fatal: existing store contents (possibly empty) render - // the "No skills registered" CTA, which still lets the user - // pivot to the Skills page. - }); - return () => { - cancelled = true; - }; - }, [workspaceId, setSkills]); -} - export function AgentSkillsTab({ agent }: AgentSkillsTabProps) { - useHydrateSkills(); - const skills = useAppStore((s) => s.office.skills); - const updateStore = useAppStore((s) => s.updateOfficeAgentProfile); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const qc = useQueryClient(); + // TQ query handles the hydration that useHydrateSkills previously did + // imperatively. It also triggers the backend's lazy system-skill sync + // on first view of /office/agents//skills. + const { data: skills = [] } = useQuery({ + ...officeQueryOptions.skills(workspaceId ?? ""), + enabled: !!workspaceId, + }); const [skillIds, setSkillIds] = useState(agent.skillIds ?? []); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); @@ -64,7 +44,7 @@ export function AgentSkillsTab({ agent }: AgentSkillsTabProps) { setSaving(true); try { await updateAgentProfile(agent.id, { skillIds }); - updateStore(agent.id, { skillIds }); + if (workspaceId) void qc.invalidateQueries({ queryKey: qk.office.agents(workspaceId) }); setDirty(false); toast.success("Skills updated"); } catch (err) { @@ -72,7 +52,7 @@ export function AgentSkillsTab({ agent }: AgentSkillsTabProps) { } finally { setSaving(false); } - }, [agent.id, skillIds, updateStore]); + }, [agent.id, skillIds, workspaceId, qc]); if (skills.length === 0) { return ( @@ -93,52 +73,7 @@ export function AgentSkillsTab({ agent }: AgentSkillsTabProps) { Skills this agent owns. Skills are injected into the agent's system prompt at session start.

-
- {skills.map((skill) => { - const isDefault = skill.isSystem && (skill.defaultForRoles ?? []).includes(agent.role); - return ( - - ); - })} -
+ {dirty && (
); } + +type Skill = { + id: string; + name: string; + slug: string; + isSystem?: boolean; + systemVersion?: string; + defaultForRoles?: string[]; +}; + +function SkillList({ + skills, + selected, + agentRole, + onToggle, +}: { + skills: Skill[]; + selected: Set; + agentRole: string; + onToggle: (id: string) => void; +}) { + return ( +
+ {skills.map((skill) => { + const isDefault = skill.isSystem && (skill.defaultForRoles ?? []).includes(agentRole); + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/app/office/agents/[id]/configuration/page.tsx b/apps/web/app/office/agents/[id]/configuration/page.tsx index 04974fcc3..18980d0fb 100644 --- a/apps/web/app/office/agents/[id]/configuration/page.tsx +++ b/apps/web/app/office/agents/[id]/configuration/page.tsx @@ -1,14 +1,21 @@ "use client"; import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { AgentConfigurationTab } from "../components/agent-configuration-tab"; type Props = { params: Promise<{ id: string }> }; export default function AgentConfigurationPage({ params }: Props) { const { id } = use(params); - const agent = useAppStore((s) => s.office.agentProfiles.find((a) => a.id === id)); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agent } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + select: (agents) => agents.find((a) => a.id === id), + }); if (!agent) return null; return ; } diff --git a/apps/web/app/office/agents/[id]/dashboard/dashboard-view.tsx b/apps/web/app/office/agents/[id]/dashboard/dashboard-view.tsx index 4115ea776..c6b739b08 100644 --- a/apps/web/app/office/agents/[id]/dashboard/dashboard-view.tsx +++ b/apps/web/app/office/agents/[id]/dashboard/dashboard-view.tsx @@ -1,8 +1,7 @@ "use client"; -import { useCallback, useState } from "react"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import { getAgentSummary, type AgentSummaryResponse } from "@/lib/api/domains/office-extended-api"; +import { useAgentSummary } from "@/hooks/domains/office/use-agent-summary"; +import type { AgentSummaryResponse } from "@/lib/api/domains/office-runs-api"; import { LatestRunCard } from "./components/latest-run-card"; import { RunActivityChart } from "./components/run-activity-chart"; import { TasksByPriorityChart } from "./components/tasks-by-priority-chart"; @@ -19,10 +18,11 @@ type Props = { }; /** - * Client-side shell for the agent dashboard. Holds the SSR snapshot - * in `useState` and refetches via WebSocket-driven triggers (the - * `agents` and `tasks` channels both impact the dashboard) — matching - * the project's reactive-only convention. + * Client-side shell for the agent dashboard. The summary is read from + * TanStack Query (seeded from the SSR snapshot via `initialData`) and + * stays reactive via `useAgentSummary`, which subscribes to the office + * WS events that affect the dashboard and invalidates its own key — + * matching the project's reactive-only convention. * * The chart components are pure SSR-safe presentational pieces; this * shell exists so a future "Refresh" / "Date range" UI has somewhere @@ -30,23 +30,7 @@ type Props = { * Server Component and therefore can't hold state). */ export function DashboardView({ agentId, initial, days }: Props) { - const [summary, setSummary] = useState(initial); - - const refresh = useCallback(async () => { - try { - const next = await getAgentSummary(agentId, days); - setSummary(next); - } catch { - // Silent; the snapshot stays useful even if the refetch fails. - // A stale-data badge could surface this in a follow-up. - } - }, [agentId, days]); - - // The dashboard derives from runs, activity_log, cost_events, and - // tasks — every WS event in those domains can change the values, so - // we subscribe to both `agents` and `tasks` triggers. - useOfficeRefetch("agents", refresh); - useOfficeRefetch("tasks", refresh); + const { summary } = useAgentSummary(agentId, initial, days); return (
diff --git a/apps/web/app/office/agents/[id]/instructions/page.tsx b/apps/web/app/office/agents/[id]/instructions/page.tsx index 3bd427015..87ba5e93f 100644 --- a/apps/web/app/office/agents/[id]/instructions/page.tsx +++ b/apps/web/app/office/agents/[id]/instructions/page.tsx @@ -1,14 +1,21 @@ "use client"; import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { AgentInstructionsTab } from "../components/agent-instructions-tab"; type Props = { params: Promise<{ id: string }> }; export default function AgentInstructionsPage({ params }: Props) { const { id } = use(params); - const agent = useAppStore((s) => s.office.agentProfiles.find((a) => a.id === id)); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agent } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + select: (agents) => agents.find((a) => a.id === id), + }); if (!agent) return null; return ; } diff --git a/apps/web/app/office/agents/[id]/layout.tsx b/apps/web/app/office/agents/[id]/layout.tsx index d95fa1fe0..ef14cd668 100644 --- a/apps/web/app/office/agents/[id]/layout.tsx +++ b/apps/web/app/office/agents/[id]/layout.tsx @@ -1,13 +1,13 @@ "use client"; -import { use, useCallback, useEffect, type ReactNode } from "react"; +import { use, type ReactNode } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; import { IconInfoCircle } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import { listAgentProfiles } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { cn } from "@/lib/utils"; import { OfficeTopbarPortal } from "../../components/office-topbar-portal"; import { AgentAvatar } from "../../components/agent-avatar"; @@ -42,28 +42,22 @@ const TABS: Array<{ slug: string; label: string }> = [ export default function AgentDetailLayout({ children, params }: AgentDetailLayoutProps) { const { id } = use(params); const pathname = usePathname(); - const agent = useAppStore((s) => s.office.agentProfiles.find((a) => a.id === id)); const workspaceId = useAppStore((s) => s.workspaces.activeId); - const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); - - // Refetch the agents list on mount and on WS "agents" events so this - // layout recovers when SSR hydrated the store with a stale agent set - // (e.g. the agent was created after the SSR fetch fired). - const refetchAgents = useCallback(async () => { - if (!workspaceId) return; - const res = await listAgentProfiles(workspaceId).catch(() => ({ agents: [] })); - setOfficeAgentProfiles(res.agents ?? []); - }, [workspaceId, setOfficeAgentProfiles]); - - // Fire once on mount to recover from stale SSR hydration. - useEffect(() => { - refetchAgents(); - }, [refetchAgents]); - - useOfficeRefetch("agents", refetchAgents); - + const { data: agents = [], isPending } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); + const agent = agents.find((a) => a.id === id); const activeSlug = activeSlugFromPath(pathname, id); + if (isPending) { + return ( +
+

Loading...

+
+ ); + } + if (!agent) { return (
@@ -145,7 +139,11 @@ function activeSlugFromPath(pathname: string | null, agentId: string): string { * don't get this hint since they only run on assignment, not schedule. */ function CoordinatorRoutineHint({ agentId, agentRole }: { agentId: string; agentRole: string }) { - const routines = useAppStore((s) => s.office.routines); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: routines = [] } = useQuery({ + ...officeQueryOptions.routines(workspaceId ?? ""), + enabled: !!workspaceId && agentRole === "ceo", + }); if (agentRole !== "ceo") return null; const hasActive = routines.some( (r) => r.assigneeAgentProfileId === agentId && r.status === "active", diff --git a/apps/web/app/office/agents/[id]/memory/page.tsx b/apps/web/app/office/agents/[id]/memory/page.tsx index 028385eaa..e9194d291 100644 --- a/apps/web/app/office/agents/[id]/memory/page.tsx +++ b/apps/web/app/office/agents/[id]/memory/page.tsx @@ -1,14 +1,21 @@ "use client"; import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { AgentMemoryTab } from "../components/agent-memory-tab"; type Props = { params: Promise<{ id: string }> }; export default function AgentMemoryPage({ params }: Props) { const { id } = use(params); - const agent = useAppStore((s) => s.office.agentProfiles.find((a) => a.id === id)); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agent } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + select: (agents) => agents.find((a) => a.id === id), + }); if (!agent) return null; return ; } diff --git a/apps/web/app/office/agents/[id]/permissions/page.tsx b/apps/web/app/office/agents/[id]/permissions/page.tsx index f238161e5..e0f580a46 100644 --- a/apps/web/app/office/agents/[id]/permissions/page.tsx +++ b/apps/web/app/office/agents/[id]/permissions/page.tsx @@ -1,14 +1,21 @@ "use client"; import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { AgentPermissionsTab } from "../components/agent-permissions-tab"; type Props = { params: Promise<{ id: string }> }; export default function AgentPermissionsPage({ params }: Props) { const { id } = use(params); - const agent = useAppStore((s) => s.office.agentProfiles.find((a) => a.id === id)); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agent } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + select: (agents) => agents.find((a) => a.id === id), + }); if (!agent) return null; return ; } diff --git a/apps/web/app/office/agents/[id]/runs/runs-list-view.tsx b/apps/web/app/office/agents/[id]/runs/runs-list-view.tsx index c027a8907..dedb3d153 100644 --- a/apps/web/app/office/agents/[id]/runs/runs-list-view.tsx +++ b/apps/web/app/office/agents/[id]/runs/runs-list-view.tsx @@ -12,7 +12,8 @@ import { } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; -import { useAppStore } from "@/components/state-provider"; +import { useTaskCandidates } from "@/hooks/domains/office/use-task-candidates"; +import { useOfficeRoutines } from "@/hooks/domains/office/use-office-routines"; import { listAgentRuns, type AgentRunsListPage, @@ -156,12 +157,10 @@ function RunRow({ run, agentId }: { run: AgentRunSummary; agentId: string }) { * origin (legacy rows, scheduled wakeups without a task). */ function LinkedEntity({ run }: { run: AgentRunSummary }) { - const task = useAppStore((s) => - run.task_id ? s.office.tasks.items.find((t) => t.id === run.task_id) : undefined, - ); - const routine = useAppStore((s) => - run.routine_id ? s.office.routines.find((r) => r.id === run.routine_id) : undefined, - ); + const candidates = useTaskCandidates(); + const routines = useOfficeRoutines(); + const task = run.task_id ? candidates.find((t) => t.id === run.task_id) : undefined; + const routine = run.routine_id ? routines.find((r) => r.id === run.routine_id) : undefined; if (run.routine_id) { const label = routine?.name ?? "Routine"; diff --git a/apps/web/app/office/agents/[id]/skills/page.tsx b/apps/web/app/office/agents/[id]/skills/page.tsx index 554202de8..0f3f6e5a7 100644 --- a/apps/web/app/office/agents/[id]/skills/page.tsx +++ b/apps/web/app/office/agents/[id]/skills/page.tsx @@ -1,14 +1,21 @@ "use client"; import { use } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { AgentSkillsTab } from "../components/agent-skills-tab"; type Props = { params: Promise<{ id: string }> }; export default function AgentSkillsPage({ params }: Props) { const { id } = use(params); - const agent = useAppStore((s) => s.office.agentProfiles.find((a) => a.id === id)); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agent } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + select: (agents) => agents.find((a) => a.id === id), + }); if (!agent) return null; return ; } diff --git a/apps/web/app/office/agents/agents-page-client.tsx b/apps/web/app/office/agents/agents-page-client.tsx index 5b84786fb..4783cbd0a 100644 --- a/apps/web/app/office/agents/agents-page-client.tsx +++ b/apps/web/app/office/agents/agents-page-client.tsx @@ -1,57 +1,30 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconPlus } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; import { useRoutingPreview } from "@/hooks/domains/office/use-routing-preview"; import { useWorkspaceRouting } from "@/hooks/domains/office/use-workspace-routing"; -import { listAgentProfiles } from "@/lib/api/domains/office-api"; -import type { AgentProfile } from "@/lib/state/slices/office/types"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { AgentCard } from "./components/agent-card"; import { CreateAgentDialog } from "./components/create-agent-dialog"; import { EmptyState } from "../components/shared/empty-state"; import { PageHeader } from "../components/shared/page-header"; -type AgentsPageClientProps = { - initialAgents: AgentProfile[]; -}; - -export function AgentsPageClient({ initialAgents }: AgentsPageClientProps) { - const agents = useAppStore((s) => s.office.agentProfiles); - const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); +export function AgentsPageClient() { const workspaceId = useAppStore((s) => s.workspaces.activeId); const [showCreate, setShowCreate] = useState(false); - // Mounting these hooks fetches workspace routing config + preview once; - // every agent card reads the resolved preview from the store. + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); + // Mount routing hooks to prefetch workspace routing config + preview so + // every agent card reads resolved preview from the TQ cache. useWorkspaceRouting(workspaceId); useRoutingPreview(workspaceId); - useEffect(() => { - if (initialAgents.length > 0) { - setOfficeAgentProfiles(initialAgents); - } - }, [initialAgents, setOfficeAgentProfiles]); - - const refetchAgents = useCallback(async () => { - if (!workspaceId) return; - const res = await listAgentProfiles(workspaceId).catch(() => ({ - agents: [] as AgentProfile[], - })); - setOfficeAgentProfiles(res.agents ?? []); - }, [workspaceId, setOfficeAgentProfiles]); - - // Fire once on mount to recover from stale SSR hydration. The SSR fetch - // may have raced ahead of a just-created agent's DB write; this re-hit - // ensures the store reflects the current DB state without waiting for a - // WS event. - useEffect(() => { - refetchAgents(); - }, [refetchAgents]); - - useOfficeRefetch("agents", refetchAgents); - return (
s.workspaces.activeId); - const routingEnabled = useAppStore( - (s) => s.office.routing.byWorkspace[workspaceId ?? ""]?.enabled ?? false, - ); - const preview = useAppStore((s) => - workspaceId - ? (s.office.routing.preview.byWorkspace[workspaceId] ?? []).find( - (p) => p.agent_id === agent.id, - ) - : undefined, - ); + const { config } = useWorkspaceRouting(workspaceId ?? null); + const { agents: routePreviews } = useRoutingPreview(workspaceId ?? null); + const routingEnabled = config?.enabled ?? false; + const preview = routePreviews.find((p) => p.agent_id === agent.id); return ( = { @@ -19,7 +20,7 @@ type AgentRoleBadgeProps = { }; export function AgentRoleBadge({ role }: AgentRoleBadgeProps) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const metaRole = meta?.roles.find((r) => r.id === role); const colorClass = metaRole?.color ?? FALLBACK_COLORS[role] ?? ""; const label = metaRole?.label ?? role; diff --git a/apps/web/app/office/agents/components/agent-status-dot.tsx b/apps/web/app/office/agents/components/agent-status-dot.tsx index 2894274e8..c72afa082 100644 --- a/apps/web/app/office/agents/components/agent-status-dot.tsx +++ b/apps/web/app/office/agents/components/agent-status-dot.tsx @@ -1,7 +1,8 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { cn } from "@/lib/utils"; -import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { AgentStatus } from "@/lib/state/slices/office/types"; const FALLBACK_STYLES: Record = { @@ -18,7 +19,7 @@ type AgentStatusDotProps = { }; export function AgentStatusDot({ status, className }: AgentStatusDotProps) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const metaStatus = meta?.agentStatuses.find((s) => s.id === status); const colorClass = metaStatus?.color ?? FALLBACK_STYLES[status] ?? ""; const label = metaStatus?.label ?? status; diff --git a/apps/web/app/office/agents/components/create-agent-dialog.tsx b/apps/web/app/office/agents/components/create-agent-dialog.tsx index b6a72feb8..2105992cc 100644 --- a/apps/web/app/office/agents/components/create-agent-dialog.tsx +++ b/apps/web/app/office/agents/components/create-agent-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; @@ -9,6 +10,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; import { createAgentProfile } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import { qk } from "@/lib/query/keys"; import type { AgentRole, AgentProfile } from "@/lib/state/slices/office/types"; type CreateAgentDialogProps = { @@ -208,9 +211,12 @@ const FALLBACK_EXECUTOR_TYPES = [ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const agents = useAppStore((s) => s.office.agentProfiles); - const meta = useAppStore((s) => s.office.meta); - const addOfficeAgentProfile = useAppStore((s) => s.addOfficeAgentProfile); + const qc = useQueryClient(); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const roles = meta?.roles.map((r) => ({ id: r.id, label: r.label })) ?? FALLBACK_ROLES; const executorTypes = @@ -236,8 +242,8 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps maxConcurrentSessions: state.maxConcurrent, executorPreference: state.executorPref ? { type: state.executorPref } : undefined, } as Partial); - if (result) { - addOfficeAgentProfile(result); + if (result && workspaceId) { + void qc.invalidateQueries({ queryKey: qk.office.agents(workspaceId) }); } setState(INITIAL_STATE); onOpenChange(false); @@ -249,7 +255,7 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps } finally { setSubmitting(false); } - }, [state, workspaceId, addOfficeAgentProfile, onOpenChange]); + }, [state, workspaceId, qc, onOpenChange]); return ( diff --git a/apps/web/app/office/agents/page.tsx b/apps/web/app/office/agents/page.tsx index 816f84aa8..e8532e75b 100644 --- a/apps/web/app/office/agents/page.tsx +++ b/apps/web/app/office/agents/page.tsx @@ -1,18 +1,5 @@ -import { listAgentProfiles } from "@/lib/api/domains/office-api"; -import { getActiveWorkspaceId } from "../lib/get-active-workspace"; import { AgentsPageClient } from "./agents-page-client"; -import type { AgentProfile } from "@/lib/state/slices/office/types"; -export default async function AgentsPage() { - const workspaceId = await getActiveWorkspaceId(); - - let agents: AgentProfile[] = []; - if (workspaceId) { - const res = await listAgentProfiles(workspaceId, { cache: "no-store" }).catch(() => ({ - agents: [], - })); - agents = res.agents ?? []; - } - - return ; +export default function AgentsPage() { + return ; } diff --git a/apps/web/app/office/components/new-task-bottom-bar.tsx b/apps/web/app/office/components/new-task-bottom-bar.tsx index ab93d2c1f..036dcf871 100644 --- a/apps/web/app/office/components/new-task-bottom-bar.tsx +++ b/apps/web/app/office/components/new-task-bottom-bar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { IconCircleDot, IconMinus, @@ -12,7 +13,7 @@ import { import { Button } from "@kandev/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { IssueDraft } from "./new-task-draft"; type StatusOption = { value: string; label: string; className: string }; @@ -44,7 +45,7 @@ type Props = { }; function useStatusOptions(): StatusOption[] { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); if (!meta) return FALLBACK_STATUS_OPTIONS; // Only show creation-relevant statuses (backlog, todo, in_progress) const creationStatuses = ["backlog", "todo", "in_progress"]; @@ -82,7 +83,7 @@ function StatusChip({ draft, onUpdate }: Props) { } function usePriorityOptions(): PriorityOption[] { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); if (!meta) return FALLBACK_PRIORITY_OPTIONS; // Exclude "none" from the creation picker return meta.priorities diff --git a/apps/web/app/office/components/new-task-selector-row.tsx b/apps/web/app/office/components/new-task-selector-row.tsx index 4ed7aadd0..25b5e8c9d 100644 --- a/apps/web/app/office/components/new-task-selector-row.tsx +++ b/apps/web/app/office/components/new-task-selector-row.tsx @@ -1,5 +1,6 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { IconDotsVertical, IconEye, IconCircleCheck } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; @@ -11,6 +12,7 @@ import { } from "@kandev/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { AgentProfile, Project } from "@/lib/state/slices/office/types"; import type { IssueDraft } from "./new-task-draft"; import { ParticipantRow } from "./new-task-participant-row"; @@ -107,8 +109,15 @@ function ProjectPickerPopover({ } export function NewTaskSelectorRow({ draft, onUpdate }: Props) { - const agents = useAppStore((s) => s.office.agentProfiles); - const projects = useAppStore((s) => s.office.projects); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); + const { data: projects = [] } = useQuery({ + ...officeQueryOptions.projects(workspaceId ?? ""), + enabled: !!workspaceId, + }); return (
diff --git a/apps/web/app/office/components/new-task-stages.tsx b/apps/web/app/office/components/new-task-stages.tsx index bc03df1ef..d83a4e2ed 100644 --- a/apps/web/app/office/components/new-task-stages.tsx +++ b/apps/web/app/office/components/new-task-stages.tsx @@ -1,9 +1,11 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { IconChevronDown, IconChevronRight } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { AgentProfile } from "@/lib/state/slices/office/types"; // Execution policy stage types @@ -144,7 +146,11 @@ type Props = { }; export function NewTaskStages({ stages, onUpdate }: Props) { - const agents = useAppStore((s) => s.office.agentProfiles); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); return (
diff --git a/apps/web/app/office/components/office-sidebar.tsx b/apps/web/app/office/components/office-sidebar.tsx index 53961b7d8..378626593 100644 --- a/apps/web/app/office/components/office-sidebar.tsx +++ b/apps/web/app/office/components/office-sidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconSquarePlus, IconLayoutDashboard, @@ -20,7 +21,10 @@ import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { ThemeToggle } from "@/components/theme-toggle"; import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { selectTotalLiveSessions } from "@/lib/state/slices/session/selectors"; +import { useAllTaskSessions } from "@/hooks/domains/session/use-task-session-by-id"; import { SidebarNavItem } from "./sidebar-nav-item"; import { SidebarSection } from "./sidebar-section"; import { SidebarAgentsList } from "./sidebar-agents-list"; @@ -31,23 +35,91 @@ interface OfficeSidebarProps { workspaceName?: string; } +type SidebarNavCounts = { + inboxCount: number; + taskCount: number; + skillCount: number; + routineCount: number; + totalLiveSessions: number; +}; + +function SidebarNav({ counts, onNewTask }: { counts: SidebarNavCounts; onNewTask: () => void }) { + const { inboxCount, taskCount, skillCount, routineCount, totalLiveSessions } = counts; + return ( + + ); +} + export function OfficeSidebar({ workspaceName: ssrName }: OfficeSidebarProps) { - const workspaces = useAppStore((s) => s.workspaces); - const inboxCount = useAppStore((s) => s.office.inboxCount); - const totalLiveSessions = useAppStore(selectTotalLiveSessions); - const dashboard = useAppStore((s) => s.office.dashboard); - const taskCount = dashboard?.task_count ?? 0; - const skillCount = dashboard?.skill_count ?? 0; - const routineCount = dashboard?.routine_count ?? 0; + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { workspaces } = useWorkspaces(); + const totalLiveSessions = selectTotalLiveSessions(useAllTaskSessions()); + const { data: inbox } = useQuery({ + ...officeQueryOptions.inbox(workspaceId ?? ""), + enabled: !!workspaceId, + }); + const { data: dashboard } = useQuery({ + ...officeQueryOptions.dashboard(workspaceId ?? ""), + enabled: !!workspaceId, + }); const [newTaskOpen, setNewTaskOpen] = useState(false); - - // Use store if hydrated, fall back to SSR prop - const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); + const activeWorkspace = workspaces.find((w) => w.id === workspaceId); const workspaceName = activeWorkspace?.name || ssrName || "Workspace"; + const counts: SidebarNavCounts = { + inboxCount: inbox?.total_count ?? 0, + taskCount: dashboard?.task_count ?? 0, + skillCount: dashboard?.skill_count ?? 0, + routineCount: dashboard?.routine_count ?? 0, + totalLiveSessions, + }; return ( ); diff --git a/apps/web/app/office/components/office-tq-seeder.tsx b/apps/web/app/office/components/office-tq-seeder.tsx new file mode 100644 index 000000000..ac8c86a91 --- /dev/null +++ b/apps/web/app/office/components/office-tq-seeder.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useLayoutEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import type { AgentProfile, Project, InboxItem, OfficeMeta } from "@/lib/state/slices/office/types"; + +export type OfficeSsrSnapshot = { + workspaceId: string | null; + agents: AgentProfile[]; + projects: Project[]; + inboxItems: InboxItem[]; + inboxCount: number; + meta: OfficeMeta | null; +}; + +type GetInboxResponse = { items: InboxItem[]; total_count: number }; + +/** + * Seeds the office TanStack Query caches from the SSR snapshot the office + * layout fetches (agents / projects / inbox / meta for the active + * workspace). The office sidebar + pickers read these via `useQuery`, so + * without a seed they'd mount with an empty cache and flash until the + * client refetch lands. + * + * Seed-if-absent: a live WS/refetch result is never clobbered. This is + * the office-scoped analogue of the turns seed in `state-hydrator.tsx` + * (the SSR→TQ bridge for Zustand-hydrated pages). + */ +export function OfficeTqSeeder({ snapshot }: { snapshot: OfficeSsrSnapshot }) { + const queryClient = useQueryClient(); + + useLayoutEffect(() => { + const { workspaceId } = snapshot; + if (!workspaceId) return; + + const seed = (key: readonly unknown[], value: T) => { + if (queryClient.getQueryData(key) === undefined) { + queryClient.setQueryData(key, value); + } + }; + + seed(qk.office.agents(workspaceId), snapshot.agents); + seed(["office", workspaceId, "projects"] as const, snapshot.projects); + seed(["office", workspaceId, "inbox"] as const, { + items: snapshot.inboxItems, + total_count: snapshot.inboxCount, + } satisfies GetInboxResponse); + if (snapshot.meta) { + seed(["office", workspaceId, "meta"] as const, snapshot.meta); + } + }, [snapshot, queryClient]); + + return null; +} diff --git a/apps/web/app/office/components/sidebar-agents-list.tsx b/apps/web/app/office/components/sidebar-agents-list.tsx index 30eabf2f4..afc8fb791 100644 --- a/apps/web/app/office/components/sidebar-agents-list.tsx +++ b/apps/web/app/office/components/sidebar-agents-list.tsx @@ -1,14 +1,16 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { useMemo } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import { listAgentProfiles } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import { useOfficeInboxItems } from "@/hooks/domains/office/use-office-inbox"; import { cn } from "@/lib/utils"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import { selectActiveSessionsForAgent } from "@/lib/state/slices/session/selectors"; +import { useAllTaskSessions } from "@/hooks/domains/session/use-task-session-by-id"; import { SidebarCollapsibleSection } from "./sidebar-collapsible-section"; import { AgentAvatar } from "./agent-avatar"; import { AgentStatusDot } from "../agents/components/agent-status-dot"; @@ -16,25 +18,11 @@ import { LiveAgentIndicator } from "../agents/components/live-agent-indicator"; export function SidebarAgentsList() { const router = useRouter(); - const agents = useAppStore((s) => s.office.agentProfiles); const workspaceId = useAppStore((s) => s.workspaces.activeId); - const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); - - // Refetch agents on mount and on WS "agents" events. This ensures the - // sidebar (and any page that reads agentProfiles from the store, such - // as the org chart and agent detail layout) recovers from stale SSR - // hydration without waiting for a user action or WS event to arrive. - const refetchAgents = useCallback(async () => { - if (!workspaceId) return; - const res = await listAgentProfiles(workspaceId).catch(() => ({ agents: [] })); - setOfficeAgentProfiles(res.agents ?? []); - }, [workspaceId, setOfficeAgentProfiles]); - - useEffect(() => { - refetchAgents(); - }, [refetchAgents]); - - useOfficeRefetch("agents", refetchAgents); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); return ( router.push("/office/agents")}> @@ -56,14 +44,17 @@ function SidebarAgentRow({ agent }: { agent: AgentProfile }) { const pathname = usePathname(); const href = `/office/agents/${agent.id}`; const isActive = pathname === href; - const liveCount = useAppStore((s) => selectActiveSessionsForAgent(s, agent.id)); - const errorCount = useAppStore((s) => - s.office.inboxItems.reduce((acc, item) => { - if (item.type !== "agent_run_failed") return acc; - const payloadAgent = - typeof item.payload?.agent_profile_id === "string" ? item.payload.agent_profile_id : ""; - return payloadAgent === agent.id ? acc + 1 : acc; - }, 0), + const liveCount = selectActiveSessionsForAgent(useAllTaskSessions(), agent.id); + const inboxItems = useOfficeInboxItems(); + const errorCount = useMemo( + () => + inboxItems.reduce((acc, item) => { + if (item.type !== "agent_run_failed") return acc; + const payloadAgent = + typeof item.payload?.agent_profile_id === "string" ? item.payload.agent_profile_id : ""; + return payloadAgent === agent.id ? acc + 1 : acc; + }, 0), + [inboxItems, agent.id], ); const isAutoPaused = (agent.pauseReason ?? "").startsWith("Auto-paused:"); diff --git a/apps/web/app/office/components/sidebar-projects-list.tsx b/apps/web/app/office/components/sidebar-projects-list.tsx index f00693301..324f42507 100644 --- a/apps/web/app/office/components/sidebar-projects-list.tsx +++ b/apps/web/app/office/components/sidebar-projects-list.tsx @@ -1,14 +1,20 @@ "use client"; import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; import { Badge } from "@kandev/ui/badge"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { SidebarCollapsibleSection } from "./sidebar-collapsible-section"; import { cn } from "@/lib/utils"; export function SidebarProjectsList() { const router = useRouter(); - const projects = useAppStore((s) => s.office.projects); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: projects = [] } = useQuery({ + ...officeQueryOptions.projects(workspaceId ?? ""), + enabled: !!workspaceId, + }); const activeProjects = projects.filter((p) => p.status !== "archived"); return ( diff --git a/apps/web/app/office/components/workspace-rail.tsx b/apps/web/app/office/components/workspace-rail.tsx index fd8a0f6ef..0e22aa5a4 100644 --- a/apps/web/app/office/components/workspace-rail.tsx +++ b/apps/web/app/office/components/workspace-rail.tsx @@ -7,6 +7,7 @@ import Link from "next/link"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; const GRADIENTS = [ "linear-gradient(135deg, #6366f1, #8b5cf6)", @@ -50,12 +51,13 @@ export function WorkspaceRail({ activeWorkspaceId: ssrActiveId, }: WorkspaceRailProps) { const router = useRouter(); - const storeWorkspaces = useAppStore((s) => s.workspaces); + const { workspaces: tqWorkspaces } = useWorkspaces(); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); - // Use store if hydrated, fall back to SSR props - const items = storeWorkspaces.items.length > 0 ? storeWorkspaces.items : ssrWorkspaces; - const activeId = storeWorkspaces.activeId ?? ssrActiveId; + // Use TQ data if hydrated, fall back to SSR props + const items = tqWorkspaces.length > 0 ? tqWorkspaces : ssrWorkspaces; + const activeId = activeWorkspaceId ?? ssrActiveId; const handleSelect = useCallback( (id: string) => { diff --git a/apps/web/app/office/inbox/inbox-item-row.tsx b/apps/web/app/office/inbox/inbox-item-row.tsx index 3cf6b1aca..4d9fa1c32 100644 --- a/apps/web/app/office/inbox/inbox-item-row.tsx +++ b/apps/web/app/office/inbox/inbox-item-row.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconShieldCheck, IconAlertTriangle, @@ -14,6 +15,7 @@ import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { useAppStore } from "@/components/state-provider"; import { dismissInboxItem, retryProvider } from "@/lib/api/domains/office-extended-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { InboxItem } from "@/lib/state/slices/office/types"; import { timeAgo } from "@/lib/utils/time"; @@ -62,7 +64,7 @@ type Props = { }; function useInboxTypeConfig(type: string) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const metaType = meta?.inboxItemTypes.find((t) => t.id === type); if (metaType) { return { diff --git a/apps/web/app/office/inbox/inbox-page-client.tsx b/apps/web/app/office/inbox/inbox-page-client.tsx index 8947cc896..5659c8601 100644 --- a/apps/web/app/office/inbox/inbox-page-client.tsx +++ b/apps/web/app/office/inbox/inbox-page-client.tsx @@ -1,12 +1,14 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { IconSearch } from "@tabler/icons-react"; import { Tabs, TabsList, TabsTrigger } from "@kandev/ui/tabs"; import { Input } from "@kandev/ui/input"; import { toast } from "sonner"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import { qk } from "@/lib/query/keys"; import * as officeApi from "@/lib/api/domains/office-api"; import type { InboxItem } from "@/lib/state/slices/office/types"; import { InboxItemRow } from "./inbox-item-row"; @@ -18,66 +20,31 @@ type InboxPageClientProps = { initialCount: number; }; -function useInboxData(workspaceId: string | null, initialItems: InboxItem[], initialCount: number) { - const setInboxItems = useAppStore((s) => s.setInboxItems); - const setInboxCount = useAppStore((s) => s.setInboxCount); - const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); - - useEffect(() => { - if (initialItems.length > 0) setInboxItems(initialItems); - if (initialCount > 0) setInboxCount(initialCount); - }, [initialItems, initialCount, setInboxItems, setInboxCount]); - - const fetchInbox = useCallback(async () => { - if (!workspaceId) return; - const [inboxRes, agentsRes] = await Promise.all([ - // Single call returns items + total_count (Stream F of office - // optimization). Was getInbox + getInboxCount in parallel. - officeApi.getInbox(workspaceId), - // Refetch agents alongside inbox so unpause-on-dismiss clears the - // sidebar paused badge without waiting on a WS event. - officeApi.listAgentProfiles(workspaceId), - ]); - const items = inboxRes.items ?? []; - setInboxItems(items); - setInboxCount(inboxRes.total_count ?? items.length); - if (Array.isArray(agentsRes.agents)) { - setOfficeAgentProfiles(agentsRes.agents); - } - }, [workspaceId, setInboxItems, setInboxCount, setOfficeAgentProfiles]); - - useEffect(() => { - void fetchInbox(); - }, [fetchInbox]); - - return fetchInbox; -} - -function useApprovalActions(fetchInbox: () => Promise) { +function useApprovalActions(refetch: () => void) { const handleApprove = useCallback( async (id: string) => { try { await officeApi.decideApproval(id, { status: "approved" }); - void fetchInbox(); + refetch(); toast.success("Approved"); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to approve"); } }, - [fetchInbox], + [refetch], ); const handleReject = useCallback( async (id: string) => { try { await officeApi.decideApproval(id, { status: "rejected" }); - void fetchInbox(); + refetch(); toast.success("Rejected"); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to reject"); } }, - [fetchInbox], + [refetch], ); return { handleApprove, handleReject }; @@ -122,19 +89,37 @@ function InboxToolbar({ ); } -export function InboxPageClient({ initialItems, initialCount }: InboxPageClientProps) { +export function InboxPageClient({ + initialItems: _initialItems, + initialCount: _initialCount, +}: InboxPageClientProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const inboxItems = useAppStore((s) => s.office.inboxItems); + const qc = useQueryClient(); const [tab, setTab] = useState("mine"); const [search, setSearch] = useState(""); - const fetchInbox = useInboxData(workspaceId, initialItems, initialCount); - const { handleApprove, handleReject } = useApprovalActions(fetchInbox); + const { data: inboxData } = useQuery({ + ...officeQueryOptions.inbox(workspaceId ?? ""), + enabled: !!workspaceId, + }); - useOfficeRefetch("inbox", fetchInbox); + const refetch = useCallback(() => { + if (!workspaceId) return; + void qc.invalidateQueries({ queryKey: ["office", workspaceId, "inbox"] }); + // Refetch agents + dashboard alongside inbox so unpause-on-dismiss + // ("Mark fixed" on an auto-paused agent) clears the sidebar paused + // badge immediately, instead of waiting on the async + // requeue → office.run.queued → bridge-invalidate roundtrip. This + // mirrors the pre-TQ-migration inbox page, which refetched agents in + // the same `fetchInbox` pass for exactly this reason. + void qc.invalidateQueries({ queryKey: qk.office.agents(workspaceId) }); + void qc.invalidateQueries({ queryKey: qk.office.dashboard(workspaceId) }); + }, [qc, workspaceId]); + + const { handleApprove, handleReject } = useApprovalActions(refetch); const filteredItems = useMemo(() => { - let items: InboxItem[] = inboxItems; + let items: InboxItem[] = inboxData?.items ?? []; if (tab === "mine") { items = items.filter( (i) => @@ -155,7 +140,7 @@ export function InboxPageClient({ initialItems, initialCount }: InboxPageClientP ); } return items; - }, [inboxItems, tab, search]); + }, [inboxData, tab, search]); return (
@@ -175,7 +160,7 @@ export function InboxPageClient({ initialItems, initialCount }: InboxPageClientP item={item} onApprove={handleApprove} onReject={handleReject} - onChanged={() => void fetchInbox()} + onChanged={refetch} /> )) )} diff --git a/apps/web/app/office/layout.tsx b/apps/web/app/office/layout.tsx index f618a2b28..e90a36845 100644 --- a/apps/web/app/office/layout.tsx +++ b/apps/web/app/office/layout.tsx @@ -12,10 +12,12 @@ import { listProjects, } from "@/lib/api/domains/office-api"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; -import type { AppState } from "@/lib/state/store"; +import type { SsrInitialState } from "@/lib/ssr/initial-state"; +import type { AgentProfile, Project, InboxItem } from "@/lib/state/slices/office/types"; import { WorkspaceRail } from "./components/workspace-rail"; import { OfficeSidebar } from "./components/office-sidebar"; import { OfficeTopbar } from "./components/office-topbar"; +import { OfficeTqSeeder } from "./components/office-tq-seeder"; function resolveActiveOfficeWorkspaceId( workspaceItems: { id: string }[], @@ -118,7 +120,7 @@ export default async function OfficeLayout({ children }: { children: React.React ]) : [{ agents: [] }, { projects: [] }, { items: [], total_count: 0 }]; - const initialState: Partial = { + const initialState: SsrInitialState = { workspaces: { items: workspaceItems, activeId: activeWorkspaceId, @@ -127,42 +129,23 @@ export default async function OfficeLayout({ children }: { children: React.React ...mapUserSettingsResponse(userSettingsResponse), workspaceId: activeWorkspaceId, }, - office: { - agentProfiles: agentsResponse.agents as AppState["office"]["agentProfiles"], - skills: [], - projects: projectsResponse.projects as AppState["office"]["projects"], - approvals: [], - activity: [], - costSummary: null, - budgetPolicies: [], - routines: [], - inboxItems: inboxResponse.items as AppState["office"]["inboxItems"], - inboxCount: inboxResponse.total_count, - runs: [], - dashboard: null, - tasks: { - items: [], - filters: { statuses: [], priorities: [], assigneeIds: [], projectIds: [], search: "" }, - viewMode: "list", - sortField: "updated", - sortDir: "desc", - groupBy: "none", - nestingEnabled: true, - isLoading: false, - }, - meta: metaResponse, - isLoading: false, - refetchTrigger: null, - routing: { byWorkspace: {}, knownProviders: [], preview: { byWorkspace: {} } }, - providerHealth: { byWorkspace: {} }, - runAttempts: { byRunId: {} }, - agentRouting: { byAgentId: {} }, - }, + }; + + // Office server data is read via TanStack Query (no Zustand mirror), so + // seed the TQ caches from the SSR snapshot instead of hydrating a slice. + const officeSnapshot = { + workspaceId: activeWorkspaceId, + agents: agentsResponse.agents as AgentProfile[], + projects: projectsResponse.projects as Project[], + inboxItems: inboxResponse.items as InboxItem[], + inboxCount: inboxResponse.total_count, + meta: metaResponse, }; return ( +
s.workspaces.activeId); - const dashboard = useAppStore((s) => s.office.dashboard); - const agents = useAppStore((s) => s.office.agentProfiles); - const setDashboard = useAppStore((s) => s.setDashboard); + const { data: dashboard } = useQuery({ + ...officeQueryOptions.dashboard(workspaceId ?? ""), + enabled: !!workspaceId, + }); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); - // Hydrate from SSR exactly once on first mount; subsequent updates flow - // through the WS-driven refetch below. Skipping the unconditional mount - // fetch removes a redundant round-trip when SSR data is already in the - // store (Stream G of office optimization). - useEffect(() => { - if (initialDashboard) { - setDashboard(initialDashboard); - } - }, [initialDashboard, setDashboard]); - - const fetchDashboard = useCallback(async () => { - if (!workspaceId) return; - const data = await officeApi.getDashboard(workspaceId); - setDashboard(data); - }, [workspaceId, setDashboard]); - - // Refetch dashboard on any office event that affects metrics. The - // dashboard payload now includes per-agent summaries so a single fetch - // refreshes both the metric cards and the agent cards panel. - useOfficeRefetch("dashboard", fetchDashboard); - useOfficeRefetch("agents", fetchDashboard); - - const metrics = extractMetrics(dashboard); + const metrics = extractMetrics(dashboard ?? null); const topUtilization = maxUtilization(agents); const quotaLabel = topUtilization > 0 ? `${Math.round(topUtilization)}%` : "—"; const hasSubscriptionAgents = agents.some((a) => a.billingType === "subscription"); diff --git a/apps/web/app/office/projects/[id]/page.tsx b/apps/web/app/office/projects/[id]/page.tsx index 604c380e2..50487c707 100644 --- a/apps/web/app/office/projects/[id]/page.tsx +++ b/apps/web/app/office/projects/[id]/page.tsx @@ -1,15 +1,16 @@ "use client"; -import { useEffect, useState, use } from "react"; +import { use } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { IconChevronRight, IconTrash } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Separator } from "@kandev/ui/separator"; import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; -import { getProject, deleteProject } from "@/lib/api/domains/office-api"; -import type { Project } from "@/lib/state/slices/office/types"; +import { deleteProject } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { OfficeTopbarPortal } from "../../components/office-topbar-portal"; import { ProjectHeader } from "./project-header"; import { ProjectReposSection } from "./project-repos-section"; @@ -23,33 +24,23 @@ type PageProps = { export default function ProjectDetailPage({ params }: PageProps) { const { id } = use(params); const router = useRouter(); - const removeProject = useAppStore((s) => s.removeProject); - const storeProject = useAppStore((s) => s.office.projects.find((p) => p.id === id)); - const [fetchedProject, setFetchedProject] = useState(null); - const project = storeProject ?? fetchedProject; - - useEffect(() => { - if (storeProject) return; - let cancelled = false; - getProject(id) - .then((res) => { - if (!cancelled && res) setFetchedProject(res as unknown as Project); - }) - .catch((err) => { - if (!cancelled) { - toast.error(err instanceof Error ? err.message : "Failed to load project"); - } - }); - return () => { - cancelled = true; - }; - }, [id, storeProject]); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const qc = useQueryClient(); + const { + data: project, + isPending, + isError, + } = useQuery({ + ...officeQueryOptions.projects(workspaceId ?? ""), + enabled: !!workspaceId, + select: (projects) => projects.find((p) => p.id === id), + }); const handleDelete = async () => { if (!project) return; try { await deleteProject(project.id); - removeProject(project.id); + if (workspaceId) void qc.invalidateQueries({ queryKey: ["office", workspaceId, "projects"] }); toast.success("Project deleted"); router.push("/office/projects"); } catch (err) { @@ -57,7 +48,15 @@ export default function ProjectDetailPage({ params }: PageProps) { } }; - if (!project) { + if (isError) { + return ( +
+

Failed to load project.

+
+ ); + } + + if (isPending || !project) { return (

Loading project...

diff --git a/apps/web/app/office/projects/[id]/project-executor-section.tsx b/apps/web/app/office/projects/[id]/project-executor-section.tsx index e42ecbaae..9812cb48f 100644 --- a/apps/web/app/office/projects/[id]/project-executor-section.tsx +++ b/apps/web/app/office/projects/[id]/project-executor-section.tsx @@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { IconDeviceFloppy } from "@tabler/icons-react"; import { toast } from "sonner"; import { updateProject } from "@/lib/api/domains/office-api"; -import { useAppStore } from "@/components/state-provider"; +import { usePatchProjectCache } from "@/hooks/domains/office/use-patch-project-cache"; import type { Project } from "@/lib/state/slices/office/types"; type ProjectExecutorSectionProps = { @@ -95,7 +95,7 @@ function ContainerFields({ } export function ProjectExecutorSection({ project }: ProjectExecutorSectionProps) { - const updateProjectStore = useAppStore((s) => s.updateProject); + const updateProjectStore = usePatchProjectCache(); const config = project.executorConfig ?? {}; const [executorType, setExecutorType] = useState((config.type as string) ?? ""); diff --git a/apps/web/app/office/projects/[id]/project-header.tsx b/apps/web/app/office/projects/[id]/project-header.tsx index abe0c5313..375dc7227 100644 --- a/apps/web/app/office/projects/[id]/project-header.tsx +++ b/apps/web/app/office/projects/[id]/project-header.tsx @@ -8,7 +8,7 @@ import { Textarea } from "@kandev/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { toast } from "sonner"; import { updateProject } from "@/lib/api/domains/office-api"; -import { useAppStore } from "@/components/state-provider"; +import { usePatchProjectCache } from "@/hooks/domains/office/use-patch-project-cache"; import type { Project, ProjectStatus } from "@/lib/state/slices/office/types"; const STATUS_OPTIONS: { value: ProjectStatus; label: string }[] = [ @@ -23,7 +23,7 @@ type ProjectHeaderProps = { }; export function ProjectHeader({ project }: ProjectHeaderProps) { - const updateProjectStore = useAppStore((s) => s.updateProject); + const updateProjectStore = usePatchProjectCache(); const [name, setName] = useState(project.name); const [description, setDescription] = useState(project.description ?? ""); diff --git a/apps/web/app/office/projects/[id]/project-repos-section.tsx b/apps/web/app/office/projects/[id]/project-repos-section.tsx index 314beed35..9a7e1ae06 100644 --- a/apps/web/app/office/projects/[id]/project-repos-section.tsx +++ b/apps/web/app/office/projects/[id]/project-repos-section.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { IconCode, IconWorld, IconX } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { toast } from "sonner"; @@ -19,7 +20,7 @@ type ProjectReposSectionProps = { export function ProjectReposSection({ project }: ProjectReposSectionProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const updateProjectStore = useAppStore((s) => s.updateProject); + const qc = useQueryClient(); const { repositories } = useRepositories(workspaceId); const repos = useMemo(() => normalizeRepos(project.repositories), [project.repositories]); @@ -27,13 +28,17 @@ export function ProjectReposSection({ project }: ProjectReposSectionProps) { async (next: string[], successMessage: string, failureMessage: string) => { try { await updateProject(project.id, { repositories: next }); - updateProjectStore(project.id, { repositories: next }); + // The parent page reads project via the office projects query. + // Invalidate so the chip list reflects the new repositories. + if (workspaceId) { + await qc.invalidateQueries({ queryKey: ["office", workspaceId, "projects"] }); + } toast.success(successMessage); } catch (err) { toast.error(err instanceof Error ? err.message : failureMessage); } }, - [project.id, updateProjectStore], + [project.id, qc, workspaceId], ); const handleAdd = useCallback( diff --git a/apps/web/app/office/projects/[id]/project-tasks-section.tsx b/apps/web/app/office/projects/[id]/project-tasks-section.tsx index 8851609e2..4c1fb475b 100644 --- a/apps/web/app/office/projects/[id]/project-tasks-section.tsx +++ b/apps/web/app/office/projects/[id]/project-tasks-section.tsx @@ -1,8 +1,9 @@ "use client"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { listTasks } from "@/lib/api/domains/office-tasks-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; import { TaskRow } from "../../tasks/task-row"; @@ -12,40 +13,20 @@ type ProjectTasksSectionProps = { export function ProjectTasksSection({ projectId }: ProjectTasksSectionProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const appendTasks = useAppStore((s) => s.appendTasks); - // Select stable references; derive the filtered list and the agent-name - // lookup via useMemo. Returning a freshly `.filter()`'d array or a - // `new Map(...)` straight from the selector tripped React's - // getSnapshot caching guard because every render produced a new - // reference. - const allTasks = useAppStore((s) => s.office.tasks.items); - const agentProfiles = useAppStore((s) => s.office.agentProfiles); - - // Fetch tasks for this project once on mount. The list is merged into - // the global store via appendTasks so other consumers (the Tasks page, - // the inbox, etc.) keep seeing the union of every task they've loaded. - useEffect(() => { - if (!workspaceId) return; - let cancelled = false; - listTasks(workspaceId, { project: projectId }) - .then((res) => { - if (cancelled || !res?.tasks?.length) return; - appendTasks(res.tasks); - }) - .catch(() => { - // Failure is non-fatal — store-resident tasks still render. - }); - return () => { - cancelled = true; - }; - }, [workspaceId, projectId, appendTasks]); + // TQ fetches tasks for this project directly — no global store merge needed. + const { data: tasks = [] } = useQuery({ + ...officeQueryOptions.tasks(workspaceId ?? "", { projectIds: [projectId] }), + enabled: !!workspaceId, + }); + const { data: agentProfiles = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); const sorted = useMemo( () => - allTasks - .filter((t) => t.projectId === projectId) - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), - [allTasks, projectId], + [...tasks].sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), + [tasks], ); const agentNameById = useMemo( diff --git a/apps/web/app/office/projects/create-project-dialog.tsx b/apps/web/app/office/projects/create-project-dialog.tsx index 62b3def12..cd19304c0 100644 --- a/apps/web/app/office/projects/create-project-dialog.tsx +++ b/apps/web/app/office/projects/create-project-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { IconPlus, IconX } from "@tabler/icons-react"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; @@ -10,8 +11,8 @@ import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Textarea } from "@kandev/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { useAppStore } from "@/components/state-provider"; import { createProject } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { AgentProfile } from "@/lib/state/slices/office/types"; const COLOR_OPTIONS = [ @@ -193,7 +194,7 @@ const INITIAL_PROJECT_STATE: ProjectFormState = { }; function useProjectForm(workspaceId: string, onClose: () => void) { - const addProject = useAppStore((s) => s.addProject); + const qc = useQueryClient(); const [form, setForm] = useState(INITIAL_PROJECT_STATE); const [submitting, setSubmitting] = useState(false); @@ -228,7 +229,9 @@ function useProjectForm(workspaceId: string, onClose: () => void) { ? { type: form.executorType, image: form.dockerImage || undefined } : undefined, }); - if (result) addProject(result); + if (result) { + void qc.invalidateQueries({ queryKey: ["office", workspaceId, "projects"] }); + } onClose(); setForm(INITIAL_PROJECT_STATE); toast.success("Project created"); @@ -237,7 +240,7 @@ function useProjectForm(workspaceId: string, onClose: () => void) { } finally { setSubmitting(false); } - }, [form, workspaceId, addProject, onClose]); + }, [form, workspaceId, qc, onClose]); return { form, update, submitting, handleAddRepo, handleRemoveRepo, handleCreate }; } @@ -320,8 +323,11 @@ function ProjectFormBody({ } export function CreateProjectDialog({ open, onOpenChange, workspaceId }: CreateProjectDialogProps) { - const agents = useAppStore((s) => s.office.agentProfiles); - const meta = useAppStore((s) => s.office.meta); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId), + enabled: !!workspaceId, + }); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const executorTypes = meta?.executorTypes.map((e) => ({ id: e.id, label: e.label })) ?? FALLBACK_EXECUTOR_TYPES; const { form, update, submitting, handleAddRepo, handleRemoveRepo, handleCreate } = diff --git a/apps/web/app/office/projects/page.tsx b/apps/web/app/office/projects/page.tsx index 259421ccf..7597aa4d6 100644 --- a/apps/web/app/office/projects/page.tsx +++ b/apps/web/app/office/projects/page.tsx @@ -1,18 +1,5 @@ -import { listProjects } from "@/lib/api/domains/office-api"; -import { getActiveWorkspaceId } from "../lib/get-active-workspace"; import { ProjectsPageClient } from "./projects-page-client"; -import type { Project } from "@/lib/state/slices/office/types"; -export default async function ProjectsPage() { - const workspaceId = await getActiveWorkspaceId(); - - let projects: Project[] = []; - if (workspaceId) { - const res = await listProjects(workspaceId, { cache: "no-store" }).catch(() => ({ - projects: [], - })); - projects = res.projects ?? []; - } - - return ; +export default function ProjectsPage() { + return ; } diff --git a/apps/web/app/office/projects/project-card.tsx b/apps/web/app/office/projects/project-card.tsx index 5a7316b3f..e312d4b19 100644 --- a/apps/web/app/office/projects/project-card.tsx +++ b/apps/web/app/office/projects/project-card.tsx @@ -1,11 +1,12 @@ "use client"; import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; import { IconGitBranch } from "@tabler/icons-react"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Badge } from "@kandev/ui/badge"; import { Progress } from "@kandev/ui/progress"; -import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { Project } from "@/lib/state/slices/office/types"; import { normalizeRepos } from "./normalize-repos"; @@ -29,7 +30,7 @@ type ProjectCardProps = { }; function useProjectStatusDisplay(status: string) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const metaStatus = meta?.projectStatuses.find((s) => s.id === status); return { badgeClass: metaStatus?.color ?? FALLBACK_BADGE_CLASSES[status] ?? "", diff --git a/apps/web/app/office/projects/projects-page-client.tsx b/apps/web/app/office/projects/projects-page-client.tsx index dc55dcca7..f1b035a4a 100644 --- a/apps/web/app/office/projects/projects-page-client.tsx +++ b/apps/web/app/office/projects/projects-page-client.tsx @@ -1,51 +1,27 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconPlus } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import { listProjects } from "@/lib/api/domains/office-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; -import type { Project } from "@/lib/state/slices/office/types"; import { ProjectCard } from "./project-card"; import { CreateProjectDialog } from "./create-project-dialog"; import { EmptyState } from "../components/shared/empty-state"; -type ProjectsPageClientProps = { - initialProjects: Project[]; -}; - -export function ProjectsPageClient({ initialProjects }: ProjectsPageClientProps) { - const projects = useAppStore((s) => s.office.projects); - const agents = useAppStore((s) => s.office.agentProfiles); - const setProjects = useAppStore((s) => s.setProjects); +export function ProjectsPageClient() { const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); const [dialogOpen, setDialogOpen] = useState(false); - - // Hydrate from SSR; subsequent updates flow through the WS-driven - // refetch below. Skipping the unconditional mount fetch removes a - // redundant round-trip when SSR data is already in the store - // (Stream G of office optimization). - useEffect(() => { - if (initialProjects.length > 0) { - setProjects(initialProjects); - } - }, [initialProjects, setProjects]); - - const loadProjects = useCallback(async () => { - if (!activeWorkspaceId) return; - try { - const res = await listProjects(activeWorkspaceId); - if (res?.projects) { - setProjects(res.projects); - } - } catch { - // Silently handle fetch errors - } - }, [activeWorkspaceId, setProjects]); - - useOfficeRefetch("projects", loadProjects); + const { data: projects = [] } = useQuery({ + ...officeQueryOptions.projects(activeWorkspaceId ?? ""), + enabled: !!activeWorkspaceId, + }); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(activeWorkspaceId ?? ""), + enabled: !!activeWorkspaceId, + }); const agentNameMap = new Map(agents.map((a) => [a.id, a.name])); diff --git a/apps/web/app/office/routines/[id]/routine-detail-view.tsx b/apps/web/app/office/routines/[id]/routine-detail-view.tsx index bbcadbd4e..de004759c 100644 --- a/apps/web/app/office/routines/[id]/routine-detail-view.tsx +++ b/apps/web/app/office/routines/[id]/routine-detail-view.tsx @@ -3,6 +3,7 @@ import { useCallback, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; import { IconChevronRight, IconPlayerPlay, IconDeviceFloppy } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; @@ -12,6 +13,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { updateRoutine, runRoutine, @@ -70,7 +72,11 @@ type RoutineDetailViewProps = { export function RoutineDetailView({ initialRoutine, initialTriggers }: RoutineDetailViewProps) { const router = useRouter(); - const agents = useAppStore((s) => s.office.agentProfiles); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); const [routine] = useState(initialRoutine); const [triggers, setTriggers] = useState(initialTriggers); const [draft, setDraft] = useState(buildDraft(initialRoutine, initialTriggers)); diff --git a/apps/web/app/office/routines/routines-content.tsx b/apps/web/app/office/routines/routines-content.tsx index b7c44479a..d9fbbc0fd 100644 --- a/apps/web/app/office/routines/routines-content.tsx +++ b/apps/web/app/office/routines/routines-content.tsx @@ -1,13 +1,15 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; import { IconPlus } from "@tabler/icons-react"; import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import { qk } from "@/lib/query/keys"; import { - listRoutines, createRoutine, updateRoutine, deleteRoutine, @@ -117,17 +119,19 @@ function useRoutineActions(workspaceId: string | null, fetchRoutines: () => Prom // fetcher is referentially stable (depends on workspaceId only) so the // effects don't re-fire on unrelated re-renders. function useRoutinesData(workspaceId: string | null) { - const routines = useAppStore((s) => s.office.routines); - const setRoutines = useAppStore((s) => s.setRoutines); + const qc = useQueryClient(); + const { data: routines = [] } = useQuery({ + ...officeQueryOptions.routines(workspaceId ?? ""), + enabled: !!workspaceId, + }); const [runs, setRuns] = useState([]); const [triggersByRoutine, setTriggersByRoutine] = useState>({}); const fetchRoutines = useCallback(async () => { if (!workspaceId) return; - const res = await listRoutines(workspaceId); - setRoutines(res.routines ?? []); - }, [workspaceId, setRoutines]); + await qc.invalidateQueries({ queryKey: qk.office.routines(workspaceId ?? "") }); + }, [workspaceId, qc]); const fetchRuns = useCallback(async () => { if (!workspaceId) return [] as RoutineRun[]; @@ -151,14 +155,13 @@ function useRoutinesData(workspaceId: string | null) { useEffect(() => { let cancelled = false; - void fetchRoutines(); fetchRuns().then((next) => { if (!cancelled) setRuns(next); }); return () => { cancelled = true; }; - }, [fetchRoutines, fetchRuns]); + }, [fetchRuns]); useEffect(() => { let cancelled = false; @@ -183,7 +186,10 @@ function useRoutinesData(workspaceId: string | null) { export function RoutinesContent() { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const agents = useAppStore((s) => s.office.agentProfiles); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); const [showCreate, setShowCreate] = useState(false); const { routines, runs, setRuns, triggersByRoutine, fetchRoutines, fetchRuns } = useRoutinesData(workspaceId); diff --git a/apps/web/app/office/routines/routines-page-client.tsx b/apps/web/app/office/routines/routines-page-client.tsx index 9b552cc54..ecd9bbf34 100644 --- a/apps/web/app/office/routines/routines-page-client.tsx +++ b/apps/web/app/office/routines/routines-page-client.tsx @@ -1,9 +1,9 @@ "use client"; -import { useCallback, useEffect } from "react"; +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import { listRoutines } from "@/lib/api/domains/office-api"; +import { qk } from "@/lib/query/keys"; import type { Routine } from "@/lib/state/slices/office/types"; import { RoutinesContent } from "./routines-content"; @@ -13,21 +13,18 @@ type RoutinesPageClientProps = { export function RoutinesPageClient({ initialRoutines }: RoutinesPageClientProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const setRoutines = useAppStore((s) => s.setRoutines); + const qc = useQueryClient(); + // Seed the TQ routines cache from the SSR snapshot so the first paint + // isn't empty. Seed-if-absent: never clobber a live client result, and + // let the office WS bridge keep the cache fresh thereafter. useEffect(() => { - if (initialRoutines.length > 0) { - setRoutines(initialRoutines); + if (!workspaceId || initialRoutines.length === 0) return; + const key = qk.office.routines(workspaceId); + if (qc.getQueryData(key) === undefined) { + qc.setQueryData(key, initialRoutines); } - }, [initialRoutines, setRoutines]); - - const refetchRoutines = useCallback(async () => { - if (!workspaceId) return; - const res = await listRoutines(workspaceId).catch(() => ({ routines: [] as Routine[] })); - setRoutines(res.routines ?? []); - }, [workspaceId, setRoutines]); - - useOfficeRefetch("routines", refetchRoutines); + }, [workspaceId, initialRoutines, qc]); return ; } diff --git a/apps/web/app/office/routines/run-row.tsx b/apps/web/app/office/routines/run-row.tsx index b6451e234..fffd52b1e 100644 --- a/apps/web/app/office/routines/run-row.tsx +++ b/apps/web/app/office/routines/run-row.tsx @@ -1,7 +1,8 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { Badge } from "@kandev/ui/badge"; -import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { RoutineRun } from "@/lib/state/slices/office/types"; const FALLBACK_COLORS: Record = { @@ -28,7 +29,7 @@ type RunRowProps = { }; export function RunRow({ run }: RunRowProps) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const metaStatus = meta?.routineRunStatuses.find((s) => s.id === run.status); const colorClass = metaStatus?.color ?? FALLBACK_COLORS[run.status] ?? ""; const label = metaStatus?.label ?? formatLabel(run.status); diff --git a/apps/web/app/office/setup/page.tsx b/apps/web/app/office/setup/page.tsx index 7369e7d95..812a58b67 100644 --- a/apps/web/app/office/setup/page.tsx +++ b/apps/web/app/office/setup/page.tsx @@ -4,7 +4,7 @@ import { fetchUserSettings, listAgents } from "@/lib/api/domains/settings-api"; import { listWorkspaces } from "@/lib/api/domains/workspace-api"; import { SetupWizard } from "./setup-wizard"; import type { Agent } from "@/lib/types/http"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import { toAgentProfileOption } from "@/lib/types/settings"; export default async function SetupPage({ searchParams, diff --git a/apps/web/app/office/setup/setup-wizard.tsx b/apps/web/app/office/setup/setup-wizard.tsx index 9a1eb7378..485831202 100644 --- a/apps/web/app/office/setup/setup-wizard.tsx +++ b/apps/web/app/office/setup/setup-wizard.tsx @@ -15,7 +15,7 @@ import { StepTask } from "./step-task"; import { StepReview } from "./step-review"; import { WizardFooter } from "./wizard-footer"; import { CloseButton } from "./close-button"; -import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; +import type { AgentProfileOption } from "@/lib/types/settings"; import type { Tier } from "@/lib/state/slices/office/types"; type SetupWizardProps = { diff --git a/apps/web/app/office/setup/step-agent.tsx b/apps/web/app/office/setup/step-agent.tsx index 99ef8082e..f9088ef5c 100644 --- a/apps/web/app/office/setup/step-agent.tsx +++ b/apps/web/app/office/setup/step-agent.tsx @@ -1,15 +1,19 @@ "use client"; import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; -import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import { settingsQueryOptions } from "@/lib/query/query-options/settings"; +import { useUpsertAgentProfileOption } from "@/hooks/domains/settings/use-settings-reads"; import { AgentSelector } from "@/components/task-create-dialog-selectors"; import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; -import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import type { AgentProfile } from "@/lib/types/agent-profile"; +import type { AgentProfileOption } from "@/lib/types/settings"; +import { toAgentProfileOption } from "@/lib/types/settings"; import { getCapabilityWarning } from "@/lib/capability-warning"; import { CliProfileEditor } from "@/components/agent/cli-profile-editor"; import { Combobox, type ComboboxOption } from "@/components/combobox"; @@ -64,11 +68,10 @@ export function StepAgent({ onChange, onAgentProfilesChange, }: StepAgentProps) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const executorOptions = meta?.executorTypes ?? FALLBACK_EXECUTOR_OPTIONS; - const settingsAgents = useAppStore((s) => s.settingsAgents.items); - const setAgentProfiles = useAppStore((s) => s.setAgentProfiles); - const agentProfilesState = useAppStore((s) => s.agentProfiles.items); + const { data: settingsAgents = [] } = useQuery({ ...settingsQueryOptions.agents() }); + const upsertAgentProfile = useUpsertAgentProfileOption(); const sortedProfiles = useMemo(() => sortProfiles(agentProfiles), [agentProfiles]); const baseOptions = useAgentProfileOptions(sortedProfiles); @@ -124,10 +127,9 @@ export function StepAgent({ {showCreate ? ( 0} - setAgentProfiles={setAgentProfiles} + upsertAgentProfile={upsertAgentProfile} onAgentProfilesChange={onAgentProfilesChange} onChange={onChange} onClose={() => setShowCreate(false)} @@ -200,19 +202,17 @@ function TierIndicator({ function CreateProfilePanel({ settingsAgents, - storeProfiles, wizardProfiles, canCancel, - setAgentProfiles, + upsertAgentProfile, onAgentProfilesChange, onChange, onClose, }: { settingsAgents: { id: string; name: string }[]; - storeProfiles: AgentProfileOption[]; wizardProfiles: AgentProfileOption[]; canCancel: boolean; - setAgentProfiles: (profiles: AgentProfileOption[]) => void; + upsertAgentProfile: (saved: AgentProfile) => void; onAgentProfilesChange?: (profiles: AgentProfileOption[]) => void; onChange: StepAgentProps["onChange"]; onClose: () => void; @@ -225,12 +225,12 @@ function CreateProfilePanel({ showAdvanced allowCliPassthrough={false} onSaved={(saved) => { + upsertAgentProfile(saved); const agentForProfile = settingsAgents.find((a) => a.id === saved.agentId) ?? { id: saved.agentId ?? "", name: saved.agentId ?? "", }; const option = toAgentProfileOption(agentForProfile, saved); - setAgentProfiles([...storeProfiles.filter((p) => p.id !== option.id), option]); onAgentProfilesChange?.([...wizardProfiles.filter((p) => p.id !== option.id), option]); onChange({ agentProfileId: saved.id }); onClose(); diff --git a/apps/web/app/office/setup/step-review.tsx b/apps/web/app/office/setup/step-review.tsx index 8db4b8230..495b6121a 100644 --- a/apps/web/app/office/setup/step-review.tsx +++ b/apps/web/app/office/setup/step-review.tsx @@ -1,8 +1,9 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { Badge } from "@kandev/ui/badge"; import { Card } from "@kandev/ui/card"; -import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; type StepReviewProps = { workspaceName: string; @@ -28,7 +29,7 @@ export function StepReview({ executorPreference, taskTitle, }: StepReviewProps) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const executorLabel = meta?.executorTypes.find((e) => e.id === executorPreference)?.label ?? diff --git a/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx b/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx index b16c0a5c8..248796ae9 100644 --- a/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx +++ b/apps/web/app/office/tasks/[id]/advanced-panels/chat-panel.tsx @@ -8,7 +8,7 @@ import { Input } from "@kandev/ui/input"; import { useSessionMessages } from "@/hooks/domains/session/use-session-messages"; import { useSession } from "@/hooks/domains/session/use-session"; import { useSessionLaunch } from "@/hooks/domains/session/use-session-launch"; -import { useAppStore } from "@/components/state-provider"; +import { useAgentProfiles } from "@/hooks/domains/settings/use-settings-reads"; import { getWebSocketClient } from "@/lib/ws/connection"; import { buildStartRequest } from "@/lib/services/session-launch-helpers"; import { MessageRenderer } from "@/components/task/chat/message-renderer"; @@ -137,7 +137,7 @@ export function AdvancedChatPanel({ taskId, sessionId, hideInput }: AdvancedChat const { session } = useSession(sessionId); const { messages, isLoading } = useSessionMessages(sessionId); - const agentProfiles = useAppStore((s) => s.agentProfiles.items ?? []); + const agentProfiles = useAgentProfiles(); const defaultProfile = agentProfiles[0] ?? null; const { launch, isLoading: isLaunching } = useSessionLaunch(); diff --git a/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx b/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx index 8d986177f..8d975b35f 100644 --- a/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx +++ b/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { DockviewReact, type DockviewReadyEvent } from "dockview-react"; import { themeKandev } from "@/lib/layout/dockview-theme"; import { useDockviewStore } from "@/lib/state/dockview-store"; @@ -20,6 +21,10 @@ import { DockviewWatermark } from "@/components/task/dockview-watermark"; import { VcsDialogsProvider } from "@/components/vcs/vcs-dialogs"; import { ensureTaskSession } from "@/lib/services/session-launch-service"; +import { qk } from "@/lib/query/keys"; +import { mergeTaskSessionIntoCache } from "@/lib/query/cache/task-session-cache"; +import { writeAgentctlStatus } from "@/lib/query/agentctl-status"; +import type { TaskSession } from "@/lib/types/http"; import { panelPortalManager } from "@/lib/layout/panel-portal-manager"; import { PanelPortalHost } from "@/lib/layout/panel-portal-host"; @@ -74,8 +79,7 @@ export function OfficeDockviewLayout({ taskId, sessionId }: OfficeDockviewLayout } }, [taskId, sessionId, setActiveSession, setActiveTask]); - const setAgentctlStatus = useAppStore((s) => s.setSessionAgentctlStatus); - const setTaskSession = useAppStore((s) => s.setTaskSession); + const queryClient = useQueryClient(); // Ensure the execution (agentctl) is running so file/terminal/changes panels work. // Office tasks are one-off — the execution may have been torn down after completion. @@ -86,16 +90,21 @@ export function OfficeDockviewLayout({ taskId, sessionId }: OfficeDockviewLayout ensureTaskSession(taskId, { ensureExecution: true }) .then((resp) => { if (resp.session_id) { - setAgentctlStatus(resp.session_id, { + writeAgentctlStatus(queryClient, resp.session_id, { status: "ready", updatedAt: new Date().toISOString(), }); // Populate worktree_path from workspace_path for quick-chat sessions // so the file browser shows the workspace path instead of a skeleton. if (resp.workspace_path) { - const existing = appStore.getState().taskSessions.items[resp.session_id]; + const existing = queryClient.getQueryData( + qk.taskSession.byId(resp.session_id), + ); if (existing && !existing.worktree_path) { - setTaskSession({ ...existing, worktree_path: resp.workspace_path }); + mergeTaskSessionIntoCache(queryClient, { + ...existing, + worktree_path: resp.workspace_path, + }); } } } @@ -103,7 +112,7 @@ export function OfficeDockviewLayout({ taskId, sessionId }: OfficeDockviewLayout .catch(() => { // Non-fatal: panels will show appropriate empty/retry states. }); - }, [taskId, setAgentctlStatus, setTaskSession, appStore]); + }, [taskId, queryClient]); // Clean up on unmount — release portals and clear active session. useEffect(() => { diff --git a/apps/web/app/office/tasks/[id]/page.tsx b/apps/web/app/office/tasks/[id]/page.tsx index 4a81f1583..9ff9c7068 100644 --- a/apps/web/app/office/tasks/[id]/page.tsx +++ b/apps/web/app/office/tasks/[id]/page.tsx @@ -2,8 +2,11 @@ import { use, useState, useEffect, useRef, useCallback, useMemo, Suspense } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; +import { useTaskSessionsByTaskFromCache } from "@/hooks/domains/session/use-task-session-by-id"; +import { setTaskSessionsForTaskInCache } from "@/lib/query/cache/task-session-cache"; +import { useOfficeWsEffect } from "@/hooks/domains/office/use-office-ws-effect"; import { TaskOptimisticContextProvider } from "@/hooks/use-optimistic-task-mutation"; import { getTask, @@ -214,11 +217,15 @@ function useSessionLiveSync({ onTaskRefetch, onCommentsRefetch, }: LiveSyncParams) { - // Join to a stable string to avoid infinite re-renders from array reference changes. - const sessionStatesKey = useAppStore((s) => { - const items = s.taskSessions?.items ?? {}; - return baseSessions.map((sess) => items[sess.id]?.state ?? sess.state).join(","); - }); + // Read live session states from the TQ by-task cache (populated by the + // session-state bridge + the by-task fetch), falling back to the snapshot + // state. Join to a stable string to avoid infinite re-renders from array + // reference changes. + const cachedSessions = useTaskSessionsByTaskFromCache(task?.id ?? null); + const sessionStatesKey = useMemo(() => { + const stateById = new Map(cachedSessions.map((s) => [s.id, s.state])); + return baseSessions.map((sess) => stateById.get(sess.id) ?? sess.state).join(","); + }, [cachedSessions, baseSessions]); const sessionStoreStates = useMemo( () => (sessionStatesKey ? sessionStatesKey.split(",") : []), [sessionStatesKey], @@ -278,9 +285,17 @@ function useTaskOptimisticHelpers( /* swallow — next user action will retry */ } }, [id, setTask, setTimeline]); - useOfficeRefetch(`task:${id}`, () => { - void refetchTask(); - }); + // Refetch the task DTO when the backend broadcasts a change to THIS task. + // Mirrors the legacy `task:${id}` refetch trigger: the WS handler fired it + // on these three events; here we subscribe to them directly and filter by + // task id. + useOfficeWsEffect( + ["office.task.updated", "office.task.decision_recorded", "office.task.review_requested"], + (message) => { + const updatedTaskId = (message.payload.task_id ?? message.payload.id) as string | undefined; + if (updatedTaskId === id) void refetchTask(); + }, + ); const applyTaskPatch = useCallback( (patch: Partial) => { @@ -304,17 +319,16 @@ function useTaskOptimisticHelpers( // --------------------------------------------------------------------------- function useIssueData(id: string) { - const storeIssues = useAppStore((s) => s.office.tasks.items); - const setTaskSessionsForTask = useAppStore((s) => s.setTaskSessionsForTask); - // Snapshot storeIssues in a ref so the load effect can seed `task` from - // the store without re-running on every store update. Re-running the GET - // on store changes would race with in-flight optimistic mutations (the - // WS-driven refetch in useTaskOptimisticHelpers handles canonical - // refresh after a property mutation commits). - const storeIssuesRef = useRef(storeIssues); + const qc = useQueryClient(); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + // Peek into the TQ tasks cache to pre-seed the task while the API call is + // in flight. We use a ref so the load effect doesn't re-run on cache changes. + const cachedTasksRef = useRef(null); useEffect(() => { - storeIssuesRef.current = storeIssues; - }, [storeIssues]); + if (!workspaceId) return; + const cached = qc.getQueryData(["office", workspaceId, "tasks"]); + cachedTasksRef.current = cached ?? null; + }, [qc, workspaceId]); const [task, setTask] = useState(null); const [comments, setComments] = useState([]); @@ -328,10 +342,10 @@ function useIssueData(id: string) { (detail: IssueDetailData) => { if (detail.activity) setActivity(detail.activity); if (detail.sessions) setBaseSessions(detail.sessions); - if (detail.rawSessions) setTaskSessionsForTask(id, detail.rawSessions); + if (detail.rawSessions) setTaskSessionsForTaskInCache(qc, id, detail.rawSessions); if (detail.comments) setComments(detail.comments); }, - [id, setTaskSessionsForTask], + [id, qc], ); useEffect(() => { @@ -340,7 +354,7 @@ function useIssueData(id: string) { async function load() { setLoading(true); setError(null); - const fromStore = storeIssuesRef.current.find((i) => i.id === id); + const fromStore = (cachedTasksRef.current ?? []).find((i) => i.id === id); if (fromStore && !cancelled) setTask(mapOfficeTaskToTask(fromStore)); try { @@ -393,8 +407,21 @@ function useIssueData(id: string) { [baseSessions, sessionStoreStates], ); - // Refetch comments when a new comment is created via office WS event - useOfficeRefetch("comments", fetchComments); + // Refetch comments for this task when a comment is created OR when a run + // for this task is queued/processed (TQ-native replacement for the Zustand + // `comments:` refetch trigger). A queued/processed run flips the + // user-comment run badge (absent → Queued, then claimed → finished/failed/ + // cancelled), and that lifecycle rides on the comment's `runStatus`, so the + // comments list must refetch on these run events too — otherwise the badge + // only updates on a page reload. The old `office.run.{queued,processed}` + // Zustand handlers fired `comments:`; mirror that here. + useOfficeWsEffect( + ["office.comment.created", "office.run.queued", "office.run.processed"], + (message) => { + const eventTaskId = message.payload.task_id; + if (!eventTaskId || eventTaskId === id) void fetchComments(); + }, + ); const { applyTaskPatch, restoreTask } = useTaskOptimisticHelpers(id, setTask, setTimeline); diff --git a/apps/web/app/office/tasks/[id]/use-advanced-session.ts b/apps/web/app/office/tasks/[id]/use-advanced-session.ts index d2170363a..0630bc4c5 100644 --- a/apps/web/app/office/tasks/[id]/use-advanced-session.ts +++ b/apps/web/app/office/tasks/[id]/use-advanced-session.ts @@ -4,26 +4,25 @@ import { useMemo } from "react"; import { useAppStore } from "@/components/state-provider"; import { useSession } from "@/hooks/domains/session/use-session"; import { useTaskFocus } from "@/hooks/domains/session/use-task-focus"; -import type { TaskSession, TaskSessionState } from "@/lib/types/http"; - -const EMPTY_SESSIONS: TaskSession[] = []; +import { + useTaskSessionById, + useTaskSessionsByTask, +} from "@/hooks/domains/session/use-task-session-by-id"; +import type { TaskSessionState } from "@/lib/types/http"; const TERMINAL_STATES = new Set(["COMPLETED", "FAILED", "CANCELLED"]); /** * Resolves the active ACP session for a task in the office advanced view. - * Reads from the global store (populated by WS handlers) and subscribes to + * Reads the per-task session list + the active session from the TQ cache + * (populated by the session-state bridge + the by-task fetch) and subscribes to * real-time updates via useSession + useTaskFocus. */ export function useAdvancedSession(taskId: string) { - const sessionsForTask = useAppStore((state) => - taskId ? (state.taskSessionsByTask.itemsByTaskId[taskId] ?? EMPTY_SESSIONS) : EMPTY_SESSIONS, - ); + const { sessions: sessionsForTask } = useTaskSessionsByTask(taskId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); - const activeSession = useAppStore((state) => - activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null, - ); + const activeSession = useTaskSessionById(activeSessionId); // Prefer the globally active session if it belongs to this task, otherwise // pick the newest non-terminal session for the task. diff --git a/apps/web/app/office/tasks/page.tsx b/apps/web/app/office/tasks/page.tsx index fe0ddcb49..e90c95d0f 100644 --- a/apps/web/app/office/tasks/page.tsx +++ b/apps/web/app/office/tasks/page.tsx @@ -1,18 +1,5 @@ -import { listTasks } from "@/lib/api/domains/office-api"; -import { getActiveWorkspaceId } from "../lib/get-active-workspace"; import { TasksPageClient } from "./tasks-page-client"; -import type { OfficeTask } from "@/lib/state/slices/office/types"; -export default async function TasksPage() { - const workspaceId = await getActiveWorkspaceId(); - - let tasks: OfficeTask[] = []; - if (workspaceId) { - const res = await listTasks(workspaceId, { cache: "no-store" }).catch(() => ({ - tasks: [], - })); - tasks = res.tasks ?? []; - } - - return ; +export default function TasksPage() { + return ; } diff --git a/apps/web/app/office/tasks/task-board.tsx b/apps/web/app/office/tasks/task-board.tsx index 3a2650000..44babeba6 100644 --- a/apps/web/app/office/tasks/task-board.tsx +++ b/apps/web/app/office/tasks/task-board.tsx @@ -1,8 +1,9 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { useRouter } from "next/navigation"; import { ScrollArea } from "@kandev/ui/scroll-area"; -import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { OfficeTask, OfficeTaskStatus } from "@/lib/state/slices/office/types"; import { StatusIcon } from "./status-icon"; @@ -68,7 +69,7 @@ function BoardColumn({ } export function TaskBoard({ tasks }: TaskBoardProps) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const columns = meta ? meta.statuses.map((s) => ({ status: s.id as OfficeTaskStatus, label: s.label })) : FALLBACK_COLUMNS; diff --git a/apps/web/app/office/tasks/task-filters.tsx b/apps/web/app/office/tasks/task-filters.tsx index 441cf99d7..5de0dcf9d 100644 --- a/apps/web/app/office/tasks/task-filters.tsx +++ b/apps/web/app/office/tasks/task-filters.tsx @@ -1,12 +1,13 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { IconFilter } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Checkbox } from "@kandev/ui/checkbox"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Separator } from "@kandev/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { TaskFilterState, OfficeTaskStatus, @@ -42,7 +43,7 @@ function toggleInArray(arr: T[], value: T): T[] { } export function TaskFilters({ filters, onFilterChange }: IssueFiltersProps) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const STATUSES = meta ? meta.statuses.map((s) => ({ value: s.id as OfficeTaskStatus, label: s.label })) : FALLBACK_STATUSES; diff --git a/apps/web/app/office/tasks/task-row.tsx b/apps/web/app/office/tasks/task-row.tsx index 1c27b8095..8fb433f66 100644 --- a/apps/web/app/office/tasks/task-row.tsx +++ b/apps/web/app/office/tasks/task-row.tsx @@ -4,8 +4,8 @@ import { useRouter } from "next/navigation"; import { IconChevronRight, IconLoader2 } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { formatRelativeTime } from "@/lib/utils"; -import { useAppStore } from "@/components/state-provider"; import { selectLiveSessionForTask } from "@/lib/state/slices/session/selectors"; +import { useAllTaskSessions } from "@/hooks/domains/session/use-task-session-by-id"; import type { OfficeTask } from "@/lib/state/slices/office/types"; import { StatusIcon } from "./status-icon"; import { ExecutionIndicator } from "../components/execution-indicator"; @@ -31,7 +31,7 @@ export function TaskRow({ // Show an animated yellow spinner instead of the static status icon // while any session for this task is RUNNING. Drives the "this task // is being worked on right now" affordance in the task list. - const isRunning = useAppStore((s) => selectLiveSessionForTask(s, task.id) !== null); + const isRunning = selectLiveSessionForTask(useAllTaskSessions(), task.id) !== null; const handleClick = () => { router.push(`/office/tasks/${task.id}`); diff --git a/apps/web/app/office/tasks/tasks-list.tsx b/apps/web/app/office/tasks/tasks-list.tsx index 9d2f22b70..813ec08ac 100644 --- a/apps/web/app/office/tasks/tasks-list.tsx +++ b/apps/web/app/office/tasks/tasks-list.tsx @@ -1,9 +1,23 @@ "use client"; -import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { + type Dispatch, + type SetStateAction, + useCallback, + useState, + useSyncExternalStore, +} from "react"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; +import type { + TaskFilterState, + TaskSortField, + TaskSortDir, + TaskGroupBy, + OfficeTask, +} from "@/lib/state/slices/office/types"; import { NewTaskDialog } from "../components/new-task-dialog"; import { TasksToolbar } from "./tasks-toolbar"; import { TasksContent } from "./tasks-content"; @@ -15,6 +29,14 @@ const STORAGE_KEY_PREFIX = "kandev-tasks-filters-"; const SHOW_SYSTEM_STORAGE_KEY = "kandev-tasks-show-system"; const SHOW_SYSTEM_EVENT = "kandev:tasks-show-system"; +const DEFAULT_FILTERS: TaskFilterState = { + statuses: [], + priorities: [], + assigneeIds: [], + projectIds: [], + search: "", +}; + function readShowSystemPref(): boolean { if (typeof window === "undefined") return false; try { @@ -77,79 +99,111 @@ function persistFilters(workspaceId: string | null, filters: Record s.setTaskFilters); - useEffect(() => { +function useTasksListState(workspaceId: string | null) { + // UI state lives in local React state (not Zustand) per TQ migration plan. + // Lazily seed filters from localStorage so we don't need a setState-in-effect. + const [filters, setFilters] = useState(() => { const persisted = loadPersistedFilters(workspaceId); - if (Object.keys(persisted).length > 0) setTaskFilters(persisted); - }, [workspaceId, setTaskFilters]); -} - -export function TasksList() { - const workspaceId = useAppStore((s) => s.workspaces.activeId); - const tasks = useAppStore((s) => s.office.tasks.items); - const filters = useAppStore((s) => s.office.tasks.filters); - const viewMode = useAppStore((s) => s.office.tasks.viewMode); - const sortField = useAppStore((s) => s.office.tasks.sortField); - const sortDir = useAppStore((s) => s.office.tasks.sortDir); - const groupBy = useAppStore((s) => s.office.tasks.groupBy); - const nestingEnabled = useAppStore((s) => s.office.tasks.nestingEnabled); - const isLoading = useAppStore((s) => s.office.tasks.isLoading); - const agents = useAppStore((s) => s.office.agentProfiles); - - const setTaskFilters = useAppStore((s) => s.setTaskFilters); - const setTaskViewMode = useAppStore((s) => s.setTaskViewMode); - const setTaskSortField = useAppStore((s) => s.setTaskSortField); - const setTaskSortDir = useAppStore((s) => s.setTaskSortDir); - const setTaskGroupBy = useAppStore((s) => s.setTaskGroupBy); - const toggleNesting = useAppStore((s) => s.toggleNesting); - + return Object.keys(persisted).length > 0 + ? { ...DEFAULT_FILTERS, ...persisted } + : DEFAULT_FILTERS; + }); + const [viewMode, setViewMode] = useState<"list" | "board">("list"); + const [sortField, setSortField] = useState("updated"); + const [sortDir, setSortDir] = useState("desc"); + const [groupBy, setGroupBy] = useState("none"); + const [nestingEnabled, setNestingEnabled] = useState(true); const [expandedIds, setExpandedIds] = useState>(new Set()); const [newTaskOpen, setNewTaskOpen] = useState(false); - const [showSystem, setShowSystem] = useShowSystemPref(); - const { searchResults, triggerSearch } = useServerSearch(workspaceId); + return { + filters, + setFilters, + viewMode, + setViewMode, + sortField, + setSortField, + sortDir, + setSortDir, + groupBy, + setGroupBy, + nestingEnabled, + setNestingEnabled, + expandedIds, + setExpandedIds, + newTaskOpen, + setNewTaskOpen, + }; +} - const agentMap = new Map(agents.map((a) => [a.id, a.name])); +type TasksHandlersOptions = { + workspaceId: string | null; + tasks: OfficeTask[]; + filters: TaskFilterState; + setFilters: Dispatch>; + sortField: TaskSortField; + sortDir: TaskSortDir; + nestingEnabled: boolean; + expandedIds: Set; + setExpandedIds: Dispatch>>; + triggerSearch: (search: string) => void; + searchResults: OfficeTask[] | null; +}; - useRehydratePersistedFilters(workspaceId); - const { loadMore, hasMore, isLoadingMore, refetch } = usePaginatedTasks(workspaceId, showSystem); - // WS-driven invalidation: refetch the current filter/sort/page-1 on - // task lifecycle events (task created, etc.) — moved from the page - // client so the refetch preserves the user's active filters. - useOfficeRefetch("tasks", refetch); +type TasksHandlers = { + handleFilterChange: (patch: Record) => void; + handleSearchChange: (search: string) => void; + handleToggleExpand: (id: string) => void; + flatNodes: ReturnType; +}; +function useTasksHandlers(opts: TasksHandlersOptions): TasksHandlers { + const { + workspaceId, + tasks, + filters, + setFilters, + sortField, + sortDir, + nestingEnabled, + expandedIds, + setExpandedIds, + triggerSearch, + searchResults, + } = opts; const handleFilterChange = useCallback( (patch: Record) => { - setTaskFilters(patch); - persistFilters(workspaceId, { ...filters, ...patch }); + setFilters((prev) => { + const next = { ...prev, ...patch } as TaskFilterState; + persistFilters(workspaceId, next); + return next; + }); }, - [setTaskFilters, filters, workspaceId], + [workspaceId, setFilters], ); const handleSearchChange = useCallback( (search: string) => { - setTaskFilters({ search }); + setFilters((prev) => ({ ...prev, search })); triggerSearch(search); }, - [setTaskFilters, triggerSearch], + [triggerSearch, setFilters], ); - const handleToggleExpand = useCallback((id: string) => { - setExpandedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }, []); + const handleToggleExpand = useCallback( + (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, + [setExpandedIds], + ); - const activeIssues = searchResults ?? tasks; - // Skip local search filter when using server results to avoid - // rejecting matches on description or identifier. const treeFilters = searchResults ? { ...filters, search: "" } : filters; - const flatNodes = useIssuesTree({ - tasks: activeIssues, + tasks: searchResults ?? tasks, filters: treeFilters, sortField, sortDir, @@ -157,6 +211,64 @@ export function TasksList() { expandedIds, }); + return { handleFilterChange, handleSearchChange, handleToggleExpand, flatNodes }; +} + +export function TasksList() { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + + const { + filters, + setFilters, + viewMode, + setViewMode, + sortField, + setSortField, + sortDir, + setSortDir, + groupBy, + setGroupBy, + nestingEnabled, + setNestingEnabled, + expandedIds, + setExpandedIds, + newTaskOpen, + setNewTaskOpen, + } = useTasksListState(workspaceId); + + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); + const [showSystem, setShowSystem] = useShowSystemPref(); + const { searchResults, triggerSearch } = useServerSearch(workspaceId); + const agentMap = new Map(agents.map((a) => [a.id, a.name])); + + const { tasks, isLoading, loadMore, hasMore, isLoadingMore } = usePaginatedTasks( + workspaceId, + showSystem, + filters, + sortField, + sortDir, + ); + // WS-driven invalidation flows through the office TQ bridge + // (`lib/query/bridge/office.ts`), which invalidates the paginated cache + // key on task lifecycle events so this `useInfiniteQuery` refetches. + const { handleFilterChange, handleSearchChange, handleToggleExpand, flatNodes } = + useTasksHandlers({ + workspaceId, + tasks, + filters, + setFilters, + sortField, + sortDir, + nestingEnabled, + expandedIds, + setExpandedIds, + triggerSearch, + searchResults, + }); + return (
setNestingEnabled((v) => !v)} onFilterChange={handleFilterChange} - onSortFieldChange={setTaskSortField} - onSortDirChange={setTaskSortDir} - onGroupByChange={setTaskGroupBy} + onSortFieldChange={setSortField} + onSortDirChange={setSortDir} + onGroupByChange={setGroupBy} onSearchChange={handleSearchChange} onShowSystemChange={setShowSystem} onNewIssue={() => setNewTaskOpen(true)} /> - - s.setTasks); - - // Hydrate the store from SSR so the first paint shows tasks before the - // client-side filtered fetch in TasksList resolves. TasksList owns the - // ongoing fetch / filter / pagination / WS-refetch lifecycle. - useEffect(() => { - if (initialIssues.length > 0) setTasks(initialIssues); - }, [initialIssues, setTasks]); - +/** + * Client shell for the office tasks list. `TasksList` owns the full + * fetch / filter / sort / pagination lifecycle via `usePaginatedTasks` + * (TanStack Query `useInfiniteQuery`), with WS-driven refresh flowing + * through the office bridge. No SSR Zustand seed is needed — the list + * fetches on mount and the office TQ cache is the single source of truth. + */ +export function TasksPageClient() { return ; } diff --git a/apps/web/app/office/tasks/use-paginated-tasks.ts b/apps/web/app/office/tasks/use-paginated-tasks.ts index f2a4d3823..dfca026b2 100644 --- a/apps/web/app/office/tasks/use-paginated-tasks.ts +++ b/apps/web/app/office/tasks/use-paginated-tasks.ts @@ -1,8 +1,14 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { toast } from "sonner"; -import { useAppStore } from "@/components/state-provider"; -import { listTasks, type ListTasksParams } from "@/lib/api/domains/office-extended-api"; -import type { TaskFilterState, TaskSortDir, TaskSortField } from "@/lib/state/slices/office/types"; +import type { ListTasksParams } from "@/lib/api/domains/office-extended-api"; +import { flattenTasksPaginated, officeQueryOptions } from "@/lib/query/query-options/office"; +import type { + OfficeTask, + TaskFilterState, + TaskSortDir, + TaskSortField, +} from "@/lib/state/slices/office/types"; import { canonicalStatusesToBackend } from "./normalize-status"; const DEFAULT_PAGE_LIMIT = 200; @@ -51,6 +57,8 @@ function buildParams( } export type UsePaginatedTasksResult = { + tasks: OfficeTask[]; + isLoading: boolean; loadMore: () => void; hasMore: boolean; isLoadingMore: boolean; @@ -61,117 +69,55 @@ export type UsePaginatedTasksResult = { * Owns the lifecycle of the office tasks list: server-side filter / sort / * keyset pagination via the Stream-E `/workspaces/:wsId/tasks?...` endpoint. * - * Resets the cursor and replaces the list whenever the workspace, filters - * or sort change. Exposes loadMore() to fetch the next page (appending to - * the store) and refetch() for WS-driven invalidations. + * Backed by TanStack Query's `useInfiniteQuery` against + * `officeQueryOptions.tasksPaginated`. WS-driven updates flow in via the + * office bridge (`apps/web/lib/query/bridge/office.ts`), which invalidates + * the paginated cache key on task lifecycle events so every page refetches. + * + * Accepts filters / sort as explicit params (rather than reading from + * Zustand) so callers can keep UI state in local React state per the TQ + * migration plan. */ export function usePaginatedTasks( workspaceId: string | null, includeSystem: boolean, + filters: TaskFilterState, + sortField: TaskSortField, + sortDir: TaskSortDir, ): UsePaginatedTasksResult { - const setTasks = useAppStore((s) => s.setTasks); - const appendTasks = useAppStore((s) => s.appendTasks); - const setTasksLoading = useAppStore((s) => s.setTasksLoading); - const filters = useAppStore((s) => s.office.tasks.filters); - const sortField = useAppStore((s) => s.office.tasks.sortField); - const sortDir = useAppStore((s) => s.office.tasks.sortDir); - - // Cursor + the params snapshot that produced it, kept atomically so a - // stale cursor from a previous filter set can't be used for loadMore. - const [page, setPage] = useState<{ - cursor?: string; - id?: string; - key: string; - }>({ key: "" }); - const [isLoadingMore, setIsLoadingMore] = useState(false); - - // Derive the live params + key from filter/sort state so render and - // event handlers see a consistent snapshot without ref reads. const params = useMemo( () => buildParams(filters, sortField, sortDir, DEFAULT_PAGE_LIMIT, includeSystem), [filters, sortField, sortDir, includeSystem], ); - const paramsKey = useMemo( - () => JSON.stringify(params) + ":" + (workspaceId ?? ""), - [params, workspaceId], - ); - // Mirror the latest snapshot into a ref for refetch() callers (WS - // events) so they don't pull from a stale closure. - const paramsRef = useRef(params); - useEffect(() => { - paramsRef.current = params; - }, [params]); - // Initial fetch + refetch on workspace / filter / sort change. Cursor - // reset is rolled into the same setPage call as the fetch result to - // avoid a separate setState pass inside the effect body. `params` and - // `paramsKey` derive from the dependencies, so they need not be listed. - useEffect(() => { - if (!workspaceId) return; - let cancelled = false; - setTasksLoading(true); - const key = paramsKey; - listTasks(workspaceId, params) - .then((res) => { - if (cancelled) return; - setTasks(res.tasks ?? []); - setPage({ cursor: res.next_cursor || undefined, id: res.next_id || undefined, key }); - }) - .catch((err) => { - if (!cancelled) toast.error(err instanceof Error ? err.message : "Failed to load tasks"); - }) - .finally(() => { - if (!cancelled) setTasksLoading(false); - }); - return () => { - cancelled = true; - }; - }, [workspaceId, params, paramsKey, setTasks, setTasksLoading]); + const query = useInfiniteQuery({ + ...officeQueryOptions.tasksPaginated(workspaceId ?? "", params), + enabled: !!workspaceId, + }); + + const tasks = useMemo(() => flattenTasksPaginated(query.data), [query.data]); const loadMore = useCallback(() => { - // Only paginate when the cursor matches the current params snapshot. - if (!workspaceId || !page.cursor || isLoadingMore) return; - if (page.key !== paramsKey) return; - setIsLoadingMore(true); - const next: ListTasksParams = { ...params, cursor: page.cursor, cursor_id: page.id }; - listTasks(workspaceId, next) - .then((res) => { - appendTasks(res.tasks ?? []); - setPage({ - cursor: res.next_cursor || undefined, - id: res.next_id || undefined, - key: page.key, - }); - }) - .catch((err) => { - toast.error(err instanceof Error ? err.message : "Failed to load more tasks"); - }) - .finally(() => setIsLoadingMore(false)); - }, [workspaceId, page, paramsKey, params, isLoadingMore, appendTasks]); + if (!query.hasNextPage || query.isFetchingNextPage) return; + void query.fetchNextPage().catch((err) => { + toast.error(err instanceof Error ? err.message : "Failed to load more tasks"); + }); + }, [query]); const refetch = useCallback(async () => { - if (!workspaceId) return; - // Use the latest params snapshot via ref so a stale closure from a - // long-lived WS subscription doesn't post old filters. - const liveParams = paramsRef.current; - const liveKey = JSON.stringify(liveParams) + ":" + workspaceId; try { - const res = await listTasks(workspaceId, liveParams); - setTasks(res.tasks ?? []); - setPage({ - cursor: res.next_cursor || undefined, - id: res.next_id || undefined, - key: liveKey, - }); + await query.refetch(); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to refresh tasks"); } - }, [workspaceId, setTasks]); + }, [query]); return { + tasks, + isLoading: query.isPending && !!workspaceId, loadMore, - hasMore: !!page.cursor && page.key === paramsKey, - isLoadingMore, + hasMore: !!query.hasNextPage, + isLoadingMore: query.isFetchingNextPage, refetch, }; } diff --git a/apps/web/app/office/tasks/use-tasks-tree.ts b/apps/web/app/office/tasks/use-tasks-tree.ts index 75796f91e..ef0d63054 100644 --- a/apps/web/app/office/tasks/use-tasks-tree.ts +++ b/apps/web/app/office/tasks/use-tasks-tree.ts @@ -1,5 +1,6 @@ import { useMemo } from "react"; -import { useAppStore } from "@/components/state-provider"; +import { useQuery } from "@tanstack/react-query"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { OfficeTask, OfficeTaskStatus, @@ -94,7 +95,7 @@ export type UseIssuesTreeOptions = { export function useIssuesTree(opts: UseIssuesTreeOptions): FlatTaskNode[] { const { tasks, filters, sortField, sortDir, nestingEnabled, expandedIds } = opts; - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const STATUS_ORDER = useMemo(() => { if (!meta) return FALLBACK_STATUS_ORDER; diff --git a/apps/web/app/office/workspace/activity/activity-page-client.tsx b/apps/web/app/office/workspace/activity/activity-page-client.tsx index 11fd48389..02659511d 100644 --- a/apps/web/app/office/workspace/activity/activity-page-client.tsx +++ b/apps/web/app/office/workspace/activity/activity-page-client.tsx @@ -1,35 +1,10 @@ "use client"; -import { useCallback, useEffect } from "react"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import { listActivity } from "@/lib/api/domains/office-api"; -import type { ActivityEntry } from "@/lib/state/slices/office/types"; import { ActivityFeed } from "./activity-feed"; -type ActivityPageClientProps = { - initialActivity: ActivityEntry[]; -}; - -export function ActivityPageClient({ initialActivity }: ActivityPageClientProps) { +export function ActivityPageClient() { const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); - const setActivity = useAppStore((s) => s.setActivity); - - useEffect(() => { - if (initialActivity.length > 0) { - setActivity(initialActivity); - } - }, [initialActivity, setActivity]); - - const refetchActivity = useCallback(async () => { - if (!activeWorkspaceId) return; - const res = await listActivity(activeWorkspaceId).catch(() => ({ - activity: [] as ActivityEntry[], - })); - setActivity(res.activity ?? []); - }, [activeWorkspaceId, setActivity]); - - useOfficeRefetch("activity", refetchActivity); if (!activeWorkspaceId) { return ( diff --git a/apps/web/app/office/workspace/activity/page.tsx b/apps/web/app/office/workspace/activity/page.tsx index ee38505d1..7533b2531 100644 --- a/apps/web/app/office/workspace/activity/page.tsx +++ b/apps/web/app/office/workspace/activity/page.tsx @@ -1,18 +1,5 @@ -import { listActivity } from "@/lib/api/domains/office-api"; -import { getActiveWorkspaceId } from "../../lib/get-active-workspace"; import { ActivityPageClient } from "./activity-page-client"; -import type { ActivityEntry } from "@/lib/state/slices/office/types"; -export default async function ActivityPage() { - const workspaceId = await getActiveWorkspaceId(); - - let activity: ActivityEntry[] = []; - if (workspaceId) { - const res = await listActivity(workspaceId, undefined, { cache: "no-store" }).catch(() => ({ - activity: [], - })); - activity = res.activity ?? []; - } - - return ; +export default function ActivityPage() { + return ; } diff --git a/apps/web/app/office/workspace/costs/costs-page-client.tsx b/apps/web/app/office/workspace/costs/costs-page-client.tsx index 189cbbfc1..89daebe79 100644 --- a/apps/web/app/office/workspace/costs/costs-page-client.tsx +++ b/apps/web/app/office/workspace/costs/costs-page-client.tsx @@ -1,26 +1,13 @@ "use client"; -import { useEffect } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; import { useAppStore } from "@/components/state-provider"; -import type { CostSummary } from "@/lib/state/slices/office/types"; import { CostOverview } from "./cost-overview"; import { BudgetsTab } from "./budgets-tab"; import { PageHeader } from "../../components/shared/page-header"; -type CostsPageClientProps = { - initialCostSummary: CostSummary | null; -}; - -export function CostsPageClient({ initialCostSummary }: CostsPageClientProps) { +export function CostsPageClient() { const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); - const setCostSummary = useAppStore((s) => s.setCostSummary); - - useEffect(() => { - if (initialCostSummary) { - setCostSummary(initialCostSummary); - } - }, [initialCostSummary, setCostSummary]); if (!activeWorkspaceId) { return ( diff --git a/apps/web/app/office/workspace/costs/page.tsx b/apps/web/app/office/workspace/costs/page.tsx index 9bd9364e2..3828830d1 100644 --- a/apps/web/app/office/workspace/costs/page.tsx +++ b/apps/web/app/office/workspace/costs/page.tsx @@ -1,15 +1,5 @@ -import { getCosts } from "@/lib/api/domains/office-api"; -import { getActiveWorkspaceId } from "../../lib/get-active-workspace"; import { CostsPageClient } from "./costs-page-client"; -import type { CostSummary } from "@/lib/state/slices/office/types"; -export default async function CostsPage() { - const workspaceId = await getActiveWorkspaceId(); - - let costSummary: CostSummary | null = null; - if (workspaceId) { - costSummary = await getCosts(workspaceId, { cache: "no-store" }).catch(() => null); - } - - return ; +export default function CostsPage() { + return ; } diff --git a/apps/web/app/office/workspace/org/page.tsx b/apps/web/app/office/workspace/org/page.tsx index 7ef1b5a54..1e07d8c97 100644 --- a/apps/web/app/office/workspace/org/page.tsx +++ b/apps/web/app/office/workspace/org/page.tsx @@ -1,10 +1,16 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import { OrgChartCanvas } from "./org-chart-canvas"; export default function OrgPage() { - const agents = useAppStore((s) => s.office.agentProfiles); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); return (
diff --git a/apps/web/app/office/workspace/settings/components/danger-zone-section.tsx b/apps/web/app/office/workspace/settings/components/danger-zone-section.tsx index 5b0c3f940..67d3ae406 100644 --- a/apps/web/app/office/workspace/settings/components/danger-zone-section.tsx +++ b/apps/web/app/office/workspace/settings/components/danger-zone-section.tsx @@ -15,14 +15,14 @@ import { DialogHeader, DialogTitle, } from "@kandev/ui/dialog"; +import { useQueryClient } from "@tanstack/react-query"; import { deleteWorkspace, getWorkspaceDeletionSummary, type WorkspaceDeletionSummary, } from "@/lib/api/domains/office-api"; -import type { WorkspaceState } from "@/lib/state/slices/workspace/types"; - -type Workspace = WorkspaceState["items"][number]; +import { qk } from "@/lib/query/keys"; +import type { Workspace } from "@/lib/types/http"; function SettingCard({ children }: { children: React.ReactNode }) { return
{children}
; @@ -96,15 +96,14 @@ function DeleteWorkspaceDialog({ export function DangerZoneSection({ workspace, workspaces, - setWorkspaces, setActiveWorkspace, }: { workspace: Workspace; workspaces: Workspace[]; - setWorkspaces: (items: Workspace[]) => void; setActiveWorkspace: (id: string | null) => void; }) { const router = useRouter(); + const queryClient = useQueryClient(); const [open, setOpen] = useState(false); const [confirmText, setConfirmText] = useState(""); const [summary, setSummary] = useState(null); @@ -134,7 +133,8 @@ export function DangerZoneSection({ await deleteWorkspace(workspace.id, confirmName); const remaining = workspaces.filter((item) => item.id !== workspace.id); const nextWorkspace = remaining[0] ?? null; - setWorkspaces(remaining); + // workspace.deleted WS event also updates TQ; invalidate to be safe. + await queryClient.invalidateQueries({ queryKey: qk.workspaces.all() }); setActiveWorkspace(nextWorkspace?.id ?? null); router.push(nextWorkspace ? "/office" : "/office/setup"); toast.success("Workspace deleted"); diff --git a/apps/web/app/office/workspace/settings/components/settings-content.tsx b/apps/web/app/office/workspace/settings/components/settings-content.tsx index 928907931..beb662cdb 100644 --- a/apps/web/app/office/workspace/settings/components/settings-content.tsx +++ b/apps/web/app/office/workspace/settings/components/settings-content.tsx @@ -9,7 +9,8 @@ import { Switch } from "@kandev/ui/switch"; import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; import { updateWorkspaceSettings, getWorkspaceSettings } from "@/lib/api/domains/office-api"; -import type { WorkspaceState } from "@/lib/state/slices/workspace/types"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; +import type { Workspace } from "@/lib/types/http"; import { ConfigSection } from "./config-section"; import { DangerZoneSection } from "./danger-zone-section"; import { GitSection } from "./git-section"; @@ -245,8 +246,6 @@ function RecoverySection({ ); } -type Workspace = WorkspaceState["items"][number]; - function useRecoveryState(activeWorkspace: Workspace | undefined) { const [lookbackHours, setLookbackHours] = useState(24); const [origLookbackHours, setOrigLookbackHours] = useState(24); @@ -398,10 +397,10 @@ function useSettingsState(activeWorkspace: Workspace | undefined) { } export function SettingsContent() { - const workspaces = useAppStore((s) => s.workspaces); - const setWorkspaces = useAppStore((s) => s.setWorkspaces); + const { workspaces } = useWorkspaces(); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); - const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); + const activeWorkspace = workspaces.find((w) => w.id === activeWorkspaceId); const s = useSettingsState(activeWorkspace); const initial = (s.name || "W").charAt(0).toUpperCase(); @@ -469,8 +468,7 @@ export function SettingsContent() { Danger Zone
diff --git a/apps/web/app/office/workspace/settings/export/export-preview.tsx b/apps/web/app/office/workspace/settings/export/export-preview.tsx index a9fbf4ccb..ff9e1a30f 100644 --- a/apps/web/app/office/workspace/settings/export/export-preview.tsx +++ b/apps/web/app/office/workspace/settings/export/export-preview.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { IconDownload } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import * as officeApi from "@/lib/api/domains/office-api"; import { ExportFileTree } from "./export-file-tree"; import { ExportFilePreview } from "./export-file-preview"; @@ -12,8 +13,8 @@ import type { ExportFile } from "./export-types"; export function ExportPreview() { const activeWorkspaceId = useAppStore((s) => s.workspaces?.activeId ?? ""); - const workspaces = useAppStore((s) => s.workspaces); - const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); + const { workspaces } = useWorkspaces(); + const activeWorkspace = workspaces.find((w) => w.id === activeWorkspaceId); const workspaceName = activeWorkspace?.name || "Workspace"; const [files, setFiles] = useState([]); diff --git a/apps/web/app/office/workspace/skills/create-skill-form.tsx b/apps/web/app/office/workspace/skills/create-skill-form.tsx index 20cacc976..edd2a8b3a 100644 --- a/apps/web/app/office/workspace/skills/create-skill-form.tsx +++ b/apps/web/app/office/workspace/skills/create-skill-form.tsx @@ -1,11 +1,12 @@ "use client"; import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Textarea } from "@kandev/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { SkillSourceType } from "@/lib/state/slices/office/types"; const FALLBACK_SOURCE_TYPES = [ @@ -26,7 +27,7 @@ type CreateSkillFormProps = { }; export function CreateSkillForm({ onCreate, onCancel }: CreateSkillFormProps) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); // Only show creatable (non-read-only) source types, plus exclude skills_sh from creation const sourceTypes = meta ? meta.skillSourceTypes.filter((s) => !s.readOnly).map((s) => ({ id: s.id, label: s.label })) diff --git a/apps/web/app/office/workspace/skills/page.tsx b/apps/web/app/office/workspace/skills/page.tsx index 66fa5c36b..f107d4bc8 100644 --- a/apps/web/app/office/workspace/skills/page.tsx +++ b/apps/web/app/office/workspace/skills/page.tsx @@ -1,18 +1,5 @@ -import { listSkills } from "@/lib/api/domains/office-api"; -import { getActiveWorkspaceId } from "../../lib/get-active-workspace"; import { SkillsPageClient } from "./skills-page-client"; -import type { Skill } from "@/lib/state/slices/office/types"; -export default async function SkillsPage() { - const workspaceId = await getActiveWorkspaceId(); - - let skills: Skill[] = []; - if (workspaceId) { - const res = await listSkills(workspaceId, { cache: "no-store" }).catch(() => ({ - skills: [], - })); - skills = res.skills ?? []; - } - - return ; +export default function SkillsPage() { + return ; } diff --git a/apps/web/app/office/workspace/skills/skill-detail.tsx b/apps/web/app/office/workspace/skills/skill-detail.tsx index 5bf6b2b8a..3f4fb4710 100644 --- a/apps/web/app/office/workspace/skills/skill-detail.tsx +++ b/apps/web/app/office/workspace/skills/skill-detail.tsx @@ -16,8 +16,10 @@ import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Separator } from "@kandev/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useQuery } from "@tanstack/react-query"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useAppStore } from "@/components/state-provider"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { Skill, SkillSourceType } from "@/lib/state/slices/office/types"; import { FileTree, type FileTreeNode } from "@/components/shared/file-tree"; import { ScriptEditor } from "@/components/settings/profile-edit/script-editor"; @@ -50,7 +52,7 @@ function SourceIcon({ sourceType }: { sourceType: SkillSourceType }) { } function useSkillSourceMeta(sourceType: SkillSourceType) { - const meta = useAppStore((s) => s.office.meta); + const { data: meta } = useQuery(officeQueryOptions.metaGlobal()); const metaSource = meta?.skillSourceTypes.find((s) => s.id === sourceType); return { label: metaSource?.label ?? FALLBACK_SOURCE_LABELS[sourceType] ?? sourceType, @@ -68,7 +70,11 @@ export function SkillDetail({ skill, onSave, onDelete }: SkillDetailProps) { // local edits would just get overwritten. Lock both edit and delete // for them regardless of what the source meta says. const readOnly = sourceMeta.readOnly || !!skill.isSystem; - const agents = useAppStore((s) => s.office.agentProfiles); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: agents = [] } = useQuery({ + ...officeQueryOptions.agents(workspaceId ?? ""), + enabled: !!workspaceId, + }); const usedByCount = useMemo( () => agents.filter((a) => a.desiredSkills?.includes(skill.id)).length, [agents, skill.id], @@ -120,37 +126,66 @@ export function SkillDetail({ skill, onSave, onDelete }: SkillDetailProps) { />
)} + +
+ ); +} -
-
- {activeFilePath} - {!readOnly && ( - - )} -
-
- {readOnly &&
+function SkillContentEditor({ + draft, + readOnly, + isDirty, + isSaving, + activeFilePath, + onChange, + onSave, +}: { + draft: string; + readOnly: boolean; + isDirty: boolean; + isSaving: boolean; + activeFilePath: string; + onChange: (v: string) => void; + onSave: () => void; +}) { + return ( +
+
+ {activeFilePath} + {!readOnly && ( + + )} +
+
+ {readOnly &&
); diff --git a/apps/web/app/office/workspace/skills/skills-page-client.tsx b/apps/web/app/office/workspace/skills/skills-page-client.tsx index 3a1a2a34a..cb9f0c1fc 100644 --- a/apps/web/app/office/workspace/skills/skills-page-client.tsx +++ b/apps/web/app/office/workspace/skills/skills-page-client.tsx @@ -1,10 +1,13 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { IconBoxMultiple } from "@tabler/icons-react"; import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; import * as officeApi from "@/lib/api/domains/office-api"; +import * as skillsApi from "@/lib/api/domains/office-skills-api"; +import { officeQueryOptions } from "@/lib/query/query-options/office"; import type { Skill } from "@/lib/state/slices/office/types"; import { SkillList } from "./skill-list"; import { SkillDetail } from "./skill-detail"; @@ -12,10 +15,6 @@ import { CreateSkillForm } from "./create-skill-form"; type ViewMode = "view" | "create"; -type SkillsPageClientProps = { - initialSkills: Skill[]; -}; - function useSkillActions( activeWorkspaceId: string | null, selectedId: string | null, @@ -23,16 +22,22 @@ function useSkillActions( setViewMode: (mode: ViewMode) => void, skills: Skill[], ) { - const addSkill = useAppStore((s) => s.addSkill); - const updateSkillInStore = useAppStore((s) => s.updateSkill); - const removeSkillFromStore = useAppStore((s) => s.removeSkill); + const qc = useQueryClient(); + + function invalidate() { + if (activeWorkspaceId) { + void qc.invalidateQueries({ + queryKey: ["office", activeWorkspaceId, "skills"], + }); + } + } const handleCreate = useCallback( async (data: Partial) => { if (!activeWorkspaceId) return; try { - const res = await officeApi.createSkill(activeWorkspaceId, data); - addSkill(res.skill); + const res = await skillsApi.createSkill(activeWorkspaceId, data); + invalidate(); setSelectedId(res.skill.id); setViewMode("view"); } catch (err) { @@ -44,73 +49,60 @@ function useSkillActions( } } }, - [activeWorkspaceId, addSkill, setSelectedId, setViewMode], + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeWorkspaceId, qc, setSelectedId, setViewMode], ); const handleSave = useCallback( async (id: string, patch: Partial) => { - await officeApi.updateSkill(id, patch); - updateSkillInStore(id, patch); + await skillsApi.updateSkill(id, patch); + invalidate(); }, - [updateSkillInStore], + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeWorkspaceId, qc], ); const handleDelete = useCallback( async (id: string) => { - await officeApi.deleteSkill(id); - removeSkillFromStore(id); + await skillsApi.deleteSkill(id); + invalidate(); if (selectedId === id) { const remaining = skills.filter((s) => s.id !== id); setSelectedId(remaining[0]?.id ?? null); } }, - [removeSkillFromStore, selectedId, skills, setSelectedId], + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeWorkspaceId, qc, selectedId, skills, setSelectedId], ); const handleImport = useCallback( async (source: string) => { if (!activeWorkspaceId) return; const res = await officeApi.importSkill(activeWorkspaceId, source); - for (const skill of res.skills) { - addSkill(skill); - } + invalidate(); if (res.skills.length > 0) { setSelectedId(res.skills[0].id); setViewMode("view"); } }, - [activeWorkspaceId, addSkill, setSelectedId, setViewMode], + // eslint-disable-next-line react-hooks/exhaustive-deps + [activeWorkspaceId, qc, setSelectedId, setViewMode], ); return { handleCreate, handleSave, handleDelete, handleImport }; } -export function SkillsPageClient({ initialSkills }: SkillsPageClientProps) { - const skills = useAppStore((s) => s.office.skills); - const setSkills = useAppStore((s) => s.setSkills); +export function SkillsPageClient() { + const qc = useQueryClient(); const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const { data: skills = [] } = useQuery({ + ...officeQueryOptions.skills(activeWorkspaceId ?? ""), + enabled: !!activeWorkspaceId, + }); const [selectedId, setSelectedId] = useState(null); const [viewMode, setViewMode] = useState("view"); - useEffect(() => { - if (initialSkills.length > 0) { - setSkills(initialSkills); - } - }, [initialSkills, setSkills]); - - const fetchSkills = useCallback(() => { - if (!activeWorkspaceId) return; - officeApi - .listSkills(activeWorkspaceId) - .then((res) => { - if (res.skills && res.skills.length > 0) { - setSkills(res.skills); - } - }) - .catch(() => {}); - }, [activeWorkspaceId, setSkills]); - const selectedSkill = skills.find((s) => s.id === selectedId) ?? null; const { handleCreate, handleSave, handleDelete, handleImport } = useSkillActions( activeWorkspaceId, @@ -120,6 +112,12 @@ export function SkillsPageClient({ initialSkills }: SkillsPageClientProps) { skills, ); + function handleRefresh() { + if (activeWorkspaceId) { + void qc.invalidateQueries({ queryKey: ["office", activeWorkspaceId, "skills"] }); + } + } + return (
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 56bbdcb74..1507c3e32 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,3 +1,4 @@ +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; import { PageClient } from "@/app/page-client"; import { StateHydrator } from "@/components/state-hydrator"; import { @@ -12,8 +13,16 @@ import { import { listWorkspaceTaskPRs } from "@/lib/api/domains/github-api"; import { snapshotToState } from "@/lib/ssr/mapper"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import type { UserSettingsState } from "@/lib/types/settings"; import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; -import type { AppState } from "@/lib/state/store"; +import { makeQueryClient } from "@/lib/query/client"; +import { + multiKanbanQueryOptions, + snapshotToWorkflowSnapshotData, + workflowsFromResponse, +} from "@/lib/query/query-options/kanban"; +import type { KanbanMultiData } from "@/lib/query/query-options/kanban"; +import type { SsrInitialState } from "@/lib/ssr/initial-state"; import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http"; // Server Component: runs on the server for SSR and data hydration. @@ -44,7 +53,7 @@ function mapWorkspaceItem(ws: WorkspaceItem) { function buildUserSettingsState( resp: UserSettingsResponse | null, workspaceId: string | null, -): AppState["userSettings"] { +): UserSettingsState { return { ...mapUserSettingsResponse(resp), workspaceId }; } @@ -73,7 +82,7 @@ function buildBaseState( workspaces: ListWorkspacesResponse, userSettingsResponse: UserSettingsResponse | null, activeWorkspaceId: string | null, -): Partial { +): SsrInitialState { return { workspaces: { items: workspaces.workspaces.map(mapWorkspaceItem), @@ -87,7 +96,7 @@ async function loadSnapshotState( workflowId: string, taskId: string | undefined, sessionId: string | undefined, -): Promise> { +): Promise { const [snapshot, messagesResponse] = await Promise.all([ fetchWorkflowSnapshot(workflowId, { cache: "no-store" }), taskId && sessionId @@ -98,7 +107,7 @@ async function loadSnapshotState( ).catch(() => null) : Promise.resolve(null), ]); - const state: Partial = { ...snapshotToState(snapshot) }; + const state: SsrInitialState = { ...snapshotToState(snapshot) }; if (sessionId && messagesResponse) { const messages = [...(messagesResponse.messages ?? [])].reverse(); @@ -116,6 +125,47 @@ async function loadSnapshotState( return state; } +/** + * Prefetch all workflow snapshots for the workspace into a per-request + * TanStack Query client, then return the dehydrated state. + * + * The QueryProvider in app/layout.tsx already mounts a HydrationBoundary; + * we wrap the page output in a second HydrationBoundary so the SSR data + * seeds the browser cache before first render. + * + * Re-uses the same snapshot fetches already in-flight for the Zustand + * hydration path to avoid redundant network calls. + */ +async function prefetchKanbanMulti( + workspaceId: string, + workflowList: Awaited>, +): Promise<{ + qc: ReturnType; + dehydratedState: ReturnType; +}> { + const qc = makeQueryClient(); + + const entries = await Promise.all( + workflowList.workflows.map(async (wf) => { + try { + const raw = await fetchWorkflowSnapshot(wf.id, { cache: "no-store" }); + return [wf.id, snapshotToWorkflowSnapshotData(wf.id, wf.name, raw)] as const; + } catch { + return null; + } + }), + ); + + const snapshots = Object.fromEntries( + entries.filter((e): e is NonNullable => e !== null), + ); + const multiData: KanbanMultiData = { snapshots }; + + qc.setQueryData(multiKanbanQueryOptions(workspaceId).queryKey, multiData); + + return { qc, dehydratedState: dehydrate(qc) }; +} + export default async function Page({ searchParams }: PageProps) { try { const resolvedParams = searchParams ? await searchParams : {}; @@ -180,16 +230,15 @@ export default async function Page({ searchParams }: PageProps) { initialState = { ...initialState, userSettings: { - ...(initialState.userSettings as AppState["userSettings"]), + ...(initialState.userSettings as UserSettingsState), workflowId, }, workflows: { - items: workflowList.workflows.map((w) => ({ - id: w.id, - workspaceId: w.workspace_id, - name: w.name, - hidden: w.hidden, - })), + // Full server shape (via workflowsFromResponse) so the TQ + // workflows-list seed in StateHydrator isn't under-populated — a + // missing agent_profile_id silently breaks the create-task workflow + // agent lock (the seed marks the query fresh, suppressing a refetch). + items: workflowsFromResponse(workflowList), activeId: workflowId, }, repositories: { @@ -204,12 +253,17 @@ export default async function Page({ searchParams }: PageProps) { }, }; + // Prefetch all workflow snapshots into TQ SSR cache + const { dehydratedState } = await prefetchKanbanMulti(activeWorkspaceId, workflowList).catch( + () => ({ dehydratedState: dehydrate(makeQueryClient()) }), + ); + if (!workflowId) { return ( - <> + - + ); } @@ -217,10 +271,10 @@ export default async function Page({ searchParams }: PageProps) { initialState = { ...initialState, ...snapshotState }; return ( - <> + - + ); } catch { return ; diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 6d0974a4f..36d297602 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; @@ -18,8 +19,12 @@ import { buildDefaultPermissions } from "@/lib/agent-permissions"; import { seedDefaultCLIFlags } from "@/lib/cli-flags"; import { generateUUID } from "@/lib/utils"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; -import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { settingsQueryOptions } from "@/lib/query/query-options/settings"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; +import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; +import type { AgentProfileOption } from "@/lib/types/settings"; +import { toAgentProfileOption } from "@/lib/types/settings"; import { deleteAgentAction } from "@/app/actions/agents"; import { saveNewAgent, saveExistingAgent, isProfileDirty } from "./agent-save-helpers"; import type { DraftProfile, DraftAgent } from "./agent-save-helpers"; @@ -147,21 +152,15 @@ function useAgentFormState( } function useAgentStoreSync() { - const settingsAgents = useAppStore((state) => state.settingsAgents.items); - const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); - const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); + const qc = useQueryClient(); + const { data: settingsAgents = [] } = useQuery({ ...settingsQueryOptions.agents() }); const syncAgentsToStore = (nextAgents: Agent[]) => { - setSettingsAgents(nextAgents); - setAgentProfiles( + qc.setQueryData(qk.settings.agents(), nextAgents); + qc.setQueryData( + qk.settings.agentProfiles(), nextAgents.flatMap((agent) => - agent.profiles.map((profile) => ({ - id: profile.id, - label: `${profile.agentDisplayName ?? ""} • ${profile.name}`, - agent_id: agent.id, - agent_name: agent.name, - cli_passthrough: profile.cliPassthrough ?? false, - })), + agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), ), ); }; @@ -439,8 +438,8 @@ export default function AgentSetupPage() { const isCreateMode = searchParams.get("mode") === "create"; const agentKey = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId; const decodedKey = decodeURIComponent(agentKey ?? ""); - const discoveryAgents = useAppStore((state) => state.agentDiscovery.items); - const savedAgents = useAppStore((state) => state.settingsAgents.items); + const { items: discoveryAgents } = useAgentDiscovery(); + const { data: savedAgents = [] } = useQuery({ ...settingsQueryOptions.agents() }); const availableAgents = useAvailableAgents().items; const discoveryAgent = useMemo( diff --git a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts index aa1f5253a..bc46c7c76 100644 --- a/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts +++ b/apps/web/app/settings/agents/[agentId]/profiles/[profileId]/use-agent-profile-settings.ts @@ -1,10 +1,11 @@ "use client"; import { useEffect, useMemo, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; -import { useAppStore } from "@/components/state-provider"; +import { useSetAgentsAndProfiles } from "@/hooks/domains/settings/use-settings-reads"; +import { settingsQueryOptions } from "@/lib/query/query-options/settings"; import { listAgents } from "@/lib/api"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; import type { Agent, AgentProfile, @@ -26,9 +27,8 @@ export function useAgentProfileSettings( agentKey: string, profileId: string, ): AgentProfileSettingsResult { - const settingsAgents = useAppStore((state) => state.settingsAgents.items); - const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); - const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); + const { data: settingsAgents = [] } = useQuery({ ...settingsQueryOptions.agents() }); + const syncAgentsToStore = useSetAgentsAndProfiles(); const availableAgents = useAvailableAgents().items; const refreshKeyRef = useRef(null); @@ -54,12 +54,7 @@ export function useAgentProfileSettings( listAgents({ cache: "no-store" }) .then((response) => { if (cancelled) return; - setSettingsAgents(response.agents); - setAgentProfiles( - response.agents.flatMap((item) => - item.profiles.map((itemProfile) => toAgentProfileOption(item, itemProfile)), - ), - ); + syncAgentsToStore(response.agents); }) .catch(() => { refreshKeyRef.current = null; @@ -68,7 +63,7 @@ export function useAgentProfileSettings( return () => { cancelled = true; }; - }, [agentKey, profile, profileId, setAgentProfiles, setSettingsAgents]); + }, [agentKey, profile, profileId, syncAgentsToStore]); const availableAgent = useMemo(() => { return availableAgents.find((item: AvailableAgent) => item.name === agent?.name) ?? null; diff --git a/apps/web/app/settings/agents/page.tsx b/apps/web/app/settings/agents/page.tsx index c376a12dd..ccea6d1d3 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -17,7 +17,10 @@ import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; -import { useAppStore } from "@/components/state-provider"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import { settingsQueryOptions } from "@/lib/query/query-options/settings"; +import { useInstallJobsByAgent } from "@/hooks/domains/settings/use-settings-reads"; import { createCustomTUIAgent, installAgent, @@ -34,7 +37,7 @@ import { AddTUIAgentDialog } from "@/components/settings/add-tui-agent-dialog"; import { HostShellDialog } from "@/components/settings/host-shell-dialog"; import { InstallAgentCard } from "@/components/settings/install-agent-card"; import { InstalledAgentCard } from "@/components/settings/installed-agent-card"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import { toAgentProfileOption, type AgentProfileOption } from "@/lib/types/settings"; import type { AgentDiscovery, Agent, @@ -387,9 +390,26 @@ function AgentProfilesSection({ savedAgents }: AgentProfilesSectionProps) { * - Exposes handleInstall(name) which POSTs to enqueue (idempotent on the * server: clicking again while running returns the same job_id). */ +function upsertInstallJobInCache(qc: QueryClient, job: InstallJob): void { + qc.setQueryData(qk.settings.installJobs(), (prev) => { + const items = prev ?? []; + const existing = items.find((j) => j.agent_name === job.agent_name); + // Drop stale events from a previous job_id (e.g. after retry). + if ( + existing && + existing.job_id !== job.job_id && + Date.parse(existing.started_at) > Date.parse(job.started_at) + ) { + return items; + } + return [...items.filter((j) => j.agent_name !== job.agent_name), job]; + }); +} + function useInstallAgent(onSuccess: () => Promise) { - const installJobs = useAppStore((state) => state.installJobs.byAgent); - const upsertInstallJob = useAppStore((state) => state.upsertInstallJob); + const qc = useQueryClient(); + const installJobs = useInstallJobsByAgent(); + const upsertInstallJob = useCallback((job: InstallJob) => upsertInstallJobInCache(qc, job), [qc]); useEffect(() => { let cancelled = false; @@ -446,13 +466,35 @@ function useInstallAgent(onSuccess: () => Promise) { return { installJobs, handleInstall }; } +function setDiscoveryCache(qc: QueryClient, agents: AgentDiscovery[]): void { + qc.setQueryData(qk.settings.agentDiscovery(), agents); +} + +function setAvailableAgentsCache( + qc: QueryClient, + agents: AvailableAgent[], + tools: ToolStatus[], +): void { + qc.setQueryData<{ agents: AvailableAgent[]; tools: ToolStatus[] }>( + qk.settings.availableAgents(), + { agents, tools }, + ); +} + +function setAgentsCache(qc: QueryClient, agents: Agent[]): void { + qc.setQueryData(qk.settings.agents(), agents); + qc.setQueryData( + qk.settings.agentProfiles(), + agents.flatMap((agent) => + agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), + ), + ); +} + function useAgentPageState() { + const qc = useQueryClient(); const { items: discoveryAgents, loading: discoveryLoading } = useAgentDiscovery(); - const savedAgents = useAppStore((state) => state.settingsAgents.items); - const setAgentDiscovery = useAppStore((state) => state.setAgentDiscovery); - const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); - const setAvailableAgents = useAppStore((state) => state.setAvailableAgents); - const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); + const { data: savedAgents = [] } = useQuery({ ...settingsQueryOptions.agents() }); const { items: availableAgents, tools } = useAvailableAgents(); const [rescanning, setRescanning] = useState(false); const [tuiDialogOpen, setTuiDialogOpen] = useState(false); @@ -484,8 +526,8 @@ function useAgentPageState() { listAgentDiscovery({ cache: "no-store" }), listAvailableAgents({ cache: "no-store" }), ]); - setAgentDiscovery(discoveryResp.agents); - setAvailableAgents(availableResp.agents, availableResp.tools ?? []); + setDiscoveryCache(qc, discoveryResp.agents); + setAvailableAgentsCache(qc, availableResp.agents, availableResp.tools ?? []); } finally { setRescanning(false); } @@ -504,14 +546,9 @@ function useAgentPageState() { listAgents({ cache: "no-store" }), listAvailableAgents({ cache: "no-store" }), ]); - setAgentDiscovery(discoveryResp.agents); - setSettingsAgents(agentsResp.agents); - setAgentProfiles( - agentsResp.agents.flatMap((agent) => - agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), - ), - ); - setAvailableAgents(availableResp.agents, availableResp.tools ?? []); + setDiscoveryCache(qc, discoveryResp.agents); + setAgentsCache(qc, agentsResp.agents); + setAvailableAgentsCache(qc, availableResp.agents, availableResp.tools ?? []); }; return { diff --git a/apps/web/app/settings/executor/[id]/page.tsx b/apps/web/app/settings/executor/[id]/page.tsx index 7e264ebfc..233da801b 100644 --- a/apps/web/app/settings/executor/[id]/page.tsx +++ b/apps/web/app/settings/executor/[id]/page.tsx @@ -19,7 +19,7 @@ import { } from "@kandev/ui/dialog"; import { updateExecutorAction, deleteExecutorAction } from "@/app/actions/executors"; import { getWebSocketClient } from "@/lib/ws/connection"; -import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useSetExecutors } from "@/hooks/domains/settings/use-settings-reads"; import { ExecutorProfilesCard } from "@/components/settings/executor-profiles-card"; import type { Executor, ExecutorType } from "@/lib/types/http"; import { EXECUTOR_ICON_MAP } from "@/lib/executor-icons"; @@ -29,9 +29,7 @@ const EXECUTORS_ROUTE = "/settings/executors"; export default function ExecutorEditPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); const router = useRouter(); - const executor = useAppStore( - (state) => state.executors.items.find((item: Executor) => item.id === id) ?? null, - ); + const executor = useExecutors().find((item: Executor) => item.id === id) ?? null; if (!executor) { return ( @@ -187,8 +185,8 @@ function validateMcpPolicy(value: string | undefined): string | null { function DeleteExecutorSection({ executor }: { executor: Executor }) { const router = useRouter(); - const executors = useAppStore((state) => state.executors.items); - const setExecutors = useAppStore((state) => state.setExecutors); + const executors = useExecutors(); + const setExecutors = useSetExecutors(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [isDeleting, setIsDeleting] = useState(false); @@ -274,8 +272,8 @@ function DeleteExecutorSection({ executor }: { executor: Executor }) { function ExecutorEditForm({ executor }: { executor: Executor }) { const router = useRouter(); - const executors = useAppStore((state) => state.executors.items); - const setExecutors = useAppStore((state) => state.setExecutors); + const executors = useExecutors(); + const setExecutors = useSetExecutors(); const [mcpPolicy, setMcpPolicy] = useState(executor.config?.mcp_policy ?? ""); const [savedMcpPolicy, setSavedMcpPolicy] = useState(executor.config?.mcp_policy ?? ""); const [isSaving, setIsSaving] = useState(false); diff --git a/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx b/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx index 4e9fd1d93..044497962 100644 --- a/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx +++ b/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx @@ -17,7 +17,7 @@ import { DialogTitle, } from "@kandev/ui/dialog"; import { IconPlus, IconTrash } from "@tabler/icons-react"; -import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useSetExecutors } from "@/hooks/domains/settings/use-settings-reads"; import { RequestIndicator } from "@/components/request-indicator"; import { useToast } from "@/components/toast-provider"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; @@ -74,9 +74,7 @@ export default function ProfileDetailPage({ }) { const { id: executorId, profileId } = use(params); const router = useRouter(); - const executor = useAppStore( - (state) => state.executors.items.find((e: Executor) => e.id === executorId) ?? null, - ); + const executor = useExecutors().find((e: Executor) => e.id === executorId) ?? null; const profile = executor?.profiles?.find((p: ExecutorProfile) => p.id === profileId) ?? null; if (!executor || !profile) { @@ -322,8 +320,8 @@ function DeleteProfileDialog({ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { const router = useRouter(); const { toast } = useToast(); - const executors = useAppStore((state) => state.executors.items); - const setExecutors = useAppStore((state) => state.setExecutors); + const executors = useExecutors(); + const setExecutors = useSetExecutors(); const [saveStatus, setSaveStatus] = useState("idle"); const [error, setError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); diff --git a/apps/web/app/settings/executor/new/page.tsx b/apps/web/app/settings/executor/new/page.tsx index a933e4e08..e3454cc58 100644 --- a/apps/web/app/settings/executor/new/page.tsx +++ b/apps/web/app/settings/executor/new/page.tsx @@ -11,7 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Separator } from "@kandev/ui/separator"; import { createExecutorAction } from "@/app/actions/executors"; import { getWebSocketClient } from "@/lib/ws/connection"; -import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useSetExecutors } from "@/hooks/domains/settings/use-settings-reads"; import type { Executor } from "@/lib/types/http"; const EXECUTOR_TYPES = ["local_docker", "remote_docker"] as const; @@ -217,8 +217,8 @@ function ExecutorCreatePageContent() { const [dockerCertPath, setDockerCertPath] = useState(""); const [gitToken, setGitToken] = useState(""); const [isCreating, setIsCreating] = useState(false); - const executors = useAppStore((state) => state.executors.items); - const setExecutors = useAppStore((state) => state.setExecutors); + const executors = useExecutors(); + const setExecutors = useSetExecutors(); const handleTypeChange = (value: ExecutorType) => { setType(value); diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index 6f4fc56f3..521e062ff 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -4,7 +4,7 @@ import { use, useCallback, useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; -import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useSetExecutors } from "@/hooks/domains/settings/use-settings-reads"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; import { updateExecutorProfile, @@ -50,11 +50,8 @@ import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; const EXECUTORS_ROUTE = "/settings/executors"; const SPRITES_TOKEN_KEY = "SPRITES_API_TOKEN"; function useProfileFromStore(profileId: string) { - const executor = useAppStore( - (state) => - state.executors.items.find((e: Executor) => e.profiles?.some((p) => p.id === profileId)) ?? - null, - ); + const executor = + useExecutors().find((e: Executor) => e.profiles?.some((p) => p.id === profileId)) ?? null; const profile = executor?.profiles?.find((p: ExecutorProfile) => p.id === profileId) ?? null; return executor && profile ? { executor, profile } : null; } @@ -208,8 +205,8 @@ export default function ProfileEditPage({ params }: { params: Promise<{ profileI function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { const router = useRouter(); const { toast } = useToast(); - const executors = useAppStore((state) => state.executors.items); - const setExecutors = useAppStore((state) => state.setExecutors); + const executors = useExecutors(); + const setExecutors = useSetExecutors(); const [saveStatus, setSaveStatus] = useState("idle"); const [error, setError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); diff --git a/apps/web/app/settings/executors/new/[type]/page.tsx b/apps/web/app/settings/executors/new/[type]/page.tsx index a8712fadb..67bd3590a 100644 --- a/apps/web/app/settings/executors/new/[type]/page.tsx +++ b/apps/web/app/settings/executors/new/[type]/page.tsx @@ -7,7 +7,7 @@ import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useSetExecutors } from "@/hooks/domains/settings/use-settings-reads"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; import { createExecutorProfile, @@ -418,8 +418,8 @@ function useCreateProfileSave( executorId: string, ) { const router = useRouter(); - const executors = useAppStore((state) => state.executors.items); - const setExecutors = useAppStore((state) => state.setExecutors); + const executors = useExecutors(); + const setExecutors = useSetExecutors(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); diff --git a/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx b/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx index 5b457ed19..e2a332d96 100644 --- a/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx +++ b/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx @@ -6,7 +6,8 @@ import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Separator } from "@kandev/ui/separator"; import { IconTerminal2 } from "@tabler/icons-react"; -import { useAppStoreApi } from "@/components/state-provider"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; import { createExecutor, createExecutorProfile } from "@/lib/api/domains/settings-api"; import { SSHConnectionCard } from "@/components/settings/ssh-connection-card"; import type { SSHExecutorConfig } from "@/components/settings/ssh-connection-card"; @@ -30,7 +31,7 @@ const EXECUTORS_ROUTE = "/settings/executors"; */ export function SSHCreatePage() { const router = useRouter(); - const store = useAppStoreApi(); + const qc = useQueryClient(); const handleSave = useCallback( async (cfg: SSHExecutorConfig) => { @@ -63,12 +64,12 @@ export function SSHCreatePage() { // createExecutor was in-flight doesn't get overwritten. Dedupe // on id so a WS event that already inserted this executor doesn't // double-list it after our append. - const current = store.getState().executors.items; + const current = qc.getQueryData(qk.settings.executors()) ?? []; const merged = current.some((e) => e.id === next.id) ? current : [...current, next]; - store.getState().setExecutors(merged); + qc.setQueryData(qk.settings.executors(), merged); router.push(`/settings/executors/${profile.id}`); }, - [router, store], + [router, qc], ); return ( diff --git a/apps/web/app/settings/executors/page.tsx b/apps/web/app/settings/executors/page.tsx index 047dfdbfe..0364c28f1 100644 --- a/apps/web/app/settings/executors/page.tsx +++ b/apps/web/app/settings/executors/page.tsx @@ -15,7 +15,7 @@ import { DialogHeader, DialogTitle, } from "@kandev/ui/dialog"; -import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useSetExecutors } from "@/hooks/domains/settings/use-settings-reads"; import { deleteExecutorProfile } from "@/lib/api/domains/settings-api"; import { EXECUTOR_ICON_MAP, getExecutorLabel } from "@/lib/executor-icons"; import type { Executor, ExecutorProfile } from "@/lib/types/http"; @@ -27,7 +27,7 @@ type ProfileWithExecutor = ExecutorProfile & { }; function useAllProfiles(): ProfileWithExecutor[] { - const executors = useAppStore((state) => state.executors.items); + const executors = useExecutors(); return useMemo( () => executors.flatMap((e: Executor) => @@ -177,8 +177,8 @@ function DeleteProfileDialog({ export default function ExecutorsHubPage() { const router = useRouter(); const allProfiles = useAllProfiles(); - const executors = useAppStore((state) => state.executors.items); - const setExecutors = useAppStore((state) => state.setExecutors); + const executors = useExecutors(); + const setExecutors = useSetExecutors(); const [deleteProfileId, setDeleteProfileId] = useState(null); const [deleting, setDeleting] = useState(false); diff --git a/apps/web/app/settings/executors/ssh/[executorId]/page.tsx b/apps/web/app/settings/executors/ssh/[executorId]/page.tsx index 74847873a..47ff807ae 100644 --- a/apps/web/app/settings/executors/ssh/[executorId]/page.tsx +++ b/apps/web/app/settings/executors/ssh/[executorId]/page.tsx @@ -7,7 +7,8 @@ import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Separator } from "@kandev/ui/separator"; import { IconTerminal2 } from "@tabler/icons-react"; -import { useAppStoreApi } from "@/components/state-provider"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; import { fetchExecutor, listExecutors, updateExecutor } from "@/lib/api/domains/settings-api"; import { SSHConnectionCard } from "@/components/settings/ssh-connection-card"; import type { SSHExecutorConfig } from "@/components/settings/ssh-connection-card"; @@ -168,32 +169,31 @@ function useRunningSessionCount(executorId: string): number { } function useSaveExecutor(executorId: string, onSaved: () => void | Promise) { - const store = useAppStoreApi(); + const qc = useQueryClient(); return useCallback( async (cfg: SSHExecutorConfig) => { const config = buildSSHExecutorConfig(cfg); await updateExecutor(executorId, { name: cfg.name, config }); - // Refresh the store so the executor list reflects the new name + config. + // Refresh the TQ cache so the executor list reflects the new name + config. try { const fresh = await listExecutors(); - store.getState().setExecutors(fresh.executors); + qc.setQueryData(qk.settings.executors(), fresh.executors); } catch { // Non-fatal: the local view still reloads via onSaved(). Read the // current snapshot at write time so a WS event that updated the // executor list mid-flight doesn't get overwritten with a stale // captured copy. - const current = store.getState().executors.items; - store - .getState() - .setExecutors( - current.map((e: Executor) => - e.id === executorId ? { ...e, name: cfg.name, config } : e, - ), - ); + const current = qc.getQueryData(qk.settings.executors()) ?? []; + qc.setQueryData( + qk.settings.executors(), + current.map((e: Executor) => + e.id === executorId ? { ...e, name: cfg.name, config } : e, + ), + ); } await onSaved(); }, - [executorId, store, onSaved], + [executorId, qc, onSaved], ); } diff --git a/apps/web/app/settings/integrations/github/page.tsx b/apps/web/app/settings/integrations/github/page.tsx index 01af5b9fb..b0568fb5d 100644 --- a/apps/web/app/settings/integrations/github/page.tsx +++ b/apps/web/app/settings/integrations/github/page.tsx @@ -1,21 +1,12 @@ import { GitHubIntegrationPage } from "@/components/github/github-settings"; -import { StateHydrator } from "@/components/state-hydrator"; +import { GitHubStatusSeed } from "@/components/github/github-status-seed"; import { fetchGitHubStatus } from "@/lib/api/domains/github-api"; export default async function IntegrationsGitHubPage() { const status = await fetchGitHubStatus({ cache: "no-store" }).catch(() => null); - const initialState = status - ? { - githubStatus: { - status, - loaded: true, - loading: false, - }, - } - : {}; return ( <> - + ); diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index c2fd5ccd9..52a040a45 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -7,7 +7,7 @@ import { listExecutors, listWorkspaces, } from "@/lib/api"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import { toAgentProfileOption } from "@/lib/types/settings"; export default function SettingsLayout({ children }: { children: React.ReactNode }) { return {children}; @@ -51,18 +51,10 @@ async function SettingsLayoutServer({ children }: { children: React.ReactNode }) }, agentDiscovery: { items: discovery.agents, - loading: false, - loaded: true, }, availableAgents: { items: available.agents, tools: available.tools ?? [], - loading: false, - loaded: true, - }, - settingsData: { - executorsLoaded: true, - agentsLoaded: true, }, }; } catch { diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index 58b40c8f1..00c2fb1eb 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -18,14 +18,15 @@ import { DialogHeader, DialogTitle, } from "@kandev/ui/dialog"; +import { useQueryClient } from "@tanstack/react-query"; import { updateWorkspaceAction, deleteWorkspaceAction } from "@/app/actions/workspaces"; -import type { Executor } from "@/lib/types/http"; -import type { AgentProfileOption, WorkspaceState } from "@/lib/state/slices"; - -type Workspace = WorkspaceState["items"][number]; +import type { Executor, Workspace } from "@/lib/types/http"; +import type { AgentProfileOption } from "@/lib/types/settings"; import { useRequest } from "@/lib/http/use-request"; import { useToast } from "@/components/toast-provider"; -import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useAgentProfiles } from "@/hooks/domains/settings/use-settings-reads"; +import { useWorkspace } from "@/hooks/domains/workspace/use-workspaces"; +import { qk } from "@/lib/query/keys"; import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator"; type WorkspaceEditClientProps = { @@ -33,9 +34,7 @@ type WorkspaceEditClientProps = { }; export function WorkspaceEditClient({ workspaceId }: WorkspaceEditClientProps) { - const workspace = useAppStore( - (state) => state.workspaces.items.find((item: Workspace) => item.id === workspaceId) ?? null, - ); + const workspace = useWorkspace(workspaceId); if (!workspace) { return ( @@ -336,8 +335,7 @@ type WorkspaceSaveHandlerOptions = { isDirty: boolean; setSavedState: (s: SavedState) => void; setCurrentWorkspace: (fn: (prev: Workspace) => Workspace) => void; - workspaces: Workspace[]; - setWorkspaces: (items: Workspace[]) => void; + invalidateWorkspaces: () => Promise; saveWorkspaceRequest: SaveRequestLike; toast: ReturnType["toast"]; }; @@ -349,8 +347,7 @@ function buildSaveHandler({ isDirty, setSavedState, setCurrentWorkspace, - workspaces, - setWorkspaces, + invalidateWorkspaces, saveWorkspaceRequest, toast, }: WorkspaceSaveHandlerOptions) { @@ -372,19 +369,9 @@ function buildSaveHandler({ executorId: updated.default_executor_id ?? "", agentProfileId: updated.default_agent_profile_id ?? "", }); - setWorkspaces( - workspaces.map((ws: Workspace) => - ws.id === updated.id - ? { - ...ws, - name: updated.name, - default_executor_id: updated.default_executor_id ?? null, - default_environment_id: updated.default_environment_id ?? null, - default_agent_profile_id: updated.default_agent_profile_id ?? null, - } - : ws, - ), - ); + // The workspace.updated WS event also writes the TQ cache; invalidate + // here so the list reflects the change immediately. + await invalidateWorkspaces(); } catch (error) { toast({ title: "Failed to save workspace", @@ -412,10 +399,11 @@ function useWorkspaceEditForm(workspace: Workspace) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(""); - const executors = useAppStore((state) => state.executors.items); - const agentProfiles = useAppStore((state) => state.agentProfiles.items); - const workspaces = useAppStore((state) => state.workspaces.items); - const setWorkspaces = useAppStore((state) => state.setWorkspaces); + const executors = useExecutors(); + const agentProfiles = useAgentProfiles(); + const queryClient = useQueryClient(); + const invalidateWorkspaces = () => + queryClient.invalidateQueries({ queryKey: qk.workspaces.all() }); const saveWorkspaceRequest = useRequest(updateWorkspaceAction); const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction); @@ -433,8 +421,7 @@ function useWorkspaceEditForm(workspace: Workspace) { isDirty, setSavedState, setCurrentWorkspace, - workspaces, - setWorkspaces, + invalidateWorkspaces, saveWorkspaceRequest, toast, }); @@ -443,7 +430,7 @@ function useWorkspaceEditForm(workspace: Workspace) { if (deleteConfirmText !== currentWorkspace.name) return; try { await deleteWorkspaceRequest.run(currentWorkspace.id, currentWorkspace.name); - setWorkspaces(workspaces.filter((ws: Workspace) => ws.id !== currentWorkspace.id)); + await invalidateWorkspaces(); router.push("/settings/workspace"); } catch (error) { toast({ diff --git a/apps/web/app/settings/workspace/workspace-repositories-client.tsx b/apps/web/app/settings/workspace/workspace-repositories-client.tsx index 86a5fa9db..8f7ce1181 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -26,9 +26,10 @@ import { type RepositoryScript, type Workspace, } from "@/lib/types/http"; +import { useQueryClient } from "@tanstack/react-query"; import { useRequest } from "@/lib/http/use-request"; import { useToast } from "@/components/toast-provider"; -import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; import { DiscoverRepoDialog, type ManualValidation, @@ -396,7 +397,12 @@ function useWorkspaceRepositoriesPage( ) { const router = useRouter(); const { toast } = useToast(); - const clearRepositoryScripts = useAppStore((state) => state.clearRepositoryScripts); + const queryClient = useQueryClient(); + // Invalidate the per-repo scripts query so the next reader refetches the + // freshly-saved scripts (replaces the old Zustand clearRepositoryScripts). + const clearRepositoryScripts = (repoId: string) => { + void queryClient.invalidateQueries({ queryKey: qk.workspaces.scripts(repoId) }); + }; const [repositoryItems, setRepositoryItems] = useState(repositories); const [savedRepositoryItems, setSavedRepositoryItems] = useState(repositories); diff --git a/apps/web/app/settings/workspace/workspaces-page-client.tsx b/apps/web/app/settings/workspace/workspaces-page-client.tsx index 15b0dd4c1..4c5e727e8 100644 --- a/apps/web/app/settings/workspace/workspaces-page-client.tsx +++ b/apps/web/app/settings/workspace/workspaces-page-client.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import Link from "next/link"; +import { useQueryClient } from "@tanstack/react-query"; import { IconFolder, IconPlus, IconChevronRight } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; @@ -12,10 +13,9 @@ import { createWorkspaceAction } from "@/app/actions/workspaces"; import { useRequest } from "@/lib/http/use-request"; import { useToast } from "@/components/toast-provider"; import { RequestIndicator } from "@/components/request-indicator"; -import { useAppStore } from "@/components/state-provider"; -import type { WorkspaceState } from "@/lib/state/slices"; - -type Workspace = WorkspaceState["items"][number]; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; +import { qk } from "@/lib/query/keys"; +import type { Workspace } from "@/lib/types/http"; type AddWorkspaceFormProps = { newWorkspaceName: string; @@ -92,8 +92,8 @@ function WorkspaceListItem({ workspace }: { workspace: Workspace }) { } export function WorkspacesPageClient() { - const items = useAppStore((state) => state.workspaces.items); - const setWorkspaces = useAppStore((state) => state.setWorkspaces); + const { workspaces: items } = useWorkspaces(); + const queryClient = useQueryClient(); const [isAdding, setIsAdding] = useState(false); const [newWorkspaceName, setNewWorkspaceName] = useState(""); const createRequest = useRequest(createWorkspaceAction); @@ -103,31 +103,10 @@ export function WorkspacesPageClient() { e.preventDefault(); if (!newWorkspaceName.trim()) return; try { - const created = await createRequest.run({ name: newWorkspaceName.trim() }); - setWorkspaces([ - { - id: created.id, - name: created.name, - description: created.description ?? null, - owner_id: created.owner_id, - default_executor_id: created.default_executor_id ?? null, - default_environment_id: created.default_environment_id ?? null, - default_agent_profile_id: created.default_agent_profile_id ?? null, - created_at: created.created_at, - updated_at: created.updated_at, - }, - ...items.map((workspace: Workspace) => ({ - id: workspace.id, - name: workspace.name, - description: workspace.description ?? null, - owner_id: workspace.owner_id, - default_executor_id: workspace.default_executor_id ?? null, - default_environment_id: workspace.default_environment_id ?? null, - default_agent_profile_id: workspace.default_agent_profile_id ?? null, - created_at: workspace.created_at, - updated_at: workspace.updated_at, - })), - ]); + await createRequest.run({ name: newWorkspaceName.trim() }); + // The workspace.created WS event also writes the TQ cache; invalidate + // here so the list refreshes immediately even if the event is delayed. + await queryClient.invalidateQueries({ queryKey: qk.workspaces.all() }); setNewWorkspaceName(""); setIsAdding(false); } catch (error) { diff --git a/apps/web/app/tasks/page.tsx b/apps/web/app/tasks/page.tsx index 45c8ce2e9..e31ee0b5a 100644 --- a/apps/web/app/tasks/page.tsx +++ b/apps/web/app/tasks/page.tsx @@ -18,7 +18,7 @@ import type { Workspace, UserSettingsResponse, } from "@/lib/types/http"; -import type { AppState } from "@/lib/state/store"; +import type { SsrInitialState } from "@/lib/ssr/initial-state"; type WorkspaceData = { workflows: Workflow[]; @@ -115,7 +115,7 @@ export default async function TasksPage({ const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); - const initialState: Partial = { + const initialState: SsrInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null, diff --git a/apps/web/components/agent/cli-profile-editor.test.tsx b/apps/web/components/agent/cli-profile-editor.test.tsx index 1407839c7..9adee7c09 100644 --- a/apps/web/components/agent/cli-profile-editor.test.tsx +++ b/apps/web/components/agent/cli-profile-editor.test.tsx @@ -1,8 +1,33 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { StateProvider } from "@/components/state-provider"; import { CliProfileEditor } from "./cli-profile-editor"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; +import { createTestQueryClient } from "@/test-utils/render-with-query"; +import { qk } from "@/lib/query/keys"; + +type AvailableAgentSeed = { + items: unknown[]; + tools?: unknown[]; +}; + +function renderWithAgents( + ui: React.ReactElement, + seed: AvailableAgentSeed, + initialState: Parameters[0]["initialState"] = {}, +) { + const client = createTestQueryClient(); + client.setQueryData(qk.settings.availableAgents(), { + agents: seed.items, + tools: seed.tools ?? [], + }); + return render( + + {ui} + , + ); +} vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); @@ -66,20 +91,9 @@ const agentWithRecommendedFlag = { describe("CliProfileEditor", () => { it("renders the create form with the CLI client picker, profile name, and model selector", () => { - render( - - - , + renderWithAgents( + , + { items: [baseAvailableAgent] }, ); expect(screen.getByLabelText("Profile name")).toBeTruthy(); @@ -100,21 +114,9 @@ describe("CliProfileEditor", () => { createdAt: CREATED_AT, updatedAt: CREATED_AT, }; - render( - - - , - ); + renderWithAgents(, { + items: [baseAvailableAgent], + }); const nameInput = screen.getByLabelText("Profile name") as HTMLInputElement; expect(nameInput.value).toBe("default"); @@ -123,21 +125,9 @@ describe("CliProfileEditor", () => { it("invokes onCancel when the Cancel button is clicked", () => { const onCancel = vi.fn(); - render( - - - , - ); + renderWithAgents(, { + items: [baseAvailableAgent], + }); fireEvent.click(screen.getByText("Cancel")); expect(onCancel).toHaveBeenCalled(); @@ -146,25 +136,9 @@ describe("CliProfileEditor", () => { describe("CliProfileEditor recommended flags", () => { it("can hide passthrough while showing recommended CLI flags", () => { - render( - - - , + renderWithAgents( + , + { items: [agentWithRecommendedFlag] }, ); expect(screen.queryByText("CLI passthrough")).toBeNull(); @@ -189,20 +163,9 @@ describe("CliProfileEditor recommended flags", () => { ], }); - render( - - - , + renderWithAgents( + , + { items: [agentWithRecommendedFlag] }, ); fireEvent.click(screen.getByText("Create profile")); diff --git a/apps/web/components/agent/cli-profile-editor.tsx b/apps/web/components/agent/cli-profile-editor.tsx index 880491553..ea860fe37 100644 --- a/apps/web/components/agent/cli-profile-editor.tsx +++ b/apps/web/components/agent/cli-profile-editor.tsx @@ -1,13 +1,14 @@ "use client"; import { useCallback, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Label } from "@kandev/ui/label"; import { Input } from "@kandev/ui/input"; import { Switch } from "@kandev/ui/switch"; import { Button } from "@kandev/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { useAppStore } from "@/components/state-provider"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; +import { settingsQueryOptions } from "@/lib/query/query-options/settings"; import { ModelCombobox } from "@/components/settings/model-combobox"; import { ModeCombobox } from "@/components/settings/mode-combobox"; import { CLIFlagsField } from "@/components/settings/cli-flags-field"; @@ -105,7 +106,12 @@ export function CliProfileEditor({ allowCliPassthrough = true, }: CliProfileEditorProps) { const availableAgents = useAvailableAgents(); - const settingsAgents = useAppStore((s) => s.settingsAgents.items); + // Read the registered settings agents from TanStack Query — the Zustand + // settingsAgents.items mirror is no longer populated since the settings + // domain was migrated. `saveNewProfile` needs this list so it can attach + // the new profile to the existing agent row instead of failing to recreate + // an agent with a duplicate name. + const { data: settingsAgents = [] } = useQuery({ ...settingsQueryOptions.agents() }); const installed = useMemo( () => availableAgents.items.filter((a) => a.available), [availableAgents.items], diff --git a/apps/web/components/automations/config-section.tsx b/apps/web/components/automations/config-section.tsx index 2f8000431..862250f72 100644 --- a/apps/web/components/automations/config-section.tsx +++ b/apps/web/components/automations/config-section.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo } from "react"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useAgentProfiles } from "@/hooks/domains/settings/use-settings-reads"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; @@ -161,13 +161,12 @@ export function ConfigSection({ onExecutionModeChange, }: ConfigSectionProps) { useSettingsData(true); - useWorkflows(workspaceId, true); + const { workflows } = useWorkflows(workspaceId, true); const { repositories } = useRepositories(workspaceId, true); const discoveredRepos = useDiscoveredRepositories(workspaceId); - const workflows = useAppStore((state) => state.workflows.items); - const agentProfiles = useAppStore((state) => state.agentProfiles.items); - const executors = useAppStore((state) => state.executors.items); + const agentProfiles = useAgentProfiles(); + const executors = useExecutors(); const steps = useWorkflowSteps(workflowId); const filteredAgentProfiles = useMemo( diff --git a/apps/web/components/command-panel-footer.tsx b/apps/web/components/command-panel-footer.tsx index da32d0a9f..c0b98276f 100644 --- a/apps/web/components/command-panel-footer.tsx +++ b/apps/web/components/command-panel-footer.tsx @@ -18,9 +18,9 @@ import { Badge } from "@kandev/ui/badge"; import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types"; import { formatShortcut } from "@/lib/keyboard/utils"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; -import { useAppStore } from "@/components/state-provider"; import type { Task } from "@/lib/types/http"; import { FileIcon } from "@/components/ui/file-icon"; +import { useUserSettings } from "@/hooks/domains/settings/use-user-settings"; const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); export const MODE_COMMANDS: CommandPanelMode = "commands"; @@ -257,7 +257,7 @@ function getModeLabel(mode: CommandPanelMode, inputCommand: CommandItemType | nu } function CommandPanelFooter({ mode }: { mode: CommandPanelMode }) { - const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const keyboardShortcuts = useUserSettings().data?.keyboardShortcuts ?? {}; return (
{mode === MODE_COMMANDS && ( diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx index 668aae362..6cb0668df 100644 --- a/apps/web/components/command-panel.tsx +++ b/apps/web/components/command-panel.tsx @@ -8,6 +8,8 @@ import { SHORTCUTS } from "@/lib/keyboard/constants"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { useAppStore } from "@/components/state-provider"; +import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; +import { useActiveWorkflowSteps } from "@/hooks/domains/kanban/use-kanban-tasks"; import { listTasksByWorkspace } from "@/lib/api"; import { linkToTask } from "@/lib/links"; @@ -22,6 +24,7 @@ import { getFileResultValue, getTaskResultValue, } from "@/components/command-panel-footer"; +import { useUserSettings } from "@/hooks/domains/settings/use-user-settings"; function getFileName(filePath: string) { return filePath.split("/").pop() ?? filePath; @@ -428,11 +431,11 @@ function useCommandPanelHandlers( export function CommandPanel() { const { open, setOpen } = useCommandPanelOpen(); const commands = useCommands(); - const kanbanSteps = useAppStore((state) => state.kanban.steps); + const kanbanSteps = useActiveWorkflowSteps(); const workspaceId = useAppStore((state) => state.workspaces.activeId); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); - const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); - const repositories = workspaceId ? (reposByWorkspace[workspaceId] ?? []) : []; + // Observe-only repos for the active workspace (no fetch on command-panel open). + const { repositories } = useRepositories(workspaceId, false); const state = useCommandPanelState(); const { @@ -468,7 +471,7 @@ export function CommandPanel() { } }, [setOpen, state]); - const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const keyboardShortcuts = useUserSettings().data?.keyboardShortcuts ?? {}; const searchShortcut = getShortcut("SEARCH", keyboardShortcuts); const fileSearchShortcut = getShortcut("FILE_SEARCH", keyboardShortcuts); diff --git a/apps/web/components/config-chat/config-chat-panel.tsx b/apps/web/components/config-chat/config-chat-panel.tsx index 3388c8c20..31ff3f307 100644 --- a/apps/web/components/config-chat/config-chat-panel.tsx +++ b/apps/web/components/config-chat/config-chat-panel.tsx @@ -14,7 +14,7 @@ import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { Textarea } from "@kandev/ui/textarea"; import { QuickChatContent } from "@/components/quick-chat/quick-chat-content"; -import { useAppStore } from "@/components/state-provider"; +import { useAgentProfiles } from "@/hooks/domains/settings/use-settings-reads"; import { useConfigChat } from "./use-config-chat"; import type { ConfigChatSession } from "@/lib/state/slices/ui/types"; @@ -39,7 +39,7 @@ function SuggestionList() { } function ProfileSelector({ onSelect }: { onSelect: (id: string) => void }) { - const profiles = useAppStore((s) => s.agentProfiles.items ?? []); + const profiles = useAgentProfiles(); return (

Select an agent profile

@@ -150,7 +150,7 @@ function ConfigChatEmptyState({ isStarting: boolean; error: string | null; }) { - const profileCount = useAppStore((s) => s.agentProfiles.items?.length ?? 0); + const profileCount = useAgentProfiles().length; const [selectedProfileId, setSelectedProfileId] = useState(defaultProfileId ?? ""); const [inputValue, setInputValue] = useState(""); diff --git a/apps/web/components/config-chat/use-config-chat.ts b/apps/web/components/config-chat/use-config-chat.ts index 063f91a61..9db867abe 100644 --- a/apps/web/components/config-chat/use-config-chat.ts +++ b/apps/web/components/config-chat/use-config-chat.ts @@ -2,9 +2,13 @@ import { useCallback, useState } from "react"; import { useShallow } from "zustand/react/shallow"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAppStore } from "@/components/state-provider"; import { startConfigChat } from "@/lib/api/domains/workspace-api"; import { updateWorkspaceAction } from "@/app/actions/workspaces"; +import { useWorkspace } from "@/hooks/domains/workspace/use-workspaces"; +import { qk } from "@/lib/query/keys"; +import { mergeTaskSessionIntoCache } from "@/lib/query/cache/task-session-cache"; import { agentProfileId as toAgentProfileId, sessionId as toSessionId, @@ -28,25 +32,14 @@ function useConfigChatStore() { ); } -function useUpdateWorkspaceInStore() { - const storeApi = useAppStoreApi(); - return (workspaceId: string, updates: Record) => { - const { workspaces, setWorkspaces } = storeApi.getState(); - setWorkspaces(workspaces.items.map((w) => (w.id === workspaceId ? { ...w, ...updates } : w))); - }; -} - export function useConfigChat(workspaceId: string) { const store = useConfigChatStore(); - const storeApi = useAppStoreApi(); + const queryClient = useQueryClient(); const [isStarting, setIsStarting] = useState(false); const [error, setError] = useState(null); const [pendingPrompt, setPendingPrompt] = useState(null); - const updateWorkspaceInStore = useUpdateWorkspaceInStore(); - const workspace = useAppStore( - (s) => s.workspaces.items.find((w) => w.id === workspaceId) ?? null, - ); + const workspace = useWorkspace(workspaceId); const defaultProfileId = workspace?.default_config_agent_profile_id ?? workspace?.default_agent_profile_id ?? undefined; @@ -73,9 +66,9 @@ export function useConfigChat(workspaceId: string) { agent_profile_id: agentProfileId, }); - // Seed the task session in the main store so QuickChatContent can find it + // Seed the task session in the TQ cache so QuickChatContent can find it // immediately. The WS event will merge on top when it arrives. - storeApi.getState().setTaskSession({ + mergeTaskSessionIntoCache(queryClient, { id: toSessionId(response.session_id), task_id: toTaskId(response.task_id), state: "CREATED", @@ -96,9 +89,8 @@ export function useConfigChat(workspaceId: string) { await updateWorkspaceAction(workspaceId, { default_config_agent_profile_id: agentProfileId, }); - updateWorkspaceInStore(workspaceId, { - default_config_agent_profile_id: agentProfileId, - }); + // workspace.updated WS event also writes TQ; invalidate to be safe. + await queryClient.invalidateQueries({ queryKey: qk.workspaces.all() }); } catch { // Non-critical — don't fail the chat start for this } @@ -110,14 +102,7 @@ export function useConfigChat(workspaceId: string) { setIsStarting(false); } }, - [ - workspaceId, - isStarting, - store, - storeApi, - workspace?.default_config_agent_profile_id, - updateWorkspaceInStore, - ], + [workspaceId, isStarting, store, workspace?.default_config_agent_profile_id, queryClient], ); const close = useCallback(() => { diff --git a/apps/web/components/editors/file-actions-dropdown.tsx b/apps/web/components/editors/file-actions-dropdown.tsx index 319995b59..13fe1e4e3 100644 --- a/apps/web/components/editors/file-actions-dropdown.tsx +++ b/apps/web/components/editors/file-actions-dropdown.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useCallback } from "react"; +import { useUserSettings } from "@/hooks/domains/settings/use-user-settings"; import { IconExternalLink, IconCopy, IconFolderShare } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { @@ -15,6 +16,7 @@ import { useEditors } from "@/hooks/domains/settings/use-editors"; import { useOpenSessionInEditor } from "@/hooks/use-open-session-in-editor"; import { useOpenSessionFolder } from "@/hooks/use-open-session-folder"; import { useAppStore } from "@/components/state-provider"; +import { useTaskSessionById } from "@/hooks/domains/session/use-task-session-by-id"; import type { EditorOption } from "@/lib/types/http"; type FileActionsDropdownProps = { @@ -36,15 +38,12 @@ export function FileActionsDropdown({ }: FileActionsDropdownProps) { const storeSessionId = useAppStore((state) => state.tasks.activeSessionId); const sessionId = sessionIdProp ?? storeSessionId ?? null; - const worktreePath = useAppStore((state) => { - if (!sessionId) return undefined; - return state.taskSessions.items[sessionId]?.worktree_path; - }); + const worktreePath = useTaskSessionById(sessionId)?.worktree_path; const openEditor = useOpenSessionInEditor(sessionId); const openFolder = useOpenSessionFolder(sessionId); const { editors } = useEditors(); - const defaultEditorId = useAppStore((state) => state.userSettings.defaultEditorId); + const defaultEditorId = useUserSettings().data?.defaultEditorId ?? null; const enabledEditors = useMemo( () => diff --git a/apps/web/components/github/github-status-seed.tsx b/apps/web/components/github/github-status-seed.tsx new file mode 100644 index 000000000..77f6983b4 --- /dev/null +++ b/apps/web/components/github/github-status-seed.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useLayoutEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import type { GitHubStatusResponse } from "@/lib/types/github"; + +/** + * Seeds the GitHub status TQ cache from an SSR snapshot. This page hydrates + * Zustand (no TQ HydrationBoundary), so without seeding, `useGitHubStatus` + * mounts with an empty cache and flashes a "loading" state until the client + * refetch lands. Seed-if-absent so a live result is never clobbered. + */ +export function GitHubStatusSeed({ status }: { status: GitHubStatusResponse | null }) { + const queryClient = useQueryClient(); + useLayoutEffect(() => { + if (status && !queryClient.getQueryData(qk.github.status())) { + queryClient.setQueryData(qk.github.status(), status); + } + }, [status, queryClient]); + return null; +} diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx index 90abc4a31..443b64985 100644 --- a/apps/web/components/github/github-status.tsx +++ b/apps/web/components/github/github-status.tsx @@ -20,7 +20,7 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/ import { Input } from "@kandev/ui/input"; import { Spinner } from "@kandev/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; +import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { useToast } from "@/components/toast-provider"; import { configureGitHubToken, clearGitHubToken } from "@/lib/api/domains/github-api"; @@ -148,9 +148,8 @@ function NotConnectedView({ }) { // gh installed → CLI sign-in is the recommended path (browser-driven OAuth, // no PAT to manage). When it's missing we drop to PAT-only. - const ghInstalled = useAppStore((state) => - state.availableAgents.tools.some((t) => t.name === "gh" && t.available), - ); + const { tools } = useAvailableAgents(); + const ghInstalled = tools.some((t) => t.name === "gh" && t.available); const [tokenOpen, setTokenOpen] = useState(false); const [diagOpen, setDiagOpen] = useState(false); diff --git a/apps/web/components/github/issue-watch-dialog.tsx b/apps/web/components/github/issue-watch-dialog.tsx index ec2d148ef..69140cfc3 100644 --- a/apps/web/components/github/issue-watch-dialog.tsx +++ b/apps/web/components/github/issue-watch-dialog.tsx @@ -19,6 +19,8 @@ import { IconInfoCircle } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { CliModeIcon } from "@/components/cli-mode-icon"; import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useAgentProfiles } from "@/hooks/domains/settings/use-settings-reads"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps"; @@ -126,12 +128,10 @@ function formStateFromWatch(watch: IssueWatch): FormState { function useWatchFormData(workspaceId: string) { useSettingsData(true); - useWorkflows(workspaceId, true); - - const allWorkflows = useAppStore((state) => state.workflows.items); + const { workflows: allWorkflows } = useWorkflows(workspaceId, true); const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); - const agentProfiles = useAppStore((state) => state.agentProfiles.items); - const executors = useAppStore((state) => state.executors.items); + const agentProfiles = useAgentProfiles(); + const executors = useExecutors(); const allExecutorProfiles = useMemo( () => executors @@ -558,7 +558,7 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { workspaces } = useWorkspaces(); return ( s.workspaces.items); + const { workspaces } = useWorkspaces(); const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; if (watches.length === 0) { diff --git a/apps/web/components/github/my-github/pr-row-task-indicator.test.tsx b/apps/web/components/github/my-github/pr-row-task-indicator.test.tsx index 6e9d79b1a..7752dc4b5 100644 --- a/apps/web/components/github/my-github/pr-row-task-indicator.test.tsx +++ b/apps/web/components/github/my-github/pr-row-task-indicator.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@kandev/ui/tooltip"; vi.mock("next/navigation", () => ({ @@ -12,10 +13,13 @@ import { PRRowTaskIndicator } from "./pr-row-task-indicator"; import type { TaskPR } from "@/lib/types/github"; function renderWithStore(ui: ReactNode) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( - - {ui} - , + + + {ui} + + , ); } diff --git a/apps/web/components/github/my-github/pr-row-task-indicator.tsx b/apps/web/components/github/my-github/pr-row-task-indicator.tsx index ce43467d8..eff5fb81b 100644 --- a/apps/web/components/github/my-github/pr-row-task-indicator.tsx +++ b/apps/web/components/github/my-github/pr-row-task-indicator.tsx @@ -12,10 +12,10 @@ import { import { Badge } from "@kandev/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; +import { useKanbanSnapshots } from "@/hooks/domains/kanban/use-kanban-tasks"; import { cn } from "@/lib/utils"; import { linkToTask } from "@/lib/links"; import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; -import type { KanbanState } from "@/lib/state/slices"; import type { TaskPR } from "@/lib/types/github"; type PRRowTaskIndicatorProps = { @@ -23,18 +23,13 @@ type PRRowTaskIndicatorProps = { }; function useTaskStepTitle(workflowStepId: string | undefined): string | null { - return useAppStore((state) => { - if (!workflowStepId) return null; - const findIn = (steps: KanbanState["steps"]) => - steps.find((s) => s.id === workflowStepId)?.title ?? null; - const fromActive = findIn(state.kanban.steps); - if (fromActive) return fromActive; - for (const snap of Object.values(state.kanbanMulti.snapshots)) { - const t = findIn(snap.steps); - if (t) return t; - } - return null; - }); + const snapshots = useKanbanSnapshots(); + if (!workflowStepId) return null; + for (const snap of Object.values(snapshots)) { + const found = snap.steps.find((s) => s.id === workflowStepId); + if (found) return found.title; + } + return null; } function truncateTitle(title: string): string { diff --git a/apps/web/components/github/pr-ci-popover.tsx b/apps/web/components/github/pr-ci-popover.tsx index 22be79bd3..7a723af44 100644 --- a/apps/web/components/github/pr-ci-popover.tsx +++ b/apps/web/components/github/pr-ci-popover.tsx @@ -512,10 +512,10 @@ export function PRCIPopover({ enabled: boolean; onOpenDetailPanel?: () => void; }) { - const ghStatus = useAppStore((s) => s.githubStatus.status); + // Read from the TQ-backed hook directly — the Zustand `githubStatus.status` + // mirror is no longer populated after the wave-2 github migration. + const { status: ghStatus } = useGitHubStatus(); const authLost = ghStatus !== null && !ghStatus.authenticated; - // Trigger an initial status load from the same hook the rest of the app uses. - useGitHubStatus(); const { feedback, isFetching, lastUpdatedAt, refetch } = usePRCIPopover(pr, enabled && !authLost); const onAddAsContext = useAddCheckToContext(pr); diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx index 83fd61087..ae1cd7b30 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -16,7 +16,12 @@ import { Separator } from "@kandev/ui/separator"; import { ScrollArea } from "@kandev/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; -import { useActiveTaskPR, useTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { useQueryClient } from "@tanstack/react-query"; +import { + useActiveTaskPR, + useTaskPR, + upsertTaskPRIntoCaches, +} from "@/hooks/domains/github/use-task-pr"; import { prPanelLabel } from "@/components/github/pr-utils"; import { usePRFeedback } from "@/hooks/domains/github/use-pr-feedback"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; @@ -116,7 +121,7 @@ function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) { // Guard: never regress the store to a less-terminal state (e.g. merged → open) // because the feedback fetch may return stale data from before a backend poll update. function useSyncLivePRState(taskPR: TaskPR, feedback: PRFeedback | null) { - const setTaskPR = useAppStore((s) => s.setTaskPR); + const qc = useQueryClient(); const prState = taskPR.state; const prMergedAt = taskPR.merged_at ?? null; const prClosedAt = taskPR.closed_at ?? null; @@ -146,8 +151,9 @@ function useSyncLivePRState(taskPR: TaskPR, feedback: PRFeedback | null) { livePR.deletions !== prDeletions || effectiveMergeableState !== prMergeableState ) { - setTaskPR(prTaskId, { + upsertTaskPRIntoCaches(qc, { ...taskPR, + task_id: prTaskId, state: effectiveState as TaskPR["state"], additions: livePR.additions, deletions: livePR.deletions, @@ -166,7 +172,7 @@ function useSyncLivePRState(taskPR: TaskPR, feedback: PRFeedback | null) { prDeletions, prMergeableState, prTaskId, - setTaskPR, + qc, ]); } diff --git a/apps/web/components/github/pr-status-chip.test.tsx b/apps/web/components/github/pr-status-chip.test.tsx index 0cc041103..75cd74d05 100644 --- a/apps/web/components/github/pr-status-chip.test.tsx +++ b/apps/web/components/github/pr-status-chip.test.tsx @@ -1,13 +1,36 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; +import { createTestQueryClient } from "@/test-utils/render-with-query"; +import { qk } from "@/lib/query/keys"; import { PRStatusChip } from "./pr-status-chip"; import type { AppState } from "@/lib/state/store"; import type { TaskPR } from "@/lib/types/github"; +const WS_ID = "ws-1"; + +// PRTaskIcon/PRStatusChip read taskPRs from the TanStack Query cache for the +// active workspace, so seed that cache + the active workspace id (client-only). +function renderWithPRs(prsByTaskId: Record | undefined, ui: ReactNode) { + const queryClient = createTestQueryClient(); + if (prsByTaskId) { + queryClient.setQueryData(qk.github.prs(WS_ID), { task_prs: prsByTaskId }); + } + return render( + + }> + + {ui} + + + , + ); +} + const isMobileMock = vi.fn(() => false); vi.mock("@/hooks/use-mobile", () => ({ useIsMobile: () => isMobileMock(), @@ -26,16 +49,6 @@ vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: vi.fn(() => null), })); -function renderWithStore(initialState: Partial | undefined, ui: ReactNode) { - return render( - - - {ui} - - , - ); -} - function makePR(overrides: Partial = {}): TaskPR { return { id: "pr-id", @@ -79,29 +92,21 @@ afterEach(() => { }); const CHIP_TESTID = "pr-status-chip"; -const seededState: Partial = { - taskPRs: { byTaskId: { "task-1": [makePR()] } }, -}; +const seededPRs: Record = { "task-1": [makePR()] }; describe("PRStatusChip", () => { it("returns null when the task has no PR", () => { - renderWithStore(undefined, ); + renderWithPRs(undefined, ); expect(screen.queryByTestId(CHIP_TESTID)).toBeNull(); }); it("returns null when the PR has been merged (terminal state)", () => { - renderWithStore( - { taskPRs: { byTaskId: { "task-1": [makePR({ state: "merged" })] } } }, - , - ); + renderWithPRs({ "task-1": [makePR({ state: "merged" })] }, ); expect(screen.queryByTestId(CHIP_TESTID)).toBeNull(); }); it("returns null when the PR has been closed (terminal state)", () => { - renderWithStore( - { taskPRs: { byTaskId: { "task-1": [makePR({ state: "closed" })] } } }, - , - ); + renderWithPRs({ "task-1": [makePR({ state: "closed" })] }, ); expect(screen.queryByTestId(CHIP_TESTID)).toBeNull(); }); @@ -109,7 +114,7 @@ describe("PRStatusChip", () => { beforeEach(() => isMobileMock.mockReturnValue(false)); it("renders the chip button without a Drawer", () => { - renderWithStore(seededState, ); + renderWithPRs(seededPRs, ); const chip = screen.getByTestId(CHIP_TESTID); expect(chip).toBeTruthy(); // The chip's HoverCard popover is hover-only on desktop; clicking the @@ -121,7 +126,7 @@ describe("PRStatusChip", () => { }); it("exposes the canonical data attributes that desktop tests rely on", () => { - renderWithStore(seededState, ); + renderWithPRs(seededPRs, ); const chip = screen.getByTestId(CHIP_TESTID); expect(chip.getAttribute("data-pr-number")).toBe("42"); expect(chip.getAttribute("data-pr-state")).toBe("open"); @@ -134,7 +139,7 @@ describe("PRStatusChip", () => { beforeEach(() => isMobileMock.mockReturnValue(true)); it("renders the chip closed and opens the drawer on click", () => { - renderWithStore(seededState, ); + renderWithPRs(seededPRs, ); // Drawer must not be in the DOM before the user taps the chip — relied // on by the e2e spec's `toHaveCount(0)` precondition. expect(document.querySelector("[data-testid='pr-status-chip-drawer']")).toBeNull(); @@ -152,7 +157,7 @@ describe("PRStatusChip", () => { }); it("preserves the same data attributes as the desktop chip", () => { - renderWithStore(seededState, ); + renderWithPRs(seededPRs, ); const chip = screen.getByTestId(CHIP_TESTID); expect(chip.getAttribute("data-pr-number")).toBe("42"); expect(chip.getAttribute("data-pr-state")).toBe("open"); @@ -161,8 +166,8 @@ describe("PRStatusChip", () => { }); it("reflects a failed PR with data-status='failed'", () => { - renderWithStore( - { taskPRs: { byTaskId: { "task-1": [makePR({ checks_state: "failure" })] } } }, + renderWithPRs( + { "task-1": [makePR({ checks_state: "failure" })] }, , ); expect(screen.getByTestId(CHIP_TESTID).getAttribute("data-status")).toBe("failed"); @@ -173,28 +178,27 @@ describe("PRStatusChip", () => { // The mobile-pr-ci-chip.spec.ts e2e covers close-button dismissal in a // real browser. - it("renders the no-checks empty state in the drawer when the PR has no checks", () => { - renderWithStore( + it("renders the no-checks empty state in the drawer when the PR has no checks", async () => { + renderWithPRs( { - taskPRs: { - byTaskId: { - "task-1": [ - makePR({ - checks_state: "", - checks_total: 0, - checks_passing: 0, - review_state: "", - mergeable_state: "", - }), - ], - }, - }, + "task-1": [ + makePR({ + checks_state: "", + checks_total: 0, + checks_passing: 0, + review_state: "", + mergeable_state: "", + }), + ], }, , ); act(() => { fireEvent.click(screen.getByTestId(CHIP_TESTID)); }); + // The feedback fetch is now TanStack Query-backed; let the (mocked-null) + // request settle before asserting the empty state renders. + await screen.findByTestId("pr-checks-empty"); expect(document.querySelector("[data-testid='pr-checks-empty']")).not.toBeNull(); }); }); diff --git a/apps/web/components/github/pr-task-icon.render.test.tsx b/apps/web/components/github/pr-task-icon.render.test.tsx index 01a976a42..47d814297 100644 --- a/apps/web/components/github/pr-task-icon.render.test.tsx +++ b/apps/web/components/github/pr-task-icon.render.test.tsx @@ -1,17 +1,30 @@ import { afterEach, describe, expect, it } from "vitest"; import type { ReactNode } from "react"; import { cleanup, render } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { StateProvider } from "@/components/state-provider"; +import { createTestQueryClient } from "@/test-utils/render-with-query"; +import { qk } from "@/lib/query/keys"; import { PRTaskIcon } from "./pr-task-icon"; import type { AppState } from "@/lib/state/store"; import type { TaskPR } from "@/lib/types/github"; -function renderWithStore(initialState: Partial | undefined, ui: ReactNode) { +const WS_ID = "ws-1"; + +// PRTaskIcon reads taskPRs from the TanStack Query cache for the active +// workspace, so seed that cache + the active workspace id (client-only). +function renderWithPRs(prsByTaskId: Record | undefined, ui: ReactNode) { + const queryClient = createTestQueryClient(); + if (prsByTaskId) { + queryClient.setQueryData(qk.github.prs(WS_ID), { task_prs: prsByTaskId }); + } return render( - - {ui} - , + + }> + {ui} + + , ); } @@ -50,44 +63,37 @@ function makePR(overrides: Partial = {}): TaskPR { afterEach(() => cleanup()); -describe("PRTaskIcon corrupted store entry", () => { +describe("PRTaskIcon corrupted cache entry", () => { // Regression: an upstream payload (partial hydration, WS reorder, etc.) once - // landed in taskPRs.byTaskId["task-1"] as a non-array truthy value. The - // length-based guards then fell through into MultiPRIcon, where for-of - // threw `prs is not iterable`. PRTaskIcon must bail rather than crash. - it("renders nothing when byTaskId[taskId] is a non-array object", () => { - const { container } = renderWithStore( + // landed in task_prs["task-1"] as a non-array truthy value. The length-based + // guards then fell through into MultiPRIcon, where for-of threw `prs is not + // iterable`. PRTaskIcon must bail rather than crash. + it("renders nothing when task_prs[taskId] is a non-array object", () => { + const { container } = renderWithPRs( // eslint-disable-next-line @typescript-eslint/no-explicit-any - { taskPRs: { byTaskId: { "task-1": {} as any } } } as Partial, + { "task-1": {} as any }, , ); expect(container.firstChild).toBeNull(); }); - it("renders nothing when byTaskId[taskId] is undefined", () => { - const { container } = renderWithStore(undefined, ); + it("renders nothing when task_prs[taskId] is undefined", () => { + const { container } = renderWithPRs(undefined, ); expect(container.firstChild).toBeNull(); }); - it("renders an icon when byTaskId[taskId] is a valid array of one PR", () => { - const { container } = renderWithStore( - { taskPRs: { byTaskId: { "task-1": [makePR()] } } }, - , - ); + it("renders an icon when task_prs[taskId] is a valid array of one PR", () => { + const { container } = renderWithPRs({ "task-1": [makePR()] }, ); expect(container.querySelector('[data-testid="pr-task-icon-task-1"]')).not.toBeNull(); }); - it("renders the multi-PR icon when byTaskId[taskId] has multiple PRs", () => { - const { container } = renderWithStore( + it("renders the multi-PR icon when task_prs[taskId] has multiple PRs", () => { + const { container } = renderWithPRs( { - taskPRs: { - byTaskId: { - "task-1": [ - makePR({ id: "a", repository_id: "repo-a", pr_number: 1 }), - makePR({ id: "b", repository_id: "repo-b", pr_number: 2 }), - ], - }, - }, + "task-1": [ + makePR({ id: "a", repository_id: "repo-a", pr_number: 1 }), + makePR({ id: "b", repository_id: "repo-b", pr_number: 2 }), + ], }, , ); diff --git a/apps/web/components/github/pr-task-icon.tsx b/apps/web/components/github/pr-task-icon.tsx index efe49feaa..da8450cf4 100644 --- a/apps/web/components/github/pr-task-icon.tsx +++ b/apps/web/components/github/pr-task-icon.tsx @@ -3,7 +3,7 @@ import { IconGitPullRequest } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { cn } from "@/lib/utils"; -import { useAppStore } from "@/components/state-provider"; +import { useTaskPRs } from "@/hooks/domains/github/use-task-pr"; import type { TaskPR } from "@/lib/types/github"; const STATUS_RANK: Record = { @@ -107,7 +107,7 @@ export function aggregatePRStatusColor(prs: TaskPR[]): string { } export function PRTaskIcon({ taskId }: { taskId: string }) { - const prs = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null); + const prs = useTaskPRs(taskId); // Defensive: an upstream payload may briefly seed byTaskId[taskId] with a // non-array value (e.g. an empty object from a partial hydration). Bail diff --git a/apps/web/components/github/review-watch-dialog.tsx b/apps/web/components/github/review-watch-dialog.tsx index b78e3a079..9143715ac 100644 --- a/apps/web/components/github/review-watch-dialog.tsx +++ b/apps/web/components/github/review-watch-dialog.tsx @@ -19,6 +19,8 @@ import { IconInfoCircle } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { CliModeIcon } from "@/components/cli-mode-icon"; import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useAgentProfiles } from "@/hooks/domains/settings/use-settings-reads"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; import { @@ -181,7 +183,7 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { workspaces } = useWorkspaces(); return ( state.workflows.items); + const { workflows: allWorkflows } = useWorkflows(workspaceId, true); const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); - const agentProfiles = useAppStore((state) => state.agentProfiles.items); - const executors = useAppStore((state) => state.executors.items); + const agentProfiles = useAgentProfiles(); + const executors = useExecutors(); const allExecutorProfiles = useMemo( () => executors diff --git a/apps/web/components/github/review-watch-table.tsx b/apps/web/components/github/review-watch-table.tsx index 847bd8607..153abb643 100644 --- a/apps/web/components/github/review-watch-table.tsx +++ b/apps/web/components/github/review-watch-table.tsx @@ -6,7 +6,7 @@ import { Badge } from "@kandev/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useToast } from "@/components/toast-provider"; -import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import type { ReviewWatch } from "@/lib/types/github"; type ReviewWatchTableProps = { @@ -114,7 +114,7 @@ export function ReviewWatchTable({ onTrigger, onToggleEnabled, }: ReviewWatchTableProps) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { workspaces } = useWorkspaces(); const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; if (watches.length === 0) { diff --git a/apps/web/components/global-commands.tsx b/apps/web/components/global-commands.tsx index 4ffafb224..a383205f9 100644 --- a/apps/web/components/global-commands.tsx +++ b/apps/web/components/global-commands.tsx @@ -23,6 +23,7 @@ import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { useAppStore } from "@/components/state-provider"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import type { CommandItem } from "@/lib/commands/types"; +import { useUserSettings } from "@/hooks/domains/settings/use-user-settings"; type PushFn = ReturnType["push"]; @@ -176,7 +177,7 @@ export function GlobalCommands() { startNewConfigChat, ]); - const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const keyboardShortcuts = useUserSettings().data?.keyboardShortcuts ?? {}; const quickChatShortcut = getShortcut("QUICK_CHAT", keyboardShortcuts); const quickChatCommand: CommandItem = useMemo( diff --git a/apps/web/components/homepage-commands.tsx b/apps/web/components/homepage-commands.tsx index ff91757f8..fa3d42044 100644 --- a/apps/web/components/homepage-commands.tsx +++ b/apps/web/components/homepage-commands.tsx @@ -7,8 +7,8 @@ import { useRegisterCommands } from "@/hooks/use-register-commands"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { linkToTasks } from "@/lib/links"; import type { CommandItem } from "@/lib/commands/types"; -import { useAppStore } from "@/components/state-provider"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; +import { useUserSettings } from "@/hooks/domains/settings/use-user-settings"; type HomepageCommandsProps = { onCreateTask: () => void; @@ -17,7 +17,7 @@ type HomepageCommandsProps = { export function HomepageCommands({ onCreateTask }: HomepageCommandsProps) { const router = useRouter(); const { onViewModeChange } = useKanbanDisplaySettings(); - const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts); + const keyboardShortcuts = useUserSettings().data?.keyboardShortcuts ?? {}; const newTaskShortcut = getShortcut("NEW_TASK", keyboardShortcuts); const commands = useMemo( diff --git a/apps/web/components/improve-kandev-dialog.tsx b/apps/web/components/improve-kandev-dialog.tsx index 90551c5f0..72cd482a2 100644 --- a/apps/web/components/improve-kandev-dialog.tsx +++ b/apps/web/components/improve-kandev-dialog.tsx @@ -6,8 +6,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dia import { Button } from "@kandev/ui/button"; import { IconAlertTriangle, IconStethoscope, IconCheck } from "@tabler/icons-react"; +import { useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/components/toast-provider"; -import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; import { bootstrapImproveKandev } from "@/lib/api/domains/improve-kandev-api"; import { listRepositories } from "@/lib/api/domains/workspace-api"; import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; @@ -142,7 +143,7 @@ function useBootstrapKandev( setBootstrap: (s: BootstrapState) => void, ) { const { toast } = useToast(); - const setRepositories = useAppStore((state) => state.setRepositories); + const queryClient = useQueryClient(); useEffect(() => { if (mode !== "create" || !workspaceId) return; let cancelled = false; @@ -172,7 +173,9 @@ function useBootstrapKandev( listRepositories(workspaceId, undefined, { cache: "no-store" }), ]); if (cancelled) return; - setRepositories(workspaceId, reposRes.repositories); + // Seed the TQ repos cache so the locked repo dropdown can resolve a + // label for the freshly-bootstrapped repository_id. + queryClient.setQueryData(qk.workspaces.repos(workspaceId), reposRes); setBootstrap({ kind: "ready", data, steps: stepsRes.steps }); } catch (err) { if (cancelled) return; @@ -188,7 +191,7 @@ function useBootstrapKandev( return () => { cancelled = true; }; - }, [mode, workspaceId, setBootstrap, setRepositories, toast]); + }, [mode, workspaceId, setBootstrap, queryClient, toast]); } function IntroBody({ diff --git a/apps/web/components/integrations/workspace-scoped-section.tsx b/apps/web/components/integrations/workspace-scoped-section.tsx index dc76e794b..db3f2d0c6 100644 --- a/apps/web/components/integrations/workspace-scoped-section.tsx +++ b/apps/web/components/integrations/workspace-scoped-section.tsx @@ -3,6 +3,7 @@ import { useState, type ReactNode } from "react"; import { Card, CardContent } from "@kandev/ui/card"; import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { WorkspaceSwitcher } from "@/components/task/workspace-switcher"; type WorkspaceScopedSectionProps = { @@ -21,7 +22,7 @@ export function WorkspaceScopedSection({ emptyMessage, children, }: WorkspaceScopedSectionProps) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { workspaces } = useWorkspaces(); const activeId = useAppStore((s) => s.workspaces.activeId); const [override, setOverride] = useState(null); diff --git a/apps/web/components/jira/jira-issue-watch-dialog.tsx b/apps/web/components/jira/jira-issue-watch-dialog.tsx index ed419dca3..4d1d1e570 100644 --- a/apps/web/components/jira/jira-issue-watch-dialog.tsx +++ b/apps/web/components/jira/jira-issue-watch-dialog.tsx @@ -19,6 +19,8 @@ import { IconInfoCircle } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { CliModeIcon } from "@/components/cli-mode-icon"; import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useAgentProfiles } from "@/hooks/domains/settings/use-settings-reads"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps"; @@ -116,11 +118,10 @@ function formStateFromWatch(w: JiraIssueWatch): FormState { function useFormData(workspaceId: string) { useSettingsData(true); - useWorkflows(workspaceId, true); - const allWorkflows = useAppStore((s) => s.workflows.items); + const { workflows: allWorkflows } = useWorkflows(workspaceId, true); const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); - const agentProfiles = useAppStore((s) => s.agentProfiles.items); - const executors = useAppStore((s) => s.executors.items); + const agentProfiles = useAgentProfiles(); + const executors = useExecutors(); const allExecutorProfiles = useMemo( () => executors @@ -279,7 +280,7 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { workspaces } = useWorkspaces(); return ( s.workspaces.items); + const { workspaces } = useWorkspaces(); const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; if (watches.length === 0) { diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index ef437876a..7d7e3b001 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -20,6 +20,7 @@ import { MobileDropTargets } from "./kanban/mobile-drop-targets"; import { MobileFab } from "./kanban/mobile-fab"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAppStore } from "@/components/state-provider"; +import { useKanbanMultiSnapshots } from "@/hooks/domains/kanban/use-kanban-snapshots"; import { getKanbanColumnGridTemplate } from "./kanban/kanban-grid-template"; export type KanbanBoardGridProps = { @@ -258,11 +259,14 @@ function DesktopLayout({ function useShowLoading(isLoading: boolean | undefined, stepsLength: number) { const workflowsActiveId = useAppStore((state) => state.workflows.activeId); - const kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId); + const { snapshots } = useKanbanMultiSnapshots({ enabled: false }); + // The single-workflow snapshot is "loaded" once the active workflow appears + // in the multi cache (the SSR seed / `useAllWorkflowSnapshots` populate it). + const activeSnapshotLoaded = !!(workflowsActiveId && snapshots[workflowsActiveId]); return ( isLoading === true || - (workflowsActiveId && !kanbanWorkflowId) || + (workflowsActiveId && !activeSnapshotLoaded) || (isLoading === undefined && stepsLength === 0 && !workflowsActiveId) ); } diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 528568e85..81b1998b0 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -4,7 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Task } from "./kanban-card"; import { TaskCreateDialog } from "./task-create-dialog"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAppStore } from "@/components/state-provider"; +import { + useKanbanMultiSnapshots, + useWorkflowItems, +} from "@/hooks/domains/kanban/use-kanban-snapshots"; import type { Task as BackendTask } from "@/lib/types/http"; import type { WorkflowsState } from "@/lib/state/slices"; import { type MoveTaskError } from "@/hooks/use-drag-and-drop"; @@ -15,6 +19,7 @@ import { MobileSearchBar } from "./kanban/mobile-search-bar"; import { MobileTaskSheet } from "./kanban/mobile-task-sheet"; import { TaskMultiSelectToolbar } from "./kanban/task-multi-select-toolbar"; import { useKanbanData, useKanbanActions, useKanbanNavigation } from "@/hooks/domains/kanban"; +import { useUserSettings } from "@/hooks/domains/settings/use-user-settings"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr"; @@ -36,21 +41,17 @@ import { import { IconAlertTriangle } from "@tabler/icons-react"; function useWorkflowSelection({ - store, userSettings, workspaceState, workflowsState, commitSettings, setActiveWorkflow, - setWorkflows, }: { - store: ReturnType; userSettings: { workflowId?: string | null }; workspaceState: { activeId: string | null }; workflowsState: WorkflowsState; commitSettings: unknown; setActiveWorkflow: (id: string | null) => void; - setWorkflows: (workflows: WorkflowsState["items"]) => void; }) { const userSettingsRef = useRef(userSettings); useEffect(() => { @@ -60,10 +61,7 @@ function useWorkflowSelection({ useEffect(() => { const workspaceId = workspaceState.activeId; if (!workspaceId) { - if (workflowsState.items.length || workflowsState.activeId) { - setWorkflows([]); - setActiveWorkflow(null); - } + if (workflowsState.activeId) setActiveWorkflow(null); return; } const settings = userSettingsRef.current; @@ -77,18 +75,11 @@ function useWorkflowSelection({ workspaceWorkflows, }); setActiveWorkflow(desiredWorkflowId); - if (!desiredWorkflowId) { - store.getState().hydrate({ - kanban: { workflowId: null, steps: [], tasks: [] }, - }); - } }, [ workflowsState.activeId, workflowsState.items, commitSettings, setActiveWorkflow, - setWorkflows, - store, workspaceState.activeId, ]); } @@ -116,21 +107,21 @@ function useMoveErrorState(router: ReturnType) { } function useKanbanBoardStore() { - const store = useAppStoreApi(); - const kanbanViewMode = useAppStore((state) => state.userSettings.kanbanViewMode); - const kanban = useAppStore((state) => state.kanban); + const kanbanViewMode = useUserSettings().data?.kanbanViewMode ?? null; const workspaceState = useAppStore((state) => state.workspaces); - const workflowsState = useAppStore((state) => state.workflows); + const activeWorkflowId = useAppStore((state) => state.workflows.activeId); + const workflowItems = useWorkflowItems(workspaceState.activeId); + const workflowsState = useMemo( + () => ({ items: workflowItems, activeId: activeWorkflowId }), + [workflowItems, activeWorkflowId], + ); const setActiveWorkflow = useAppStore((state) => state.setActiveWorkflow); - const setWorkflows = useAppStore((state) => state.setWorkflows); return { - store, kanbanViewMode, - kanban, + workflowId: activeWorkflowId, workspaceState, workflowsState, setActiveWorkflow, - setWorkflows, }; } @@ -235,15 +226,8 @@ function useKanbanBoardSetup( const router = useRouter(); const { isMobile } = useResponsiveBreakpoint(); const [searchQuery, setSearchQuery] = useState(""); - const { - store, - kanbanViewMode, - kanban, - workspaceState, - workflowsState, - setActiveWorkflow, - setWorkflows, - } = useKanbanBoardStore(); + const { kanbanViewMode, workflowId, workspaceState, workflowsState, setActiveWorkflow } = + useKanbanBoardStore(); useAllWorkflowSnapshots(workspaceState.activeId); useWorkspacePRs(workspaceState.activeId); @@ -266,9 +250,9 @@ function useKanbanBoardSetup( [onBeforeEdit, handleEdit], ); - const multiSelect = useTaskMultiSelect(kanban.workflowId); + const multiSelect = useTaskMultiSelect(workflowId); const { isMultiSelectMode, toggleSelect } = multiSelect; - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); + const { snapshots } = useKanbanMultiSnapshots({ enabled: false }); const { isMixedWorkflowSelection, multiSelectSteps } = useMultiSelectDerived( multiSelect.selectedIds, snapshots, @@ -295,19 +279,17 @@ function useKanbanBoardSetup( const automation = useMoveErrorState(router); useWorkflowSelection({ - store, userSettings: hooks.userSettings, workspaceState, workflowsState, commitSettings: hooks.commitSettings, setActiveWorkflow, - setWorkflows, }); return { isMobile, kanbanViewMode, - kanban, + workflowId, workspaceState, workflowsState, searchQuery, @@ -360,7 +342,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB isDialogOpen={s.isDialogOpen} handleDialogOpenChange={s.handleDialogOpenChange} workspaceId={s.workspaceState.activeId} - workflowId={s.kanban.workflowId} + workflowId={s.workflowId} defaultStepId={s.activeSteps[0]?.id ?? null} stepOptions={stepOptions} editingTask={s.editingTask} diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index 847a434f7..22e3c6f80 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -14,7 +14,7 @@ import { KanbanCardDropdownMenuItems, type KanbanCardMenuEntry, } from "@/components/kanban-card-menu-items"; -import { useAppStore } from "@/components/state-provider"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification"; import { @@ -135,28 +135,30 @@ export function KanbanCardBody({ ); } -function KanbanCardBadges({ task }: { task: Task }) { - const parentTitle = useAppStore((s) => { - if (!task.parentTaskId) return null; - return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null; - }); - - const showRow = - (task.sessionCount && task.sessionCount > 1) || - task.reviewStatus === "changes_requested" || - task.reviewStatus === "pending" || - task.parentTaskId; +function ParentTaskBadge({ parentTaskId }: { parentTaskId: string }) { + const parentTask = useTaskById(parentTaskId); + const parentTitle = parentTask?.title ?? null; + return ( + + + {parentTitle ?? "Subtask"} + + ); +} - if (!showRow) return null; +function shouldShowBadgesRow(task: Task): boolean { + if (task.parentTaskId) return true; + if (task.sessionCount && task.sessionCount > 1) return true; + if (task.reviewStatus === "changes_requested") return true; + if (task.reviewStatus === "pending") return true; + return false; +} +function KanbanCardBadges({ task }: { task: Task }) { + if (!shouldShowBadgesRow(task)) return null; return (
- {task.parentTaskId && ( - - - {parentTitle ?? "Subtask"} - - )} + {task.parentTaskId && } {task.sessionCount && task.sessionCount > 1 && ( {task.sessionCount} sessions diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index ea535a1ad..245562815 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -24,7 +24,10 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from "@kandev/ui/dropdown-menu"; -import { useAppStore } from "@/components/state-provider"; +import { + useKanbanMultiSnapshots, + useWorkflowItems, +} from "@/hooks/domains/kanban/use-kanban-snapshots"; import type { WorkflowStep } from "@/components/kanban-card"; import { stepHasAutoStart, @@ -293,8 +296,8 @@ export function useKanbanCardMoveTargets( taskId: string, steps?: WorkflowStep[], ): KanbanCardMoveTargets { - const workflows = useAppStore((state) => state.workflows.items); - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); + const workflows = useWorkflowItems(); + const { snapshots } = useKanbanMultiSnapshots({ enabled: false }); const currentWorkflowId = useMemo(() => { for (const [workflowId, snapshot] of Object.entries(snapshots)) { diff --git a/apps/web/components/kanban-card-preview.tsx b/apps/web/components/kanban-card-preview.tsx index 395e4f39c..df9898003 100644 --- a/apps/web/components/kanban-card-preview.tsx +++ b/apps/web/components/kanban-card-preview.tsx @@ -4,8 +4,7 @@ import { useMemo } from "react"; import { Card, CardContent } from "@kandev/ui/card"; import { KanbanCardBody } from "@/components/kanban-card-content"; import { resolveTaskRepositoryNames, type Task } from "@/components/kanban-card"; -import { useAppStore } from "@/components/state-provider"; -import type { Repository } from "@/lib/types/http"; +import { useAllRepositories } from "@/hooks/domains/workspace/use-all-repositories"; function KanbanCardPreviewLayout({ task, @@ -27,14 +26,10 @@ function KanbanCardPreviewLayout({ } export function KanbanCardPreview({ task }: { task: Task }) { - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const { repositories } = useAllRepositories(false); const repositoryNames = useMemo( - () => - resolveTaskRepositoryNames( - task, - Object.values(repositoriesByWorkspace).flat() as Repository[], - ), - [repositoriesByWorkspace, task], + () => resolveTaskRepositoryNames(task, repositories), + [repositories, task], ); return ; diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index 96e203e87..38d38ba6e 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -1,12 +1,10 @@ "use client"; -import { useMemo } from "react"; import { useDroppable } from "@dnd-kit/core"; import { KanbanCard, resolveTaskRepositoryNames, Task } from "./kanban-card"; import { Badge } from "@kandev/ui/badge"; import { cn } from "@/lib/utils"; -import { useAppStore } from "@/components/state-provider"; -import type { Repository } from "@/lib/types/http"; +import { useAllRepositories } from "@/hooks/domains/workspace/use-all-repositories"; export interface WorkflowStep { id: string; @@ -59,12 +57,8 @@ export function KanbanColumn({ id: step.id, }); - // Access repositories from store to pass repository names to cards - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); - const repositories = useMemo( - () => Object.values(repositoriesByWorkspace).flat() as Repository[], - [repositoriesByWorkspace], - ); + // Read repositories from TQ to pass repository names to cards (observe-only). + const { repositories } = useAllRepositories(false); return (
["size"]; }; diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 740a78511..884c3ad8c 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -17,6 +17,7 @@ import { useKanbanLayout } from "@/hooks/use-kanban-layout"; import { useTaskSession } from "@/hooks/use-task-session"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAppStore } from "@/components/state-provider"; +import { useAllKanbanTasks, useKanbanSnapshots } from "@/hooks/domains/kanban/use-kanban-tasks"; import { Task } from "./kanban-card"; import type { KanbanState } from "@/lib/state/slices"; import { PREVIEW_PANEL } from "@/lib/settings/constants"; @@ -154,9 +155,11 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit const router = useRouter(); const { isMobile } = useResponsiveBreakpoint(); - // Get tasks from the kanban store - const kanbanTasks = useAppStore((state) => state.kanban.tasks); - const kanbanMultiSnapshots = useAppStore((state) => state.kanbanMulti.snapshots); + // Read kanban data from TanStack Query (single source of truth) so WS-driven + // task updates surface in the preview without a hard refresh. The Zustand + // slice is a transitional mirror that lags behind for cross-workflow tasks. + const kanbanTasks = useAllKanbanTasks(); + const kanbanMultiSnapshots = useKanbanSnapshots(); const setKanbanPreviewedTaskId = useAppStore((state) => state.setKanbanPreviewedTaskId); const { selectedTaskId, isOpen, previewWidthPx, open, close, updatePreviewWidth } = diff --git a/apps/web/components/kanban/graph2-task-pipeline.tsx b/apps/web/components/kanban/graph2-task-pipeline.tsx index 60c4c6b75..02e266d1b 100644 --- a/apps/web/components/kanban/graph2-task-pipeline.tsx +++ b/apps/web/components/kanban/graph2-task-pipeline.tsx @@ -13,7 +13,7 @@ import { cn } from "@kandev/ui/lib/utils"; import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; import { needsAction } from "@/lib/utils/needs-action"; -import { useAppStore } from "@/components/state-provider"; +import { useAllRepositories } from "@/hooks/domains/workspace/use-all-repositories"; import { Graph2StepNode } from "./graph2-step-node"; import { Graph2Connector } from "./graph2-connector"; import type { Task } from "@/components/kanban-card"; @@ -233,16 +233,12 @@ function TaskButton({ } function useTaskRepoName(task: Task): string | undefined { - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const { repositories } = useAllRepositories(false); return useMemo(() => { const primaryRepoId = task.repositories?.[0]?.repository_id; if (!primaryRepoId) return undefined; - for (const repos of Object.values(repositoriesByWorkspace)) { - const repo = repos.find((r) => r.id === primaryRepoId); - if (repo) return repo.name; - } - return undefined; - }, [repositoriesByWorkspace, task.repositories]); + return repositories.find((r) => r.id === primaryRepoId)?.name; + }, [repositories, task.repositories]); } export function Graph2TaskPipeline({ diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index f6de0b891..bfdc268ef 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -20,10 +20,10 @@ import { MobileIntegrationsSection } from "@/components/integrations/integration import { TaskSearchInput } from "./task-search-input"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { linkToTasks } from "@/lib/links"; -import type { Repository } from "@/lib/types/http"; -import type { WorkflowsState, WorkspaceState } from "@/lib/state/slices"; +import type { Repository, Workspace } from "@/lib/types/http"; +import type { WorkflowsState } from "@/lib/state/slices"; -type WorkspaceItem = WorkspaceState["items"][number]; +type WorkspaceItem = Workspace; type MobileMenuSheetProps = { open: boolean; diff --git a/apps/web/components/kanban/swimlane-container.tsx b/apps/web/components/kanban/swimlane-container.tsx index 678ad650a..738887ed9 100644 --- a/apps/web/components/kanban/swimlane-container.tsx +++ b/apps/web/components/kanban/swimlane-container.tsx @@ -16,7 +16,12 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { useAppStore } from "@/components/state-provider"; +import { + useKanbanMultiSnapshots, + useWorkflowItems, + useReorderWorkflowItems, +} from "@/hooks/domains/kanban/use-kanban-snapshots"; +import { useAllRepositories } from "@/hooks/domains/workspace/use-all-repositories"; import { useSwimlaneCollapse } from "@/hooks/domains/kanban/use-swimlane-collapse"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters"; @@ -29,7 +34,6 @@ import { } from "@/lib/kanban/view-registry"; import type { Task } from "@/components/kanban-card"; import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; -import type { Repository } from "@/lib/types/http"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; export type SwimlaneContainerProps = { @@ -184,8 +188,8 @@ function useWorkflowReorder( orderedWorkflows: { id: string; name: string }[], workflowFilter: string | null, ) { - const reorderWorkflowItems = useAppStore((state) => state.reorderWorkflowItems); - const workflows = useAppStore((state) => state.workflows.items); + const reorderWorkflowItems = useReorderWorkflowItems(); + const workflows = useWorkflowItems(); const workspaceId = workflows[0]?.workspaceId; const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); const canSort = !workflowFilter && orderedWorkflows.length > 1; @@ -198,8 +202,11 @@ function useWorkflowReorder( const newIndex = orderedWorkflows.findIndex((wf) => wf.id === over.id); if (oldIndex === -1 || newIndex === -1) return; const reordered = arrayMove(orderedWorkflows, oldIndex, newIndex); - reorderWorkflowItems(reordered.map((wf) => wf.id)); if (workspaceId) { + reorderWorkflowItems( + workspaceId, + reordered.map((wf) => wf.id), + ); reorderWorkflows( workspaceId, reordered.map((wf) => wf.id), @@ -217,15 +224,10 @@ function useSwimlaneData( selectedRepositoryIds: string[], searchQuery: string, ) { - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); - const isLoading = useAppStore((state) => state.kanbanMulti.isLoading); - const workflows = useAppStore((state) => state.workflows.items); - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const { snapshots, isLoading } = useKanbanMultiSnapshots({ enabled: false }); + const workflows = useWorkflowItems(); + const { repositories } = useAllRepositories(false); - const repositories = useMemo( - () => Object.values(repositoriesByWorkspace).flat() as Repository[], - [repositoriesByWorkspace], - ); const repoFilter = useMemo( () => mapSelectedRepositoryIds(repositories, selectedRepositoryIds), [repositories, selectedRepositoryIds], diff --git a/apps/web/components/kanban/swimlane-graph-content.tsx b/apps/web/components/kanban/swimlane-graph-content.tsx index adc217976..70d901c27 100644 --- a/apps/web/components/kanban/swimlane-graph-content.tsx +++ b/apps/web/components/kanban/swimlane-graph-content.tsx @@ -18,7 +18,8 @@ import { Badge } from "@kandev/ui/badge"; import { getTaskStateIcon } from "@/lib/ui/state-icons"; import { needsAction } from "@/lib/utils/needs-action"; import { useTaskActions } from "@/hooks/use-task-actions"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAppStore } from "@/components/state-provider"; +import { useKanbanSnapshotMutator } from "@/hooks/domains/kanban/use-kanban-snapshots"; import type { Task } from "@/components/kanban-card"; import type { WorkflowStep } from "@/components/kanban-column"; import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; @@ -129,7 +130,7 @@ async function moveTaskAcrossSwimlaneSteps({ taskId, targetColumnId, workflowId, - store, + mutator, moveTaskById, onMoveError, }: { @@ -137,12 +138,12 @@ async function moveTaskAcrossSwimlaneSteps({ taskId: string; targetColumnId: string; workflowId: string; - store: ReturnType; + mutator: ReturnType; moveTaskById: ReturnType["moveTaskById"]; onMoveError?: (error: MoveTaskError) => void; }) { - const state = store.getState(); - const snapshot = state.kanbanMulti.snapshots[workflowId]; + const { getSnapshot, setSnapshot } = mutator; + const snapshot = getSnapshot(workflowId); if (!snapshot) return; const targetTasks = snapshot.tasks @@ -155,7 +156,7 @@ async function moveTaskAcrossSwimlaneSteps({ const nextPosition = targetTasks.length; const originalTasks = snapshot.tasks; - state.setWorkflowSnapshot(workflowId, { + setSnapshot(workflowId, { ...snapshot, tasks: snapshot.tasks.map((t: KanbanState["tasks"][number]) => t.id === taskId ? { ...t, workflowStepId: targetColumnId, position: nextPosition } : t, @@ -171,11 +172,9 @@ async function moveTaskAcrossSwimlaneSteps({ // Backend handles on_enter actions (auto_start_agent, plan_mode, etc.) // via the task.moved event → orchestrator processOnEnter() } catch (error) { - const currentSnapshot = store.getState().kanbanMulti.snapshots[workflowId]; + const currentSnapshot = getSnapshot(workflowId); if (currentSnapshot) { - store - .getState() - .setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); + setSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); } const message = error instanceof Error ? error.message : "Failed to move task"; onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); @@ -183,7 +182,7 @@ async function moveTaskAcrossSwimlaneSteps({ } function useSwimlaneGraphDnd({ tasks, steps, workflowId, onMoveError }: SwimlaneGraphDndOptions) { - const store = useAppStoreApi(); + const mutator = useKanbanSnapshotMutator(); const { moveTaskById } = useTaskActions(); const [activeTaskId, setActiveTaskId] = useState(null); @@ -237,12 +236,12 @@ function useSwimlaneGraphDnd({ tasks, steps, workflowId, onMoveError }: Swimlane taskId, targetColumnId, workflowId, - store, + mutator, moveTaskById, onMoveError, }); }, - [tasks, workflowId, store, moveTaskById, adjacentSteps, onMoveError], + [tasks, workflowId, mutator, moveTaskById, adjacentSteps, onMoveError], ); const handleDragCancel = useCallback(() => { diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 051fca597..f75adc0bd 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -17,7 +17,7 @@ import { KanbanCardPreview } from "@/components/kanban-card-preview"; import type { WorkflowStep } from "@/components/kanban-column"; import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; import { useTaskActions } from "@/hooks/use-task-actions"; -import { useAppStoreApi } from "@/components/state-provider"; +import { useKanbanSnapshotMutator } from "@/hooks/domains/kanban/use-kanban-snapshots"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { MobileColumnTabs } from "./mobile-column-tabs"; import { SwipeableColumns } from "./swipeable-columns"; @@ -50,7 +50,7 @@ type SwimlaneKanbanDndOptions = { }; function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanbanDndOptions) { - const store = useAppStoreApi(); + const { getSnapshot, setSnapshot } = useKanbanSnapshotMutator(); const { moveTaskById } = useTaskActions(); const [activeTaskId, setActiveTaskId] = useState(null); @@ -76,8 +76,7 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban const task = tasks.find((t) => t.id === taskId); if (!task || task.workflowStepId === targetStepId) return; - const state = store.getState(); - const snapshot = state.kanbanMulti.snapshots[workflowId]; + const snapshot = getSnapshot(workflowId); if (!snapshot) return; const targetTasks = snapshot.tasks @@ -90,7 +89,7 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban const nextPosition = targetTasks.length; const originalTasks = snapshot.tasks; - state.setWorkflowSnapshot(workflowId, { + setSnapshot(workflowId, { ...snapshot, tasks: snapshot.tasks.map((t: KanbanState["tasks"][number]) => t.id === taskId ? { ...t, workflowStepId: targetStepId, position: nextPosition } : t, @@ -104,17 +103,15 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban position: nextPosition, }); } catch (error) { - const currentSnapshot = store.getState().kanbanMulti.snapshots[workflowId]; + const currentSnapshot = getSnapshot(workflowId); if (currentSnapshot) { - store - .getState() - .setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); + setSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); } const message = error instanceof Error ? error.message : "Failed to move task"; onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); } }, - [tasks, workflowId, store, moveTaskById, onMoveError], + [tasks, workflowId, getSnapshot, setSnapshot, moveTaskById, onMoveError], ); const handleDragCancel = useCallback(() => { diff --git a/apps/web/components/kanban/task-multi-select-toolbar.tsx b/apps/web/components/kanban/task-multi-select-toolbar.tsx index 6857e2767..13d54bb31 100644 --- a/apps/web/components/kanban/task-multi-select-toolbar.tsx +++ b/apps/web/components/kanban/task-multi-select-toolbar.tsx @@ -5,7 +5,7 @@ import { IconTrash, IconArchive, IconChevronRight, IconX } from "@tabler/icons-r import { Button } from "@kandev/ui/button"; import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; -import { useAppStore } from "@/components/state-provider"; +import { useKanbanSnapshots } from "@/hooks/domains/kanban/use-kanban-tasks"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; import { DropdownMenu, @@ -28,14 +28,10 @@ interface TaskMultiSelectToolbarProps { } function useBulkExecutorTypes(taskIds: string[]): Array { - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); - const fallbackTasks = useAppStore((state) => state.kanban.tasks); + const snapshots = useKanbanSnapshots(); return useMemo( - () => - taskIds.map( - (id) => findTaskInSnapshots(id, snapshots, fallbackTasks)?.primaryExecutorType ?? null, - ), - [taskIds, snapshots, fallbackTasks], + () => taskIds.map((id) => findTaskInSnapshots(id, snapshots)?.primaryExecutorType ?? null), + [taskIds, snapshots], ); } diff --git a/apps/web/components/linear/linear-issue-watch-dialog.tsx b/apps/web/components/linear/linear-issue-watch-dialog.tsx index 18258fdd8..620c3bbb1 100644 --- a/apps/web/components/linear/linear-issue-watch-dialog.tsx +++ b/apps/web/components/linear/linear-issue-watch-dialog.tsx @@ -19,6 +19,8 @@ import { IconInfoCircle } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; import { CliModeIcon } from "@/components/cli-mode-icon"; import { useAppStore } from "@/components/state-provider"; +import { useExecutors, useAgentProfiles } from "@/hooks/domains/settings/use-settings-reads"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; import { useWorkflowSteps, stepPlaceholder } from "@/hooks/use-workflow-steps"; @@ -66,11 +68,10 @@ type Props = { function useFormData(workspaceId: string) { useSettingsData(true); - useWorkflows(workspaceId, true); - const allWorkflows = useAppStore((s) => s.workflows.items); + const { workflows: allWorkflows } = useWorkflows(workspaceId, true); const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); - const agentProfiles = useAppStore((s) => s.agentProfiles.items); - const executors = useAppStore((s) => s.executors.items); + const agentProfiles = useAgentProfiles(); + const executors = useExecutors(); const allExecutorProfiles = useMemo( () => executors @@ -384,7 +385,7 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { workspaces } = useWorkspaces(); return ( s.workspaces.items); + const { workspaces } = useWorkspaces(); const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; if (watches.length === 0) { diff --git a/apps/web/components/quick-chat/quick-chat-dialog.tsx b/apps/web/components/quick-chat/quick-chat-dialog.tsx index d7cd1b007..2de8edd71 100644 --- a/apps/web/components/quick-chat/quick-chat-dialog.tsx +++ b/apps/web/components/quick-chat/quick-chat-dialog.tsx @@ -7,10 +7,12 @@ import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { IconX, IconRocket } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; +import { useAgentProfiles } from "@/hooks/domains/settings/use-settings-reads"; import { useToast } from "@/components/toast-provider"; +import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { startQuickChat } from "@/lib/api/domains/workspace-api"; import type { Repository } from "@/lib/types/http"; -import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; +import type { AgentProfileOption } from "@/lib/types/settings"; type QuickChatPickerDialogProps = { open: boolean; @@ -29,8 +31,8 @@ type FormState = { const NONE_VALUE = "__none__"; -function QuickChatFormBody({ state }: { state: FormState }) { - const { selectedRepoId, setSelectedRepoId, selectedAgentId, setSelectedAgentId } = state; +function QuickChatFormBody({ form }: { form: FormState }) { + const { selectedRepoId, setSelectedRepoId, selectedAgentId, setSelectedAgentId } = form; return (

@@ -47,7 +49,7 @@ function QuickChatFormBody({ state }: { state: FormState }) { No repository - {state.repositories.map((repo) => ( + {form.repositories.map((repo) => ( {repo.name} @@ -66,7 +68,7 @@ function QuickChatFormBody({ state }: { state: FormState }) { Use workspace default - {state.agentProfiles.map((profile) => ( + {form.agentProfiles.map((profile) => ( {profile.label} @@ -89,8 +91,8 @@ export const QuickChatPickerDialog = memo(function QuickChatPickerDialog({ const [isStarting, setIsStarting] = useState(false); const [selectedRepoId, setSelectedRepoId] = useState(""); const [selectedAgentId, setSelectedAgentId] = useState(""); - const repositories = useAppStore((s) => s.repositories.itemsByWorkspaceId?.[workspaceId] ?? []); - const agentProfiles = useAppStore((s) => s.agentProfiles.items ?? []); + const { repositories } = useRepositories(workspaceId, open); + const agentProfiles = useAgentProfiles(); const handleStart = useCallback(async () => { if (isStarting) return; @@ -150,7 +152,7 @@ export const QuickChatPickerDialog = memo(function QuickChatPickerDialog({

- +