diff --git a/apps/backend/internal/agent/runtime/lifecycle/session.go b/apps/backend/internal/agent/runtime/lifecycle/session.go index 957e68160b..0522568d24 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session.go @@ -519,6 +519,9 @@ func (sm *SessionManager) waitForPromptDone(ctx context.Context, execution *Agen sm.logger.Error("prompt completed with error", zap.String("execution_id", execution.ID), zap.String("error", signal.Error)) + if isAgentReportedSessionLoadMissingErr(signal.Error) { + return nil, fmt.Errorf("%w: %s: %w", ErrAgentReported, signal.Error, ErrExecutionNotFound) + } // Wrap cancel-release sentinels so PromptTask can identify them and // skip the REVIEW task-state transition — the user is cancelling, not // hitting a real agent failure. @@ -782,6 +785,58 @@ func isSessionUnknownErr(err error) bool { return strings.Contains(err.Error(), "Resource not found") } +// PromptCompletionSignal currently crosses the agentctl updates stream as JSON +// with only the rendered error string. Keep this fallback narrow until the +// stream carries structured ACP error codes alongside the message. +func isAgentReportedSessionLoadMissingErr(message string) bool { + lower := strings.ToLower(message) + trimmed := strings.TrimSpace(lower) + if isStandaloneMissingSessionMessage(trimmed) { + return true + } + if !hasSessionLoadContext(lower) { + return false + } + return isMissingSessionMessage(lower) +} + +func hasSessionLoadContext(message string) bool { + return strings.Contains(message, "failed to load session") || + strings.Contains(message, "session/load") || + strings.Contains(message, "load session") || + hasBroadSessionLoadContext(message) +} + +func hasBroadSessionLoadContext(message string) bool { + hasLoadWord := strings.Contains(message, "load") || strings.Contains(message, "loading") + if !hasLoadWord || !strings.Contains(message, "session") { + return false + } + return !strings.Contains(message, "cache") && + !strings.Contains(message, "config") && + !strings.Contains(message, "profile") +} + +func isMissingSessionMessage(message string) bool { + return strings.Contains(message, "session not found") || + strings.Contains(message, "no such session") || + strings.Contains(message, "resource not found") || + strings.Contains(message, "could not find session") || + strings.Contains(message, "session id not recognized") || + strings.Contains(message, "unknown session") || + strings.Contains(message, "session does not exist") || + (strings.Contains(message, "session") && strings.Contains(message, "not found")) +} + +func isStandaloneMissingSessionMessage(message string) bool { + switch message { + case "session not found", "no such session", "resource not found": + return true + default: + return false + } +} + func isAgentStreamNotConnectedErr(err error) bool { if err == nil { return false diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_test.go index 03a4b9dc6f..c7eaf06625 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session_test.go @@ -380,6 +380,75 @@ func containsAction(items []string, want string) bool { return false } +func TestWaitForPromptDoneWrapsMissingSessionLoadAsExecutionNotFound(t *testing.T) { + tests := []string{ + "failed to load session: session not found", + "session/load failed: no such session", + "failed to load session id session-1: Resource not found", + "session/load failed: could not find session", + "failed to load session: session id not recognized", + "failed to load session: unknown session", + "failed to load session: session does not exist", + "session not found", + "no such session", + "Resource not found", + "Session abc not found while loading", + } + for _, message := range tests { + t.Run(message, func(t *testing.T) { + sm := NewSessionManager(newSessionTestLogger(), newTestStopCh(t)) + execution := &AgentExecution{ + ID: "exec-1", + promptDoneCh: make(chan PromptCompletionSignal, 1), + } + execution.promptDoneCh <- PromptCompletionSignal{ + IsError: true, + Error: message, + } + + _, err := sm.waitForPromptDone(context.Background(), execution) + if !errors.Is(err, ErrAgentReported) { + t.Fatalf("expected ErrAgentReported, got %v", err) + } + if !errors.Is(err, ErrExecutionNotFound) { + t.Fatalf("expected ErrExecutionNotFound, got %v", err) + } + }) + } +} + +func TestWaitForPromptDoneDoesNotWrapUnrelatedNotFoundAgentErrors(t *testing.T) { + tests := []string{ + "permission denied: resource not found", + "tool failed: no such session", + "tool failed: session id abc: no such session in cache", + "loading agent config: session profile not found in vault", + "loading cache: session id not found", + "permission denied: session not found", + } + for _, message := range tests { + t.Run(message, func(t *testing.T) { + sm := NewSessionManager(newSessionTestLogger(), newTestStopCh(t)) + execution := &AgentExecution{ + ID: "exec-1", + promptDoneCh: make(chan PromptCompletionSignal, 1), + } + execution.promptDoneCh <- PromptCompletionSignal{ + IsError: true, + Error: message, + } + + _, err := sm.waitForPromptDone(context.Background(), execution) + if !errors.Is(err, ErrAgentReported) { + t.Fatalf("expected ErrAgentReported, got %v", err) + } + if errors.Is(err, ErrExecutionNotFound) { + t.Fatalf("expected unrelated agent error, got ErrExecutionNotFound: %v", err) + } + }) + } +} + func TestInitializeAndPrompt_StreamTimeout(t *testing.T) { // This test verifies that InitializeAndPrompt returns an error if // the stream fails to connect within the timeout. diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 0ac80cc7d3..54d52b92ef 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -461,15 +461,21 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "lspServerConfigs": mapStringMap(settings.LspServerConfigs), "savedLayouts": settings.SavedLayouts, "sidebarViews": mapSidebarViews(settings.SidebarViews), - "defaultUtilityAgentId": nullString(settings.DefaultUtilityAgentID), - "keyboardShortcuts": mapStringAny(settings.KeyboardShortcuts), - "terminalLinkBehavior": terminalLinkBehavior(settings.TerminalLinkBehavior), - "terminalFontFamily": nullString(settings.TerminalFontFamily), - "terminalFontSize": nullInt(settings.TerminalFontSize), - "changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout), - "systemMetricsDisplay": map[string]any{"showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar}, - "voiceMode": mapVoiceMode(settings.VoiceMode), - "loaded": true, + "taskCreateLastUsed": map[string]any{ + "repositoryId": nullString(settings.TaskCreateLastUsed.RepositoryID), + "branch": nullString(settings.TaskCreateLastUsed.Branch), + "agentProfileId": nullString(settings.TaskCreateLastUsed.AgentProfileID), + "executorProfileId": nullString(settings.TaskCreateLastUsed.ExecutorProfileID), + }, + "defaultUtilityAgentId": nullString(settings.DefaultUtilityAgentID), + "keyboardShortcuts": mapStringAny(settings.KeyboardShortcuts), + "terminalLinkBehavior": terminalLinkBehavior(settings.TerminalLinkBehavior), + "terminalFontFamily": nullString(settings.TerminalFontFamily), + "terminalFontSize": nullInt(settings.TerminalFontSize), + "changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout), + "systemMetricsDisplay": map[string]any{"showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar}, + "voiceMode": mapVoiceMode(settings.VoiceMode), + "loaded": true, } } diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index e02ce337c0..a80530bfe2 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -175,6 +175,8 @@ func appendAgentctlStatusMessage( // Includes task_environment_id when present so late-subscribing clients // (page reload, task switch, WS reconnect) can seed environmentIdBySessionId // — without it, env-routed shell terminals stall on "Connecting terminal...". +// Includes is_passthrough as an explicit boolean, including false, so snapshot +// events can clear stale passthrough UI state after a task switch or reload. func appendSessionStateMessage(sessionID string, session *models.TaskSession, result []*ws.Message) []*ws.Message { payload := map[string]interface{}{ sessionIDPayloadKey: sessionID, @@ -191,6 +193,13 @@ func appendSessionStateMessage(sessionID string, session *models.TaskSession, re if session.TaskEnvironmentID != "" { payload["task_environment_id"] = session.TaskEnvironmentID } + if session.AgentProfileID != "" { + payload["agent_profile_id"] = session.AgentProfileID + } + if session.AgentProfileSnapshot != nil { + payload["agent_profile_snapshot"] = session.AgentProfileSnapshot + } + payload["is_passthrough"] = session.IsPassthrough notification, err := ws.NewNotification(ws.ActionSessionStateChanged, payload) if err == nil { result = append(result, notification) @@ -971,6 +980,7 @@ func registerSecondaryRoutes( p.eventBus, p.log, ) + registerWsSentTestRoute(p.router, p.gateway.Hub, p.log) p.log.Info("E2E mock routes enabled at /api/v1/_test/* — DO NOT enable in production") } diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index b7cf0b3dc3..65ead4f79a 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "reflect" + "sort" "strings" "testing" "time" @@ -25,6 +27,8 @@ import ( sqlitetaskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" usercontroller "github.com/kandev/kandev/internal/user/controller" + userdto "github.com/kandev/kandev/internal/user/dto" + usermodels "github.com/kandev/kandev/internal/user/models" userservice "github.com/kandev/kandev/internal/user/service" userstore "github.com/kandev/kandev/internal/user/store" "github.com/kandev/kandev/internal/webapp" @@ -44,6 +48,83 @@ func decodePayload(t *testing.T, raw json.RawMessage) map[string]interface{} { return payload } +func TestMapUserSettingsStateKeys(t *testing.T) { + state := mapUserSettingsState(userdto.UserSettingsResponse{}, "") + + got := make([]string, 0, len(state)) + for key := range state { + got = append(got, key) + } + sort.Strings(got) + + want := []string{ + "changesPanelLayout", + "chatSubmitKey", + "defaultEditorId", + "defaultUtilityAgentId", + "enablePreviewOnClick", + "kanbanViewMode", + "keyboardShortcuts", + "loaded", + "lspAutoInstallLanguages", + "lspAutoStartLanguages", + "lspServerConfigs", + "preferredShell", + "releaseNotesLastSeenVersion", + "repositoryIds", + "reviewAutoMarkOnScroll", + "savedLayouts", + "shellOptions", + "showReleaseNotification", + "sidebarViews", + "systemMetricsDisplay", + "taskCreateLastUsed", + "tasksListGroup", + "tasksListSort", + "terminalFontFamily", + "terminalFontSize", + "terminalLinkBehavior", + "voiceMode", + "workflowId", + "workspaceId", + } + sort.Strings(want) + + if !reflect.DeepEqual(got, want) { + t.Fatalf("mapUserSettingsState keys = %#v, want %#v", got, want) + } +} + +func TestMapUserSettingsStateIncludesTaskCreateLastUsed(t *testing.T) { + state := mapUserSettingsState(userdto.UserSettingsResponse{ + Settings: userdto.UserSettingsDTO{ + TaskCreateLastUsed: usermodels.TaskCreateLastUsed{ + RepositoryID: "repo-1", + Branch: "main", + AgentProfileID: "agent-profile-1", + ExecutorProfileID: "executor-profile-1", + }, + }, + }, "") + + lastUsed, ok := state["taskCreateLastUsed"].(map[string]any) + if !ok { + t.Fatalf("taskCreateLastUsed missing or wrong type: %#v", state["taskCreateLastUsed"]) + } + if lastUsed["repositoryId"] != "repo-1" { + t.Fatalf("repositoryId = %#v, want repo-1", lastUsed["repositoryId"]) + } + if lastUsed["branch"] != "main" { + t.Fatalf("branch = %#v, want main", lastUsed["branch"]) + } + if lastUsed["agentProfileId"] != "agent-profile-1" { + t.Fatalf("agentProfileId = %#v, want agent-profile-1", lastUsed["agentProfileId"]) + } + if lastUsed["executorProfileId"] != "executor-profile-1" { + t.Fatalf("executorProfileId = %#v, want executor-profile-1", lastUsed["executorProfileId"]) + } +} + // TestAppendSessionStateMessage_IncludesTaskEnvironmentID asserts the snapshot // the WS hub sends on `session.subscribe` carries `task_environment_id`. // @@ -79,6 +160,64 @@ func TestAppendSessionStateMessage_IncludesTaskEnvironmentID(t *testing.T) { } } +func TestAppendSessionStateMessage_IncludesRoutingMetadata(t *testing.T) { + session := &models.TaskSession{ + ID: "sess-1", + TaskID: "task-1", + State: models.TaskSessionStateRunning, + AgentProfileID: "profile-1", + AgentProfileSnapshot: map[string]interface{}{ + "name": "Codex", + "model": "gpt-5", + }, + IsPassthrough: true, + } + + msgs := appendSessionStateMessage(session.ID, session, nil) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + + payload := decodePayload(t, msgs[0].Payload) + if got := payload["agent_profile_id"]; got != "profile-1" { + t.Fatalf("expected agent_profile_id=profile-1, got %v", got) + } + snapshot, ok := payload["agent_profile_snapshot"].(map[string]interface{}) + if !ok { + t.Fatalf("payload missing agent_profile_snapshot: %#v", payload["agent_profile_snapshot"]) + } + if snapshot["name"] != "Codex" || snapshot["model"] != "gpt-5" { + t.Fatalf("unexpected agent_profile_snapshot: %#v", snapshot) + } + if got := payload["is_passthrough"]; got != true { + t.Fatalf("expected is_passthrough=true, got %v", got) + } +} + +func TestAppendSessionStateMessage_EmitsFalsePassthroughAndOmitsEmptyRoutingMetadata(t *testing.T) { + session := &models.TaskSession{ + ID: "sess-1", + TaskID: "task-1", + State: models.TaskSessionStateRunning, + } + + msgs := appendSessionStateMessage(session.ID, session, nil) + if len(msgs) != 1 { + t.Fatalf("expected 1 message, got %d", len(msgs)) + } + + payload := decodePayload(t, msgs[0].Payload) + got, present := payload["is_passthrough"] + if !present || got != false { + t.Fatalf("expected is_passthrough=false, got present=%v value=%#v", present, got) + } + for _, key := range []string{"agent_profile_id", "agent_profile_snapshot", "task_environment_id"} { + if _, present := payload[key]; present { + t.Fatalf("expected %s to be omitted, payload=%#v", key, payload) + } + } +} + func TestAppendAgentctlStatusMessage_IncludesWorkspacePathForReload(t *testing.T) { log, err := logger.NewLogger(logger.LoggingConfig{ Level: "error", diff --git a/apps/backend/internal/backendapp/ws_sent_test_route.go b/apps/backend/internal/backendapp/ws_sent_test_route.go new file mode 100644 index 0000000000..11906c8508 --- /dev/null +++ b/apps/backend/internal/backendapp/ws_sent_test_route.go @@ -0,0 +1,88 @@ +package backendapp + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + gateways "github.com/kandev/kandev/internal/gateway/websocket" +) + +type wsSentSource interface { + GetSentEventsFor(connectionID string, sinceConnectionSeq int64) ([]gateways.WsSentEvent, int64, bool) + GetSentEventsForSession(connectionID, sessionID string) ([]gateways.WsSentEvent, int64, bool) +} + +func registerWsSentTestRoute(router *gin.Engine, source wsSentSource, log *logger.Logger) { + if source == nil { + return + } + group := router.Group("/api/v1/_test") + group.GET("/ws-sent", handleWsSentTestRoute(source, log)) +} + +func handleWsSentTestRoute(source wsSentSource, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + connectionID := c.Query("connection_id") + if connectionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "connection_id is required"}) + return + } + sinceSeq, ok := parseSinceConnectionSeq(c) + if !ok { + return + } + sessionID := c.Query("session_id") + if sessionID != "" { + respondWsSentSession(c, source, connectionID, sessionID) + return + } + events, maxSeq, found := source.GetSentEventsFor(connectionID, sinceSeq) + if !found { + if log != nil { + log.Debug("ws sent-log connection not found") + } + c.JSON(http.StatusNotFound, gin.H{"error": "connection_id not found"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "connection_id": connectionID, + "events": events, + "max_connection_seq": maxSeq, + }) + } +} + +func parseSinceConnectionSeq(c *gin.Context) (int64, bool) { + raw := c.Query("since_seq") + if raw == "" { + return 0, true + } + seq, err := strconv.ParseInt(raw, 10, 64) + if err != nil || seq < 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "since_seq must be a non-negative integer"}) + return 0, false + } + return seq, true +} + +func respondWsSentSession( + c *gin.Context, + source wsSentSource, + connectionID string, + sessionID string, +) { + events, maxSeq, found := source.GetSentEventsForSession(connectionID, sessionID) + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "connection_id not found"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "connection_id": connectionID, + "session_id": sessionID, + "events": events, + "max_session_seq": maxSeq, + }) +} diff --git a/apps/backend/internal/backendapp/ws_sent_test_route_test.go b/apps/backend/internal/backendapp/ws_sent_test_route_test.go new file mode 100644 index 0000000000..2605d54623 --- /dev/null +++ b/apps/backend/internal/backendapp/ws_sent_test_route_test.go @@ -0,0 +1,139 @@ +package backendapp + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + gateways "github.com/kandev/kandev/internal/gateway/websocket" +) + +func TestRegisterWsSentTestRouteReturnsConnectionEvents(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + source := &fakeWsSentSource{ + connectionEvents: []gateways.WsSentEvent{ + {ConnectionSeq: 2, Type: "notification", Action: "task.updated", SentAt: time.Unix(2, 0).UTC()}, + }, + connectionMax: 4, + } + registerWsSentTestRoute(router, source, logger.Default()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/_test/ws-sent?connection_id=conn-1&since_seq=1", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s; want 200", rec.Code, rec.Body.String()) + } + if source.connectionID != "conn-1" || source.sinceSeq != 1 { + t.Fatalf("source args = connection %q since %d; want conn-1/1", source.connectionID, source.sinceSeq) + } + want := `"max_connection_seq":4` + if body := rec.Body.String(); !strings.Contains(body, want) || !strings.Contains(body, `"connection_seq":2`) { + t.Fatalf("response body %s missing expected sent-log fields", body) + } +} + +func TestRegisterWsSentTestRouteReturnsSessionEvents(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + source := &fakeWsSentSource{ + sessionEvents: []gateways.WsSentEvent{ + { + ConnectionSeq: 3, + SessionSeq: 2, + SessionID: "session-1", + Type: "notification", + Action: "session.message.added", + SentAt: time.Unix(3, 0).UTC(), + }, + }, + sessionMax: 2, + } + registerWsSentTestRoute(router, source, logger.Default()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/_test/ws-sent?connection_id=conn-1&session_id=session-1", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s; want 200", rec.Code, rec.Body.String()) + } + if source.sessionConnectionID != "conn-1" || source.sessionID != "session-1" { + t.Fatalf("source session args = %q/%q; want conn-1/session-1", source.sessionConnectionID, source.sessionID) + } + if body := rec.Body.String(); !strings.Contains(body, `"max_session_seq":2`) || !strings.Contains(body, `"session_seq":2`) { + t.Fatalf("response body %s missing expected session sent-log fields", body) + } +} + +func TestRegisterWsSentTestRouteRequiresConnectionID(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + registerWsSentTestRoute(router, &fakeWsSentSource{}, logger.Default()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/_test/ws-sent", nil) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d; want 400", rec.Code) + } +} + +func TestRegisterWsSentTestRouteRejectsInvalidSinceSeq(t *testing.T) { + gin.SetMode(gin.TestMode) + tests := []string{"-1", "notanumber"} + + for _, sinceSeq := range tests { + t.Run(sinceSeq, func(t *testing.T) { + router := gin.New() + registerWsSentTestRoute(router, &fakeWsSentSource{}, logger.Default()) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodGet, + "/api/v1/_test/ws-sent?connection_id=conn-1&since_seq="+sinceSeq, + nil, + ) + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d; want 400", rec.Code) + } + if body := rec.Body.String(); !strings.Contains(body, "since_seq must be a non-negative integer") { + t.Fatalf("response body %s missing since_seq validation error", body) + } + }) + } +} + +type fakeWsSentSource struct { + connectionID string + sinceSeq int64 + connectionEvents []gateways.WsSentEvent + connectionMax int64 + + sessionConnectionID string + sessionID string + sessionEvents []gateways.WsSentEvent + sessionMax int64 +} + +func (f *fakeWsSentSource) GetSentEventsFor(connectionID string, sinceSeq int64) ([]gateways.WsSentEvent, int64, bool) { + f.connectionID = connectionID + f.sinceSeq = sinceSeq + return f.connectionEvents, f.connectionMax, true +} + +func (f *fakeWsSentSource) GetSentEventsForSession(connectionID, sessionID string) ([]gateways.WsSentEvent, int64, bool) { + f.sessionConnectionID = connectionID + f.sessionID = sessionID + return f.sessionEvents, f.sessionMax, true +} diff --git a/apps/backend/internal/gateway/websocket/client.go b/apps/backend/internal/gateway/websocket/client.go index 3dfd4b98fc..9351f497c6 100644 --- a/apps/backend/internal/gateway/websocket/client.go +++ b/apps/backend/internal/gateway/websocket/client.go @@ -4,10 +4,12 @@ import ( "context" "encoding/json" "sync" + "sync/atomic" "time" "github.com/gorilla/websocket" "github.com/kandev/kandev/internal/common/logger" + officetestharness "github.com/kandev/kandev/internal/office/testharness" "github.com/kandev/kandev/internal/user/store" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -43,11 +45,13 @@ type Client struct { mu sync.RWMutex closed bool logger *logger.Logger + connectionSeq atomic.Int64 + sentLog *wsSentLog } // NewClient creates a new WebSocket client func NewClient(id string, conn *websocket.Conn, hub *Hub, log *logger.Logger) *Client { - return &Client{ + client := &Client{ ID: id, conn: conn, hub: hub, @@ -59,6 +63,10 @@ func NewClient(id string, conn *websocket.Conn, hub *Hub, log *logger.Logger) *C runSubscriptions: make(map[string]bool), logger: log.WithFields(zap.String("client_id", id)), } + if officetestharness.Enabled() { + client.sentLog = newWsSentLog() + } + return client } // ReadPump pumps messages from the WebSocket connection to the hub. @@ -309,7 +317,8 @@ func (c *Client) sendSessionData(sessionID string) { zap.String("session_id", sessionID), zap.Int("count", len(data))) - // Send each piece of session data as a notification + // Replay data is delivered only to this connection; live session broadcasts + // carry session_seq so multi-client subscribers share one logical sequence. for _, msg := range data { c.sendMessage(msg) } @@ -473,30 +482,152 @@ func (c *Client) handleSessionUnfocus(msg *ws.Message) { c.sendMessage(resp) } -// sendMessage sends a message to the client -func (c *Client) sendMessage(msg *ws.Message) { - data, err := json.Marshal(msg) - if err != nil { - c.logger.Error("Failed to marshal message", zap.Error(err)) - return +// sendMessage stamps and sends a connection-wide message to the client. +func (c *Client) sendMessage(msg *ws.Message) bool { + return c.sendMessageForSession("", msg) +} + +func (c *Client) sendMessageForSession(sessionID string, msg *ws.Message) bool { + return c.sendMessageForSessionSeq(sessionID, 0, msg) +} + +func (c *Client) sendMessageForSessionSeq(sessionID string, sessionSeq int64, msg *ws.Message) bool { + ok, _ := c.sendMessageForSessionSeqResult(sessionID, sessionSeq, msg) + return ok +} + +func (c *Client) sendMessageForSessionSeqResult(sessionID string, sessionSeq int64, msg *ws.Message) (bool, int64) { + stamped, ok := c.stampForSession(sessionID, sessionSeq, msg) + if !ok { + return false, 0 + } + if !c.sendStampedMessage(&stamped) { + return false, 0 + } + if c.sentLog != nil { + c.sentLog.Append( + stamped.connectionSeq, + stamped.sessionSeq, + stamped.sessionID, + stamped.msgType, + stamped.action, + stamped.sentAt, + ) + } + return true, stamped.sessionSeq +} + +func (c *Client) sendStampedCopyForSessionSeqResult(sessionID string, sessionSeq int64, msg *ws.Message) (bool, int64) { + if msg == nil { + return false, 0 } - c.sendBytes(data) + clone := *msg + return c.sendMessageForSessionSeqResult(sessionID, sessionSeq, &clone) } -func (c *Client) sendBytes(data []byte) bool { +type stampedMessage struct { + message ws.Message + connectionSeq int64 + sessionSeq int64 + sessionID string + msgType string + action string + sentAt time.Time +} + +func (c *Client) stampForSession( + sessionID string, + sessionSeq int64, + msg *ws.Message, +) (stampedMessage, bool) { + if msg == nil { + return stampedMessage{}, false + } + stampedMsg := *msg + stampedMsg.ConnectionID = "" + stampedMsg.ConnectionSeq = 0 + stampedMsg.SessionID = "" + stampedMsg.SessionSeq = 0 + if c.sentLog != nil { + stampedMsg.ConnectionID = c.ID + } + if c.sentLog != nil && sessionID != "" { + stampedMsg.SessionID = sessionID + stampedMsg.SessionSeq = sessionSeq + } + return stampedMessage{ + message: stampedMsg, + sessionSeq: stampedMsg.SessionSeq, + sessionID: sessionID, + msgType: string(stampedMsg.Type), + action: stampedMsg.Action, + }, true +} + +func (c *Client) canAcceptStampedMessage() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return !c.closed && len(c.send) < cap(c.send) +} + +func (c *Client) sendStampedMessage(stamped *stampedMessage) bool { c.mu.Lock() defer c.mu.Unlock() if c.closed { return false } + if len(c.send) >= cap(c.send) { + c.logger.Warn("Client send buffer full") + return false + } + + accountingEnabled := c.sentLog != nil + var connectionSeq int64 + message := stamped.message + if accountingEnabled { + connectionSeq = c.connectionSeq.Load() + 1 + message.ConnectionSeq = connectionSeq + } + if !c.assignSessionSeqIfNeeded(stamped, &message, accountingEnabled) { + return false + } + data, err := json.Marshal(&message) + if err != nil { + c.rollbackAssignedSessionSeq(stamped, accountingEnabled) + c.logger.Error("Failed to marshal message", zap.Error(err)) + return false + } - select { - case c.send <- data: + if accountingEnabled { + c.connectionSeq.Store(connectionSeq) + } + c.send <- data + stamped.connectionSeq = connectionSeq + stamped.sentAt = time.Now().UTC() + return true +} + +func (c *Client) assignSessionSeqIfNeeded(stamped *stampedMessage, message *ws.Message, accountingEnabled bool) bool { + if !accountingEnabled || stamped.sessionID == "" || stamped.sessionSeq > 0 || c.hub == nil { return true - default: - c.logger.Warn("Client send buffer full") + } + if message.Payload != nil && !json.Valid(message.Payload) { + c.logger.Error("Failed to marshal message", zap.String("error", "invalid JSON payload")) return false } + stamped.sessionSeq = c.hub.nextSessionSeq(stamped.sessionID) + message.SessionSeq = stamped.sessionSeq + stamped.message.SessionSeq = stamped.sessionSeq + return true +} + +func (c *Client) rollbackAssignedSessionSeq(stamped *stampedMessage, accountingEnabled bool) { + if !accountingEnabled || stamped.sessionID == "" || stamped.sessionSeq <= 0 || c.hub == nil { + return + } + c.hub.rollbackSessionSeq(stamped.sessionID, stamped.sessionSeq) + stamped.sessionSeq = 0 + stamped.message.SessionSeq = 0 } // sendError sends an error message to the client diff --git a/apps/backend/internal/gateway/websocket/hub.go b/apps/backend/internal/gateway/websocket/hub.go index b215695feb..bbc462e5b3 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" @@ -17,7 +17,8 @@ type SessionDataProvider func(ctx context.Context, sessionID string) ([]*ws.Mess // Hub manages all WebSocket client connections type Hub struct { // All registered clients - clients map[*Client]bool + clients map[*Client]bool + clientsByID map[string]*Client // Clients subscribed to specific tasks (for ACP notifications) taskSubscribers map[string]map[*Client]bool @@ -47,6 +48,8 @@ type Hub struct { // effective mode (paused/slow/fast) transitions. See hub_session_mode.go. sessionMode *sessionModeTracker metricsInterestTracker SystemMetricsInterestTracker + sessionSeqs sync.Map + sessionFanoutLocks sync.Map // dispatchCtx is the hub's lifetime context, set by Run. Dispatched // message handlers use it instead of the per-connection context so that @@ -63,6 +66,7 @@ type Hub struct { func NewHub(dispatcher *ws.Dispatcher, log *logger.Logger) *Hub { return &Hub{ clients: make(map[*Client]bool), + clientsByID: make(map[string]*Client), taskSubscribers: make(map[string]map[*Client]bool), sessionSubscribers: make(map[string]map[*Client]bool), userSubscribers: make(map[string]map[*Client]bool), @@ -100,6 +104,7 @@ func (h *Hub) Run(ctx context.Context) { case client := <-h.register: h.mu.Lock() h.clients[client] = true + h.clientsByID[client.ID] = client h.mu.Unlock() h.logger.Debug("Client registered", zap.String("client_id", client.ID)) @@ -118,6 +123,13 @@ func (h *Hub) Run(ctx context.Context) { func (h *Hub) closeAllClients() { h.mu.Lock() metricClientIDs := make([]string, 0, len(h.systemMetricsSubscribers)) + h.clientsByID = make(map[string]*Client) + h.taskSubscribers = make(map[string]map[*Client]bool) + h.sessionSubscribers = make(map[string]map[*Client]bool) + h.userSubscribers = make(map[string]map[*Client]bool) + h.runSubscribers = make(map[string]map[*Client]bool) + h.systemMetricsSubscribers = make(map[*Client]bool) + h.sessionMode.focusByClient = make(map[string]map[*Client]bool) for client := range h.clients { if client.systemMetricsSubscribed { metricClientIDs = append(metricClientIDs, client.ID) @@ -127,11 +139,6 @@ func (h *Hub) closeAllClients() { delete(h.clients, client) } tracker := h.metricsInterestTracker - h.taskSubscribers = make(map[string]map[*Client]bool) - h.sessionSubscribers = make(map[string]map[*Client]bool) - h.runSubscribers = make(map[string]map[*Client]bool) - h.systemMetricsSubscribers = make(map[*Client]bool) - h.sessionMode.focusByClient = make(map[string]map[*Client]bool) h.mu.Unlock() for _, clientID := range metricClientIDs { @@ -154,6 +161,7 @@ func (h *Hub) removeClient(client *Client) { } delete(h.clients, client) + delete(h.clientsByID, client.ID) client.closeSend() // Remove from all task subscriptions @@ -236,20 +244,16 @@ func removeClientFromSubscriberMap(subscribers map[string]map[*Client]bool, key // broadcastMessage sends a message to relevant clients 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() + clients := make([]*Client, 0, len(h.clients)) + for client := range h.clients { + clients = append(clients, client) + } + h.mu.RUnlock() // For now, broadcast to all clients // TODO: Add topic-based routing for task-specific notifications - for client := range h.clients { - client.sendBytes(data) - } + h.fanoutToClients(msg, clients) } // Register adds a client to the hub @@ -279,34 +283,81 @@ 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) { - for _, client := range clients { - if client.sendBytes(data) { +func (h *Hub) fanoutToClients(msg *ws.Message, clients []*Client) { + h.fanoutToClientsForSession("", msg, clients) +} + +func (h *Hub) fanoutToClientsForSession(sessionID string, msg *ws.Message, clients []*Client) { + if sessionID != "" { + lock := h.sessionFanoutLock(sessionID) + lock.Lock() + defer lock.Unlock() + } + h.fanoutToClientsForSessionLocked(sessionID, msg, clients) +} + +func (h *Hub) fanoutToClientsForSessionLocked(sessionID string, msg *ws.Message, clients []*Client) { + recipients := clients + var sessionSeq int64 + if sessionID != "" { + if len(clients) == 0 { + h.logger.Debug("No session subscribers for broadcast", + zap.String("session_id", sessionID), + zap.String("action", msg.Action)) + return + } + recipients = availableClients(clients) + if len(recipients) == 0 { + h.logger.Warn("All session subscriber send buffers full, dropping message", + zap.String("session_id", sessionID), + zap.String("action", msg.Action), + zap.Int("subscriber_count", len(clients))) + return + } + } + // E2E accounting stamps each client with its own connection sequence, so + // broadcasts intentionally marshal once per recipient instead of sharing one + // pre-marshaled byte slice. + for _, client := range recipients { + sent, usedSessionSeq := client.sendStampedCopyForSessionSeqResult(sessionID, sessionSeq, msg) + if sent { + if sessionID != "" && sessionSeq == 0 { + sessionSeq = usedSessionSeq + } 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)) } } } +func (h *Hub) sessionFanoutLock(sessionID string) *sync.Mutex { + value, _ := h.sessionFanoutLocks.LoadOrStore(sessionID, &sync.Mutex{}) + return value.(*sync.Mutex) +} + +func availableClients(clients []*Client) []*Client { + out := make([]*Client, 0, len(clients)) + for _, client := range clients { + if client.canAcceptStampedMessage() { + out = append(out, client) + } + } + return out +} + // 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 @@ -344,32 +395,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 @@ -452,32 +493,22 @@ 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) } func (h *Hub) BroadcastToSystemMetrics(msg *ws.Message) { - data, err := json.Marshal(msg) - if err != nil { - h.logger.Error("Failed to marshal message", zap.Error(err)) - return - } h.mu.RLock() clients := make([]*Client, 0, len(h.systemMetricsSubscribers)) for client := range h.systemMetricsSubscribers { clients = append(clients, client) } h.mu.RUnlock() - h.sendToClients(data, clients, msg.Action) + h.fanoutToClients(msg, clients) } // SubscribeToRun subscribes a client to office run-event notifications. @@ -567,6 +598,49 @@ func (h *Hub) GetClientCount() int { return len(h.clients) } +// WsSentEvent is one stamped outbound envelope recorded for E2E receipt checks. +type WsSentEvent struct { + ConnectionSeq int64 `json:"connection_seq"` + // SessionSeq is per-session accounting only. It may gap when a recipient + // send is dropped and may reset after a session pauses and later resumes. + 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"` +} + +func (h *Hub) GetSentEventsFor(connectionID string, sinceConnectionSeq int64) ([]WsSentEvent, int64, bool) { + client, ok := h.getClientByID(connectionID) + if !ok || client.sentLog == nil { + return nil, 0, false + } + entries := client.sentLog.Since(sinceConnectionSeq) + return entries, client.sentLog.MaxConnectionSeq(), true +} + +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(sessionID) + var maxSessionSeq int64 + for _, entry := range entries { + if entry.SessionSeq > maxSessionSeq { + maxSessionSeq = entry.SessionSeq + } + } + return entries, maxSessionSeq, true +} + +func (h *Hub) getClientByID(id string) (*Client, bool) { + h.mu.RLock() + defer h.mu.RUnlock() + client, ok := h.clientsByID[id] + return client, ok +} + // GetDispatcher returns the message dispatcher func (h *Hub) GetDispatcher() *ws.Dispatcher { return h.dispatcher diff --git a/apps/backend/internal/gateway/websocket/hub_broadcast_test.go b/apps/backend/internal/gateway/websocket/hub_broadcast_test.go index 50da21d6f3..065ffde4bf 100644 --- a/apps/backend/internal/gateway/websocket/hub_broadcast_test.go +++ b/apps/backend/internal/gateway/websocket/hub_broadcast_test.go @@ -12,6 +12,7 @@ import ( func registerTestClient(h *Hub, c *Client) { h.mu.Lock() h.clients[c] = true + h.clientsByID[c.ID] = c h.mu.Unlock() } diff --git a/apps/backend/internal/gateway/websocket/hub_session_mode.go b/apps/backend/internal/gateway/websocket/hub_session_mode.go index 9444c86de7..be2483e231 100644 --- a/apps/backend/internal/gateway/websocket/hub_session_mode.go +++ b/apps/backend/internal/gateway/websocket/hub_session_mode.go @@ -250,8 +250,12 @@ func (h *Hub) fireDebouncedDownTransition(sessionID string) { if latest == SessionModePaused { // Session is fully idle — drop the entry so the maps don't grow - // unbounded over a long-running gateway. Next event re-adds. + // unbounded over a long-running gateway. Paused requires no focused + // or subscribed clients after the debounce, so resetting the E2E + // session sequence cannot be observed by an active receiver. Next + // event re-adds. delete(h.sessionMode.lastMode, sessionID) + h.clearSessionSeq(sessionID) } else { h.sessionMode.lastMode[sessionID] = latest } 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 f27e783bc9..6175257e5b 100644 --- a/apps/backend/internal/gateway/websocket/hub_session_mode_test.go +++ b/apps/backend/internal/gateway/websocket/hub_session_mode_test.go @@ -37,6 +37,7 @@ func newTestClient(id string) *Client { userSubscriptions: map[string]bool{}, runSubscriptions: map[string]bool{}, logger: log, + sentLog: newWsSentLog(), } } @@ -222,6 +223,28 @@ func TestHub_UnsubscribeFromOnlySubscriberFiresPaused(t *testing.T) { } } +func TestHub_PausedSessionDropsSessionSequence(t *testing.T) { + h := newTestHub(t) + rec := newModeRecorder() + h.AddSessionModeListener(rec.listener()) + + c := newTestClient("c1") + h.SubscribeToSession(c, "sess-1") + rec.waitForCount(1, time.Second) + if got := h.nextSessionSeq("sess-1"); got != 1 { + t.Fatalf("session seq = %d; want 1", got) + } + + h.UnsubscribeFromSession(c, "sess-1") + got := rec.waitForCount(2, time.Second) + if len(got) < 2 || got[1].mode != SessionModePaused { + t.Fatalf("expected paused event after unsubscribe, got %+v", got) + } + if got := currentSessionSeq(h, "sess-1"); got != 0 { + t.Fatalf("session seq after paused transition = %d; want deleted", got) + } +} + func TestHub_DisconnectCleansBothMaps(t *testing.T) { h := newTestHub(t) rec := newModeRecorder() @@ -229,9 +252,7 @@ func TestHub_DisconnectCleansBothMaps(t *testing.T) { c := newTestClient("c1") // Manually register the client (no real WS connection in tests). - h.mu.Lock() - h.clients[c] = true - h.mu.Unlock() + registerTestClient(h, c) h.SubscribeToSession(c, "sess-1") h.FocusSession(c, "sess-1") 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 0000000000..53db827e74 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/hub_session_seq.go @@ -0,0 +1,34 @@ +package websocket + +import "sync/atomic" + +func (h *Hub) nextSessionSeq(sessionID string) int64 { + if sessionID == "" { + return 0 + } + value, ok := h.sessionSeqs.Load(sessionID) + if !ok { + value, _ = h.sessionSeqs.LoadOrStore(sessionID, &atomic.Int64{}) + } + return value.(*atomic.Int64).Add(1) +} + +func (h *Hub) rollbackSessionSeq(sessionID string, seq int64) { + if sessionID == "" || seq <= 0 { + return + } + value, ok := h.sessionSeqs.Load(sessionID) + if !ok { + return + } + counter := value.(*atomic.Int64) + counter.CompareAndSwap(seq, seq-1) +} + +func (h *Hub) clearSessionSeq(sessionID string) { + if sessionID == "" { + return + } + h.sessionSeqs.Delete(sessionID) + h.sessionFanoutLocks.Delete(sessionID) +} diff --git a/apps/backend/internal/gateway/websocket/ws_accounting_test.go b/apps/backend/internal/gateway/websocket/ws_accounting_test.go new file mode 100644 index 0000000000..5d9d83d2d8 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/ws_accounting_test.go @@ -0,0 +1,717 @@ +package websocket + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestClient_SendMessageStampsConnectionSequenceAndLog(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-1") + c.hub = h + registerTestClient(h, c) + + first, err := ws.NewResponse("req-1", "health.check", map[string]bool{"ok": true}) + if err != nil { + t.Fatalf("response: %v", err) + } + second, err := ws.NewNotification("task.updated", map[string]string{"task_id": "task-1"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + + if !c.sendMessage(first) { + t.Fatal("first send failed") + } + if !c.sendMessage(second) { + t.Fatal("second send failed") + } + + gotFirst := readStampedMessage(t, c) + gotSecond := readStampedMessage(t, c) + if gotFirst.ConnectionID != "conn-1" || gotSecond.ConnectionID != "conn-1" { + t.Fatalf("connection IDs = %q, %q; want conn-1", gotFirst.ConnectionID, gotSecond.ConnectionID) + } + if gotFirst.ConnectionSeq != 1 || gotSecond.ConnectionSeq != 2 { + t.Fatalf("connection seqs = %d, %d; want 1, 2", gotFirst.ConnectionSeq, gotSecond.ConnectionSeq) + } + if gotFirst.SessionSeq != 0 || gotSecond.SessionSeq != 0 { + t.Fatalf("connection-wide messages should not carry session_seq: %+v %+v", gotFirst, gotSecond) + } + + events, maxSeq, ok := h.GetSentEventsFor("conn-1", 0) + if !ok { + t.Fatal("sent log lookup failed") + } + if maxSeq != 2 { + t.Fatalf("max connection seq = %d; want 2", maxSeq) + } + if len(events) != 2 { + t.Fatalf("sent log entries = %d; want 2", len(events)) + } + if events[0].ConnectionSeq != 1 || events[1].ConnectionSeq != 2 { + t.Fatalf("sent log connection seqs = %d, %d; want 1, 2", events[0].ConnectionSeq, events[1].ConnectionSeq) + } +} + +func TestNewClient_DisablesSentAccountingByDefault(t *testing.T) { + t.Setenv("KANDEV_E2E_MOCK", "") + h := newTestHub(t) + c := NewClient("conn-prod", nil, h, testLogger()) + registerTestClient(h, c) + + if c.sentLog != nil { + t.Fatal("sent log should be nil when E2E harness is disabled") + } + msg, err := ws.NewNotification("task.updated", map[string]string{"task_id": "task-1"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + if !c.sendMessage(msg) { + t.Fatal("send failed") + } + got := readStampedMessage(t, c) + if got.ConnectionID != "" || got.ConnectionSeq != 0 || got.SessionID != "" || got.SessionSeq != 0 { + t.Fatalf("production envelope carried accounting fields: %+v", got) + } + if events, maxSeq, ok := h.GetSentEventsFor("conn-prod", 0); ok || len(events) != 0 || maxSeq != 0 { + t.Fatalf("sent events = ok %v max %d events %+v; want disabled", ok, maxSeq, events) + } +} + +func TestNewClient_EnablesSentAccountingInHarness(t *testing.T) { + t.Setenv("KANDEV_E2E_MOCK", "true") + h := newTestHub(t) + c := NewClient("conn-harness", nil, h, testLogger()) + registerTestClient(h, c) + + if c.sentLog == nil { + t.Fatal("sent log should be allocated when E2E harness is enabled") + } + msg, err := ws.NewNotification("task.updated", map[string]string{"task_id": "task-1"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + if !c.sendMessage(msg) { + t.Fatal("send failed") + } + got := readStampedMessage(t, c) + if got.ConnectionID != "conn-harness" || got.ConnectionSeq != 1 { + t.Fatalf("accounting fields = connection_id %q seq %d; want conn-harness/1", + got.ConnectionID, got.ConnectionSeq) + } + events, maxSeq, ok := h.GetSentEventsFor("conn-harness", 0) + if !ok || maxSeq != 1 || len(events) != 1 || events[0].ConnectionSeq != 1 { + t.Fatalf("sent events = ok %v max %d events %+v; want one event at seq 1", ok, maxSeq, events) + } +} + +func TestClient_DroppedSendDoesNotRecordSentLog(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-full") + c.hub = h + registerTestClient(h, c) + + for range cap(c.send) { + c.send <- []byte(`{"type":"notification","action":"preloaded"}`) + } + msg, err := ws.NewNotification("task.updated", map[string]string{"task_id": "task-1"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + + if c.sendMessage(msg) { + t.Fatal("send unexpectedly succeeded with a full client buffer") + } + + events, maxSeq, ok := h.GetSentEventsFor("conn-full", 0) + if !ok { + t.Fatal("sent log lookup failed") + } + if len(events) != 0 || maxSeq != 0 { + t.Fatalf("sent log = max %d events %+v; want no logged sent events", maxSeq, events) + } + if got := c.connectionSeq.Load(); got != 0 { + t.Fatalf("connection seq after dropped send = %d; want 0", got) + } + + for range cap(c.send) { + <-c.send + } + next, err := ws.NewNotification("task.updated", map[string]string{"task_id": "task-2"}) + if err != nil { + t.Fatalf("next notification: %v", err) + } + if !c.sendMessage(next) { + t.Fatal("next send failed after draining buffer") + } + got := readStampedMessage(t, c) + if got.ConnectionSeq != 1 { + t.Fatalf("connection seq after dropped send = %d; want 1", got.ConnectionSeq) + } +} + +func TestClient_DroppedSessionSendDoesNotAdvanceSessionSequence(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-full-session-send") + c.hub = h + registerTestClient(h, c) + + for range cap(c.send) { + c.send <- []byte(`{"type":"notification","action":"preloaded"}`) + } + msg, err := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-a"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + + if c.sendMessageForSession("session-a", msg) { + t.Fatal("session send unexpectedly succeeded with a full client buffer") + } + if got := currentSessionSeq(h, "session-a"); got != 0 { + t.Fatalf("session seq after dropped send = %d; want 0", got) + } + + for range cap(c.send) { + <-c.send + } + if !c.sendMessageForSession("session-a", msg) { + t.Fatal("session send failed after draining buffer") + } + got := readStampedMessage(t, c) + if got.SessionSeq != 1 { + t.Fatalf("session seq after dropped send = %d; want 1", got.SessionSeq) + } + if got := currentSessionSeq(h, "session-a"); got != 1 { + t.Fatalf("hub session seq after successful send = %d; want 1", got) + } +} + +func TestClient_MarshalFailureDoesNotAdvanceConnectionSequence(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-marshal") + c.hub = h + registerTestClient(h, c) + + bad := &ws.Message{ + Type: ws.MessageTypeNotification, + Action: "task.updated", + Payload: json.RawMessage(`{`), + Timestamp: time.Now(), + } + if c.sendMessage(bad) { + t.Fatal("send unexpectedly succeeded with invalid JSON payload") + } + if got := c.connectionSeq.Load(); got != 0 { + t.Fatalf("connection seq after marshal failure = %d; want 0", got) + } + + good, err := ws.NewNotification("task.updated", map[string]string{"task_id": "task-1"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + if !c.sendMessage(good) { + t.Fatal("valid send failed after marshal failure") + } + got := readStampedMessage(t, c) + if got.ConnectionSeq != 1 { + t.Fatalf("connection seq after marshal failure = %d; want 1", got.ConnectionSeq) + } +} + +func TestClient_SessionMarshalFailureDoesNotAdvanceSessionSequence(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-session-marshal") + c.hub = h + registerTestClient(h, c) + + bad := &ws.Message{ + Type: ws.MessageTypeNotification, + Action: "session.message.added", + Payload: json.RawMessage(`{`), + Timestamp: time.Now(), + } + if c.sendMessageForSession("session-a", bad) { + t.Fatal("session send unexpectedly succeeded with invalid JSON payload") + } + if got := c.connectionSeq.Load(); got != 0 { + t.Fatalf("connection seq after marshal failure = %d; want 0", got) + } + if got := currentSessionSeq(h, "session-a"); got != 0 { + t.Fatalf("session seq after marshal failure = %d; want 0", got) + } + + good, err := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-a"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + if !c.sendMessageForSession("session-a", good) { + t.Fatal("valid session send failed after marshal failure") + } + got := readStampedMessage(t, c) + if got.ConnectionSeq != 1 || got.SessionSeq != 1 { + t.Fatalf("seqs after marshal failure = connection %d session %d; want 1/1", + got.ConnectionSeq, got.SessionSeq) + } +} + +func TestClient_SessionJSONMarshalFailureDoesNotAdvanceSessionSequence(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-session-json-marshal") + c.hub = h + registerTestClient(h, c) + + bad := &ws.Message{ + Type: ws.MessageTypeNotification, + Action: "session.message.added", + Payload: json.RawMessage(`{"session_id":"session-a"}`), + Timestamp: time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC), + } + if c.sendMessageForSession("session-a", bad) { + t.Fatal("session send unexpectedly succeeded with invalid timestamp") + } + if got := c.connectionSeq.Load(); got != 0 { + t.Fatalf("connection seq after marshal failure = %d; want 0", got) + } + if got := currentSessionSeq(h, "session-a"); got != 0 { + t.Fatalf("session seq after marshal failure = %d; want 0", got) + } + + good, err := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-a"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + if !c.sendMessageForSession("session-a", good) { + t.Fatal("valid session send failed after marshal failure") + } + got := readStampedMessage(t, c) + if got.ConnectionSeq != 1 || got.SessionSeq != 1 { + t.Fatalf("seqs after marshal failure = connection %d session %d; want 1/1", + got.ConnectionSeq, got.SessionSeq) + } +} + +func TestClient_SendSessionDataStampsConnectionOnly(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-data") + c.hub = h + registerTestClient(h, c) + + h.SetSessionDataProvider(func(_ context.Context, sessionID string) ([]*ws.Message, error) { + msg, err := ws.NewNotification("session.git.event", map[string]string{ + "session_id": sessionID, + "type": "status_update", + }) + if err != nil { + t.Fatalf("notification: %v", err) + } + return []*ws.Message{msg}, nil + }) + + c.sendSessionData("session-a") + + got := readStampedMessage(t, c) + if got.ConnectionSeq != 1 || got.SessionSeq != 0 { + t.Fatalf("session data seqs = connection %d session %d; want 1/0", got.ConnectionSeq, got.SessionSeq) + } + if got.ConnectionID != "conn-data" { + t.Fatalf("connection id = %q; want conn-data", got.ConnectionID) + } + + events, maxSeq, ok := h.GetSentEventsFor("conn-data", 0) + if !ok { + t.Fatal("connection sent log lookup failed") + } + if maxSeq != 1 || len(events) != 1 || events[0].SessionSeq != 0 || events[0].SessionID != "" { + t.Fatalf("connection sent log = max %d events %+v; want one connection-only event", maxSeq, events) + } + + sessionEvents, sessionMaxSeq, ok := h.GetSentEventsForSession("conn-data", "session-a") + if !ok { + t.Fatal("session sent log lookup failed") + } + if sessionMaxSeq != 0 || len(sessionEvents) != 0 { + t.Fatalf("session sent log = max %d events %+v; want no replay session events", sessionMaxSeq, sessionEvents) + } +} + +func TestClient_SendMessageStampsCopyWithoutMutatingMessage(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-copy") + c.hub = h + registerTestClient(h, c) + + msg, err := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-a"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + msg.ConnectionID = "caller-owned" + msg.ConnectionSeq = 41 + msg.SessionSeq = 42 + + if !c.sendMessageForSession("session-a", msg) { + t.Fatal("session send failed") + } + gotSession := readStampedMessage(t, c) + if gotSession.ConnectionID != "conn-copy" || gotSession.ConnectionSeq != 1 || gotSession.SessionSeq != 1 || gotSession.SessionID != "session-a" { + t.Fatalf("session stamped message = %+v; want conn-copy seq 1 session seq 1", gotSession) + } + if msg.ConnectionID != "caller-owned" || msg.ConnectionSeq != 41 || msg.SessionSeq != 42 { + t.Fatalf("source message mutated after session send: %+v", msg) + } + + if !c.sendMessage(msg) { + t.Fatal("connection send failed") + } + gotConnection := readStampedMessage(t, c) + if gotConnection.ConnectionID != "conn-copy" || gotConnection.ConnectionSeq != 2 || gotConnection.SessionSeq != 0 || gotConnection.SessionID != "" { + t.Fatalf("connection stamped message = %+v; want conn-copy seq 2 without session seq", gotConnection) + } + if msg.ConnectionID != "caller-owned" || msg.ConnectionSeq != 41 || msg.SessionSeq != 42 { + t.Fatalf("source message mutated after connection send: %+v", msg) + } +} + +func TestHub_BroadcastToSessionStampsSessionSequence(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-session") + c.hub = h + registerTestClient(h, c) + h.SubscribeToSession(c, "session-a") + h.SubscribeToSession(c, "session-b") + + msgA1, _ := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-a"}) + msgB1, _ := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-b"}) + msgA2, _ := ws.NewNotification("session.message.updated", map[string]string{"session_id": "session-a"}) + + h.BroadcastToSession("session-a", msgA1) + h.BroadcastToSession("session-b", msgB1) + h.BroadcastToSession("session-a", msgA2) + + gotA1 := readStampedMessage(t, c) + gotB1 := readStampedMessage(t, c) + gotA2 := readStampedMessage(t, c) + if gotA1.ConnectionSeq != 1 || gotB1.ConnectionSeq != 2 || gotA2.ConnectionSeq != 3 { + t.Fatalf("connection seqs = %d, %d, %d; want 1, 2, 3", + gotA1.ConnectionSeq, gotB1.ConnectionSeq, gotA2.ConnectionSeq) + } + if gotA1.SessionSeq != 1 || gotB1.SessionSeq != 1 || gotA2.SessionSeq != 2 { + t.Fatalf("session seqs = %d, %d, %d; want 1, 1, 2", + gotA1.SessionSeq, gotB1.SessionSeq, gotA2.SessionSeq) + } + + eventsA, maxA, ok := h.GetSentEventsForSession("conn-session", "session-a") + if !ok { + t.Fatal("session-a sent log lookup failed") + } + if maxA != 2 || len(eventsA) != 2 { + t.Fatalf("session-a sent log max/len = %d/%d; want 2/2", maxA, len(eventsA)) + } + for i, event := range eventsA { + want := int64(i + 1) + if event.SessionID != "session-a" || event.SessionSeq != want { + t.Fatalf("session-a event %d = %+v; want session_id=session-a session_seq=%d", i, event, want) + } + } + + eventsB, maxB, ok := h.GetSentEventsForSession("conn-session", "session-b") + if !ok { + t.Fatal("session-b sent log lookup failed") + } + if maxB != 1 || len(eventsB) != 1 || eventsB[0].SessionSeq != 1 { + t.Fatalf("session-b sent log = max %d events %+v; want one session_seq=1", maxB, eventsB) + } +} + +func TestHub_BroadcastToSessionDoesNotAdvanceSequenceWhenAllRecipientsFull(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-full-session") + c.hub = h + registerTestClient(h, c) + h.SubscribeToSession(c, "session-a") + + for range cap(c.send) { + c.send <- []byte(`{"type":"notification","action":"preloaded"}`) + } + msg, err := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-a"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + h.BroadcastToSession("session-a", msg) + + for range cap(c.send) { + <-c.send + } + h.BroadcastToSession("session-a", msg) + + got := readStampedMessage(t, c) + if got.SessionSeq != 1 { + t.Fatalf("session seq after all-recipient drop = %d; want 1", got.SessionSeq) + } +} + +func TestClient_FailedSessionSendDoesNotAdvanceSessionSequence(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-full-after-check") + c.hub = h + registerTestClient(h, c) + + for range cap(c.send) { + c.send <- []byte(`{"type":"notification","action":"preloaded"}`) + } + msg, err := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-a"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + if c.sendMessageForSessionSeq("session-a", 0, msg) { + t.Fatal("session send unexpectedly succeeded with a full client buffer") + } + if got := currentSessionSeq(h, "session-a"); got != 0 { + t.Fatalf("session seq after failed send = %d; want 0", got) + } + + for range cap(c.send) { + <-c.send + } + if !c.sendMessageForSessionSeq("session-a", 0, msg) { + t.Fatal("session send failed after draining buffer") + } + got := readStampedMessage(t, c) + if got.SessionSeq != 1 { + t.Fatalf("session seq after first successful send = %d; want 1", got.SessionSeq) + } +} + +func TestHub_BroadcastToSessionSequenceSurvivesResubscribe(t *testing.T) { + h := newTestHub(t) + c := newTestClient("conn-resubscribe") + c.hub = h + registerTestClient(h, c) + h.SubscribeToSession(c, "session-a") + + msg, err := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-a"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + h.BroadcastToSession("session-a", msg) + first := readStampedMessage(t, c) + + h.UnsubscribeFromSession(c, "session-a") + h.SubscribeToSession(c, "session-a") + h.BroadcastToSession("session-a", msg) + second := readStampedMessage(t, c) + + if first.SessionSeq != 1 || second.SessionSeq != 2 { + t.Fatalf("session seqs across resubscribe = %d, %d; want 1, 2", first.SessionSeq, second.SessionSeq) + } +} + +func TestHub_BroadcastToSessionUsesOneSessionSequencePerLogicalEvent(t *testing.T) { + h := newTestHub(t) + first := newTestClient("conn-1") + second := newTestClient("conn-2") + first.hub = h + second.hub = h + registerTestClient(h, first) + registerTestClient(h, second) + h.SubscribeToSession(first, "session-a") + h.SubscribeToSession(second, "session-a") + + msg, err := ws.NewNotification("session.message.added", map[string]string{"session_id": "session-a"}) + if err != nil { + t.Fatalf("notification: %v", err) + } + h.BroadcastToSession("session-a", msg) + + gotFirst := readStampedMessage(t, first) + gotSecond := readStampedMessage(t, second) + if gotFirst.SessionSeq != 1 || gotSecond.SessionSeq != 1 { + t.Fatalf("session seqs = %d, %d; want both recipients to receive seq 1", + gotFirst.SessionSeq, gotSecond.SessionSeq) + } + + firstEvents, firstMax, ok := h.GetSentEventsForSession("conn-1", "session-a") + if !ok { + t.Fatal("first sent log lookup failed") + } + secondEvents, secondMax, ok := h.GetSentEventsForSession("conn-2", "session-a") + if !ok { + t.Fatal("second sent log lookup failed") + } + if firstMax != 1 || secondMax != 1 || len(firstEvents) != 1 || len(secondEvents) != 1 { + t.Fatalf("sent logs = first max %d events %+v second max %d events %+v; want one seq=1 each", + firstMax, firstEvents, secondMax, secondEvents) + } +} + +func TestWsSentLogEvictsOldestAndFiltersSince(t *testing.T) { + log := newWsSentLogWithCapacity(3) + base := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + for seq := int64(1); seq <= 4; seq++ { + log.Append(seq, 0, "", "notification", "task.updated", base.Add(time.Duration(seq)*time.Second)) + } + + all := log.Since(0) + if len(all) != 3 { + t.Fatalf("entries after eviction = %d; want 3", len(all)) + } + if all[0].ConnectionSeq != 2 || all[2].ConnectionSeq != 4 { + t.Fatalf("evicted entries = %+v; want seqs 2..4", all) + } + if got := log.MaxConnectionSeq(); got != 4 { + t.Fatalf("max connection seq = %d; want 4", got) + } + + filtered := log.Since(2) + if len(filtered) != 2 || filtered[0].ConnectionSeq != 3 || filtered[1].ConnectionSeq != 4 { + t.Fatalf("filtered entries = %+v; want seqs 3,4", filtered) + } +} + +func TestWsSentLogIndexesSessionEntriesAndEvictsFromIndex(t *testing.T) { + log := newWsSentLogWithCapacity(3) + base := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + log.Append(1, 1, "session-a", "notification", "session.message.added", base) + log.Append(2, 1, "session-b", "notification", "session.message.added", base.Add(time.Second)) + log.Append(3, 0, "", "notification", "task.updated", base.Add(2*time.Second)) + log.Append(4, 2, "session-a", "notification", "session.message.updated", base.Add(3*time.Second)) + + sessionA := log.SinceForSession("session-a") + if len(sessionA) != 1 || sessionA[0].ConnectionSeq != 4 || sessionA[0].SessionSeq != 2 { + t.Fatalf("session-a entries = %+v; want only non-evicted seq 2", sessionA) + } + sessionB := log.SinceForSession("session-b") + if len(sessionB) != 1 || sessionB[0].ConnectionSeq != 2 { + t.Fatalf("session-b entries = %+v; want connection seq 2", sessionB) + } +} + +func TestWsSentLogClearsEvictedSessionIndexSlot(t *testing.T) { + log := newWsSentLogWithCapacity(3) + base := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + log.Append(1, 1, "session-a", "notification", "session.message.added", base) + log.Append(2, 2, "session-a", "notification", "session.message.updated", base.Add(time.Second)) + log.Append(3, 1, "session-b", "notification", "session.message.added", base.Add(2*time.Second)) + log.Append(4, 2, "session-b", "notification", "session.message.updated", base.Add(3*time.Second)) + + log.mu.RLock() + entries := log.bySession["session-a"] + if len(entries) != 1 || entries[0].ConnectionSeq != 2 { + log.mu.RUnlock() + t.Fatalf("session-a entries = %+v; want only connection seq 2", entries) + } + backingEntries := entries[:cap(entries)] + if len(backingEntries) > len(entries) && backingEntries[len(entries)].Action != "" { + log.mu.RUnlock() + t.Fatalf("evicted backing slot was not cleared: %+v", backingEntries[len(entries)]) + } + log.mu.RUnlock() +} + +func TestWsSentLogConcurrentAppendAndRead(t *testing.T) { + const ( + workers = 8 + entriesPerRun = 200 + capacity = 256 + ) + log := newWsSentLogWithCapacity(capacity) + base := time.Date(2026, 6, 23, 12, 0, 0, 0, time.UTC) + var nextSeq atomic.Int64 + var wg sync.WaitGroup + + for worker := range workers { + wg.Add(1) + go func(worker int) { + defer wg.Done() + sessionID := fmt.Sprintf("session-%d", worker%3) + for i := range entriesPerRun { + connectionSeq := nextSeq.Add(1) + entrySessionID := "" + sessionSeq := int64(0) + if i%2 == 0 { + entrySessionID = sessionID + sessionSeq = int64(i/2 + 1) + } + log.Append( + connectionSeq, + sessionSeq, + entrySessionID, + "notification", + "session.message.updated", + base.Add(time.Duration(connectionSeq)*time.Millisecond), + ) + if i%10 == 0 { + _ = log.Since(connectionSeq - 20) + _ = log.SinceForSession(sessionID) + } + } + }(worker) + } + wg.Wait() + + events := log.Since(0) + if len(events) > capacity { + t.Fatalf("events len = %d; want <= %d", len(events), capacity) + } + seen := make(map[int64]bool, len(events)) + for _, event := range events { + if seen[event.ConnectionSeq] { + t.Fatalf("duplicate connection seq in sent log: %d", event.ConnectionSeq) + } + seen[event.ConnectionSeq] = true + } + if got, want := log.MaxConnectionSeq(), int64(workers*entriesPerRun); got != want { + t.Fatalf("max connection seq = %d; want %d", got, want) + } + for sessionIndex := range 3 { + sessionID := fmt.Sprintf("session-%d", sessionIndex) + sessionEvents := log.SinceForSession(sessionID) + for i, event := range sessionEvents { + if event.SessionID != sessionID { + t.Fatalf("%s event has session_id=%q", sessionID, event.SessionID) + } + if i > 0 && sessionEvents[i-1].SessionSeq > event.SessionSeq { + t.Fatalf("%s events not sorted by session seq: %+v", sessionID, sessionEvents) + } + } + } +} + +func TestWsSentLogRejectsInvalidCapacity(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("newWsSentLogWithCapacity(0) did not panic") + } + }() + _ = newWsSentLogWithCapacity(0) +} + +func currentSessionSeq(h *Hub, sessionID string) int64 { + value, ok := h.sessionSeqs.Load(sessionID) + if !ok { + return 0 + } + return value.(*atomic.Int64).Load() +} + +func readStampedMessage(t *testing.T, c *Client) ws.Message { + t.Helper() + select { + case raw := <-c.send: + var msg ws.Message + if err := json.Unmarshal(raw, &msg); err != nil { + t.Fatalf("decode stamped message: %v", err) + } + return msg + default: + t.Fatal("client send channel was empty") + return ws.Message{} + } +} 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 0000000000..d31ca1adb4 --- /dev/null +++ b/apps/backend/internal/gateway/websocket/ws_sent_log.go @@ -0,0 +1,141 @@ +package websocket + +import ( + "sort" + "sync" + "time" +) + +const wsSentLogCapacity = 5000 + +type wsSentEntry = WsSentEvent + +type wsSentLog struct { + mu sync.RWMutex + entries []wsSentEntry + bySession map[string][]wsSentEntry + head int + size int + maxConnectionSeq int64 +} + +func newWsSentLog() *wsSentLog { + return newWsSentLogWithCapacity(wsSentLogCapacity) +} + +func newWsSentLogWithCapacity(capacity int) *wsSentLog { + if capacity <= 0 { + panic("websocket sent log capacity must be positive") + } + return &wsSentLog{ + entries: make([]wsSentEntry, capacity), + bySession: make(map[string][]wsSentEntry), + } +} + +func (l *wsSentLog) Append( + connectionSeq int64, + sessionSeq int64, + sessionID string, + msgType string, + action string, + sentAt time.Time, +) { + l.mu.Lock() + defer l.mu.Unlock() + + if l.size == len(l.entries) { + l.removeSessionEntryLocked(l.entries[l.head]) + } + + entry := wsSentEntry{ + ConnectionSeq: connectionSeq, + SessionSeq: sessionSeq, + SessionID: sessionID, + Type: msgType, + Action: action, + SentAt: sentAt, + } + l.entries[l.head] = entry + if sessionID != "" { + l.bySession[sessionID] = append(l.bySession[sessionID], entry) + } + l.head = (l.head + 1) % len(l.entries) + if l.size < len(l.entries) { + l.size++ + } + if connectionSeq > l.maxConnectionSeq { + l.maxConnectionSeq = connectionSeq + } +} + +func (l *wsSentLog) Since(sinceConnectionSeq int64) []wsSentEntry { + l.mu.RLock() + defer l.mu.RUnlock() + + out := make([]wsSentEntry, 0, l.size) + l.eachLocked(func(e wsSentEntry) { + if e.ConnectionSeq > sinceConnectionSeq { + out = append(out, e) + } + }) + sort.Slice(out, func(i, j int) bool { + return out[i].ConnectionSeq < out[j].ConnectionSeq + }) + return out +} + +func (l *wsSentLog) SinceForSession(sessionID string) []wsSentEntry { + l.mu.RLock() + defer l.mu.RUnlock() + + if sessionID == "" { + return nil + } + out := append([]wsSentEntry(nil), l.bySession[sessionID]...) + sort.Slice(out, func(i, j int) bool { + return out[i].SessionSeq < out[j].SessionSeq + }) + return out +} + +func (l *wsSentLog) MaxConnectionSeq() int64 { + l.mu.RLock() + defer l.mu.RUnlock() + return l.maxConnectionSeq +} + +func (l *wsSentLog) eachLocked(fn func(wsSentEntry)) { + if l.size == 0 { + return + } + start := 0 + if l.size == len(l.entries) { + start = l.head + } + for i := 0; i < l.size; i++ { + fn(l.entries[(start+i)%len(l.entries)]) + } +} + +func (l *wsSentLog) removeSessionEntryLocked(entry wsSentEntry) { + if entry.SessionID == "" { + return + } + entries := l.bySession[entry.SessionID] + for i, candidate := range entries { + if candidate.ConnectionSeq != entry.ConnectionSeq { + continue + } + last := len(entries) - 1 + copy(entries[i:], entries[i+1:]) + entries[last] = wsSentEntry{} + entries = entries[:last] + break + } + if len(entries) == 0 { + delete(l.bySession, entry.SessionID) + return + } + l.bySession[entry.SessionID] = entries +} diff --git a/apps/backend/internal/github/mock_controller.go b/apps/backend/internal/github/mock_controller.go index a1180c4120..3b74ac6eaf 100644 --- a/apps/backend/internal/github/mock_controller.go +++ b/apps/backend/internal/github/mock_controller.go @@ -414,6 +414,9 @@ func (c *MockController) seedPRFeedback(ctx *gin.Context) { c.mock.ReplaceCheckRuns(req.Owner, req.Repo, headSHA, req.Checks) c.mock.ReplaceReviews(req.Owner, req.Repo, req.PRNumber, req.Reviews) c.mock.ReplaceComments(req.Owner, req.Repo, req.PRNumber, req.Comments) + if c.service != nil { + c.service.ClearPRCaches() + } ctx.JSON(http.StatusOK, gin.H{ "checks": len(req.Checks), "reviews": len(req.Reviews), @@ -446,6 +449,7 @@ func (c *MockController) reset(ctx *gin.Context) { c.mock.Reset() if c.service != nil { c.service.ClearAccessibleReposCaches() + c.service.ClearPRCaches() } ctx.JSON(http.StatusOK, gin.H{"reset": true}) } diff --git a/apps/backend/internal/github/mock_controller_test.go b/apps/backend/internal/github/mock_controller_test.go index 68d6675d4a..5e435cf5d6 100644 --- a/apps/backend/internal/github/mock_controller_test.go +++ b/apps/backend/internal/github/mock_controller_test.go @@ -21,6 +21,17 @@ func setupMockControllerTestForAddIssues() (*gin.Engine, *MockClient) { return router, mock } +func setupMockControllerTestWithService() (*gin.Engine, *Service) { + gin.SetMode(gin.TestMode) + mock := NewMockClient() + log := newControllerTestLogger() + svc := NewService(mock, "pat", nil, nil, nil, log) + ctrl := NewMockController(mock, nil, nil, svc, log) + router := gin.New() + ctrl.RegisterRoutes(router) + return router, svc +} + func TestMockControllerAddIssues(t *testing.T) { router, mock := setupMockControllerTestForAddIssues() body := bytes.NewBufferString(`{"issues":[{"number":1456,"title":"Fix picker","repo_owner":"owner","repo_name":"repo"}]}`) @@ -64,6 +75,55 @@ func TestMockControllerAddIssuesInvalidPayload(t *testing.T) { } } +func TestMockControllerSeedPRFeedbackClearsServiceCache(t *testing.T) { + router, svc := setupMockControllerTestWithService() + + seedPRFeedbackForTest(t, router, `[{ + "name":"Old / job", + "status":"completed", + "conclusion":"failure", + "html_url":"https://example.com/old" + }]`) + got, err := svc.GetPRFeedback(context.Background(), "acme", "demo", 42) + if err != nil { + t.Fatalf("GetPRFeedback first fetch: %v", err) + } + if len(got.Checks) != 1 || got.Checks[0].Name != "Old / job" { + t.Fatalf("first checks = %#v, want Old / job", got.Checks) + } + + seedPRFeedbackForTest(t, router, `[{ + "name":"New / job", + "status":"completed", + "conclusion":"success", + "html_url":"https://example.com/new" + }]`) + got, err = svc.GetPRFeedback(context.Background(), "acme", "demo", 42) + if err != nil { + t.Fatalf("GetPRFeedback second fetch: %v", err) + } + if len(got.Checks) != 1 || got.Checks[0].Name != "New / job" { + t.Fatalf("second checks = %#v, want New / job after reseed", got.Checks) + } +} + +func seedPRFeedbackForTest(t *testing.T, router *gin.Engine, checksJSON string) { + t.Helper() + body := bytes.NewBufferString(`{ + "owner":"acme", + "repo":"demo", + "pr_number":42, + "checks":` + checksJSON + ` + }`) + req := httptest.NewRequest(http.MethodPost, "/api/v1/github/mock/pr-feedback", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("seed PR feedback: got %d: %s", w.Code, w.Body.String()) + } +} + func TestEnsureMockPRForRequestCopiesMergeableState(t *testing.T) { mock := NewMockClient() controller := &MockController{mock: mock} diff --git a/apps/backend/internal/orchestrator/prompt_idle_session_test.go b/apps/backend/internal/orchestrator/prompt_idle_session_test.go index 1fa80b14dc..dcdd78f5e6 100644 --- a/apps/backend/internal/orchestrator/prompt_idle_session_test.go +++ b/apps/backend/internal/orchestrator/prompt_idle_session_test.go @@ -5,14 +5,10 @@ import ( "errors" "fmt" "strings" - "sync/atomic" "testing" "time" - "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/orchestrator/executor" - "github.com/kandev/kandev/internal/orchestrator/queue" - "github.com/kandev/kandev/internal/orchestrator/scheduler" "github.com/kandev/kandev/internal/task/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -616,271 +612,3 @@ func TestEnsureSessionRunning_CancelledContextDoesNotReapExecution(t *testing.T) t.Fatalf("expected caller cancellation, got %v", err) } } - -type promptStreamRecoveringAgentManager struct { - *mockAgentManager - recovered atomic.Bool -} - -func (m *promptStreamRecoveringAgentManager) RecoverAgentPromptStream(context.Context, string) error { - m.recovered.Store(true) - return nil -} - -func TestPromptTask_StalePromptStreamRecoversBeforeTimeout(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) - session, err := repo.GetTaskSession(context.Background(), "session1") - if err != nil { - t.Fatalf("failed to load session: %v", err) - } - session.AgentProfileID = "profile1" - if err := repo.UpdateTaskSession(context.Background(), session); err != nil { - t.Fatalf("failed to update session: %v", err) - } - seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-stale-stream") - - baseMgr := &mockAgentManager{ - isAgentRunning: true, - repoForExecutionLookup: repo, - } - agentMgr := &promptStreamRecoveringAgentManager{mockAgentManager: baseMgr} - baseMgr.isAgentReadyFn = func(context.Context, string) bool { - return agentMgr.recovered.Load() - } - - taskRepo := newMockTaskRepo() - taskRepo.tasks["task1"] = &v1.Task{ - ID: "task1", - Title: "Test Task", - State: v1.TaskStateInProgress, - } - svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) - svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) - - _, err = svc.PromptTask(ctx, "task1", "session1", "are you stuck?", "", false, nil, false) - if err != nil { - t.Fatalf("expected stale prompt stream to recover, got: %v", err) - } - if !agentMgr.recovered.Load() { - t.Fatal("expected prompt stream recovery to run") - } - if len(agentMgr.capturedPromptCalls) != 1 { - t.Fatalf("expected prompt to be delivered after recovery, got %d calls", len(agentMgr.capturedPromptCalls)) - } -} - -func TestPromptTask_ReapsPromptDeadExecutionBeforeSend(t *testing.T) { - oldReadyTimeout := agentPromptReadyTimeout - oldReadyInterval := agentPromptReadyInterval - agentPromptReadyTimeout = 20 * time.Millisecond - agentPromptReadyInterval = time.Millisecond - t.Cleanup(func() { - agentPromptReadyTimeout = oldReadyTimeout - agentPromptReadyInterval = oldReadyInterval - }) - - ctx := context.Background() - pollCtx, cancelPoll := context.WithCancel(context.Background()) - t.Cleanup(cancelPoll) - repo := setupTestRepo(t) - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) - session, err := repo.GetTaskSession(ctx, "session1") - if err != nil { - t.Fatalf("failed to load session: %v", err) - } - session.AgentProfileID = "profile1" - if err := repo.UpdateTaskSession(ctx, session); err != nil { - t.Fatalf("failed to update session: %v", err) - } - - now := time.Now().UTC() - if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ - ID: "er1", - SessionID: "session1", - TaskID: "task1", - AgentExecutionID: "exec-zombie", - Status: "running", - Resumable: true, - ResumeToken: "resume-token-123", - CreatedAt: now, - UpdatedAt: now, - }); err != nil { - t.Fatalf("failed to seed executor running: %v", err) - } - - var zombieRunning atomic.Bool - zombieRunning.Store(true) - var replacementReady atomic.Bool - var launchCalls atomic.Int32 - agentMgr := &mockAgentManager{ - repoForExecutionLookup: repo, - isAgentRunningFn: func(_ context.Context, _ string) bool { - return zombieRunning.Load() - }, - isAgentReadyFn: func(_ context.Context, _ string) bool { - return replacementReady.Load() - }, - stopAgentWithReasonFunc: func(_ context.Context, _ string, _ string, _ bool) error { - zombieRunning.Store(false) - return nil - }, - launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { - launchCalls.Add(1) - if req.ACPSessionID != "resume-token-123" { - t.Errorf("expected preserved resume token, got %q", req.ACPSessionID) - } - running, err := repo.GetExecutorRunningBySessionID(context.Background(), req.SessionID) - if err != nil { - t.Errorf("reload executor running: %v", err) - } else { - running.AgentExecutionID = "exec-replacement" - running.Status = "ready" - if err := repo.UpsertExecutorRunning(context.Background(), running); err != nil { - t.Errorf("persist replacement executor running: %v", err) - } - } - go func(ctx context.Context, sessID string) { - tick := time.NewTicker(5 * time.Millisecond) - defer tick.Stop() - timeout := time.NewTimer(5 * time.Second) - defer timeout.Stop() - for { - select { - case <-ctx.Done(): - return - case <-tick.C: - sess, err := repo.GetTaskSession(context.Background(), sessID) - if err == nil && sess != nil && sess.State == models.TaskSessionStateStarting { - sess.State = models.TaskSessionStateWaitingForInput - sess.UpdatedAt = time.Now().UTC() - _ = repo.UpdateTaskSession(context.Background(), sess) - replacementReady.Store(true) - return - } - case <-timeout.C: - return - } - } - }(pollCtx, req.SessionID) - return &executor.LaunchAgentResponse{AgentExecutionID: "exec-replacement"}, nil - }, - } - - taskRepo := newMockTaskRepo() - taskRepo.tasks["task1"] = &v1.Task{ - ID: "task1", - Title: "Test Task", - State: v1.TaskStateInProgress, - } - svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) - svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) - - if _, err := svc.PromptTask(ctx, "task1", "session1", "follow up", "", false, nil, false); err != nil { - t.Fatalf("expected prompt-dead execution to be replaced before send, got: %v", err) - } - if got := launchCalls.Load(); got != 1 { - t.Fatalf("expected one replacement launch, got %d", got) - } - if len(agentMgr.capturedPromptCalls) != 1 { - t.Fatalf("expected one delivered prompt, got %d", len(agentMgr.capturedPromptCalls)) - } - if got := agentMgr.capturedPromptCalls[0].ExecutionID; got != "exec-replacement" { - t.Fatalf("expected prompt to target replacement execution, got %q", got) - } - - running, err := repo.GetExecutorRunningBySessionID(ctx, "session1") - if err != nil { - t.Fatalf("failed to reload executor running row: %v", err) - } - if running.ResumeToken != "resume-token-123" { - t.Fatalf("expected resume token to be preserved, got %q", running.ResumeToken) - } -} - -func TestPromptTask_LazyResumeExecutionNotFoundFallsBackToFreshLaunch(t *testing.T) { - ctx := context.Background() - repo := setupTestRepo(t) - - seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateIdle) - session, err := repo.GetTaskSession(ctx, "session1") - if err != nil { - t.Fatalf("failed to load session: %v", err) - } - session.AgentExecutionID = "exec-before-restart" - session.AgentProfileID = "profile1" - if err := repo.UpdateTaskSession(ctx, session); err != nil { - t.Fatalf("failed to update session: %v", err) - } - seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-before-restart") - - var launchCalls atomic.Int32 - launchPrompts := make(chan string, 2) - agentMgr := &mockAgentManager{ - repoForExecutionLookup: repo, - promptErr: lifecycle.ErrExecutionNotFound, - isAgentRunningFn: func(_ context.Context, _ string) bool { - return launchCalls.Load() > 0 - }, - isAgentReadyFn: func(_ context.Context, _ string) bool { - return launchCalls.Load() > 0 - }, - launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { - call := launchCalls.Add(1) - launchPrompts <- req.TaskDescription - if call == 1 { - go func(sessionID string) { - tick := time.NewTicker(5 * time.Millisecond) - defer tick.Stop() - timeout := time.After(5 * time.Second) - for { - select { - case <-tick.C: - sess, err := repo.GetTaskSession(context.Background(), sessionID) - if err == nil && sess != nil && sess.State == models.TaskSessionStateStarting { - sess.State = models.TaskSessionStateWaitingForInput - sess.UpdatedAt = time.Now().UTC() - _ = repo.UpdateTaskSession(context.Background(), sess) - return - } - case <-timeout: - return - } - } - }(req.SessionID) - } - return &executor.LaunchAgentResponse{AgentExecutionID: fmt.Sprintf("exec-resumed-%d", call)}, nil - }, - } - - taskRepo := newMockTaskRepo() - taskRepo.tasks["task1"] = &v1.Task{ - ID: "task1", - Title: "Test Task", - State: v1.TaskStateInProgress, - } - svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) - exec := executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) - svc.executor = exec - svc.scheduler = scheduler.NewScheduler(queue.NewTaskQueue(100), exec, taskRepo, testLogger(), scheduler.DefaultSchedulerConfig()) - - prompt := "follow-up after restart" - if _, err := svc.PromptTask(ctx, "task1", "session1", prompt, "", false, nil, false); err != nil { - t.Fatalf("expected fresh-launch fallback to recover missing execution, got: %v", err) - } - - if got := launchCalls.Load(); got != 2 { - t.Fatalf("expected resume launch plus fresh fallback launch, got %d launches", got) - } - <-launchPrompts // resume prompt; empty for the lazy resume path. - freshPrompt := <-launchPrompts - if !strings.Contains(freshPrompt, prompt) { - t.Fatalf("fresh launch prompt %q does not contain original prompt %q", freshPrompt, prompt) - } - if len(agentMgr.capturedPromptCalls) != 1 { - t.Fatalf("expected one failed PromptAgent attempt before fallback, got %d", len(agentMgr.capturedPromptCalls)) - } -} diff --git a/apps/backend/internal/orchestrator/prompt_task_resume_recovery_test.go b/apps/backend/internal/orchestrator/prompt_task_resume_recovery_test.go new file mode 100644 index 0000000000..44ae24e3dc --- /dev/null +++ b/apps/backend/internal/orchestrator/prompt_task_resume_recovery_test.go @@ -0,0 +1,373 @@ +package orchestrator + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/queue" + "github.com/kandev/kandev/internal/orchestrator/scheduler" + "github.com/kandev/kandev/internal/task/models" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type promptStreamRecoveringAgentManager struct { + *mockAgentManager + recovered atomic.Bool +} + +func (m *promptStreamRecoveringAgentManager) RecoverAgentPromptStream(context.Context, string) error { + m.recovered.Store(true) + return nil +} + +func TestPromptTask_StalePromptStreamRecoversBeforeTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + session, err := repo.GetTaskSession(context.Background(), "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + session.AgentProfileID = "profile1" + if err := repo.UpdateTaskSession(context.Background(), session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-stale-stream") + + baseMgr := &mockAgentManager{ + isAgentRunning: true, + repoForExecutionLookup: repo, + } + agentMgr := &promptStreamRecoveringAgentManager{mockAgentManager: baseMgr} + baseMgr.isAgentReadyFn = func(context.Context, string) bool { + return agentMgr.recovered.Load() + } + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ + ID: "task1", + Title: "Test Task", + State: v1.TaskStateInProgress, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + _, err = svc.PromptTask(ctx, "task1", "session1", "are you stuck?", "", false, nil, false) + if err != nil { + t.Fatalf("expected stale prompt stream to recover, got: %v", err) + } + if !agentMgr.recovered.Load() { + t.Fatal("expected prompt stream recovery to run") + } + if len(agentMgr.capturedPromptCalls) != 1 { + t.Fatalf("expected prompt to be delivered after recovery, got %d calls", len(agentMgr.capturedPromptCalls)) + } +} + +func TestPromptTask_ReapsPromptDeadExecutionBeforeSend(t *testing.T) { + oldReadyTimeout := agentPromptReadyTimeout + oldReadyInterval := agentPromptReadyInterval + agentPromptReadyTimeout = 20 * time.Millisecond + agentPromptReadyInterval = time.Millisecond + t.Cleanup(func() { + agentPromptReadyTimeout = oldReadyTimeout + agentPromptReadyInterval = oldReadyInterval + }) + + ctx := context.Background() + pollCtx, cancelPoll := context.WithCancel(context.Background()) + t.Cleanup(cancelPoll) + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateWaitingForInput) + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + session.AgentProfileID = "profile1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + + now := time.Now().UTC() + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "er1", + SessionID: "session1", + TaskID: "task1", + AgentExecutionID: "exec-zombie", + Status: "running", + Resumable: true, + ResumeToken: "resume-token-123", + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + t.Fatalf("failed to seed executor running: %v", err) + } + + var zombieRunning atomic.Bool + zombieRunning.Store(true) + var replacementReady atomic.Bool + var launchCalls atomic.Int32 + agentMgr := &mockAgentManager{ + repoForExecutionLookup: repo, + isAgentRunningFn: func(_ context.Context, _ string) bool { + return zombieRunning.Load() + }, + isAgentReadyFn: func(_ context.Context, _ string) bool { + return replacementReady.Load() + }, + stopAgentWithReasonFunc: func(_ context.Context, _ string, _ string, _ bool) error { + zombieRunning.Store(false) + return nil + }, + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + launchCalls.Add(1) + if req.ACPSessionID != "resume-token-123" { + t.Errorf("expected preserved resume token, got %q", req.ACPSessionID) + } + running, err := repo.GetExecutorRunningBySessionID(context.Background(), req.SessionID) + if err != nil { + t.Errorf("reload executor running: %v", err) + } else { + running.AgentExecutionID = "exec-replacement" + running.Status = "ready" + if err := repo.UpsertExecutorRunning(context.Background(), running); err != nil { + t.Errorf("persist replacement executor running: %v", err) + } + } + go func(ctx context.Context, sessID string) { + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + timeout := time.NewTimer(5 * time.Second) + defer timeout.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + sess, err := repo.GetTaskSession(context.Background(), sessID) + if err == nil && sess != nil && sess.State == models.TaskSessionStateStarting { + sess.State = models.TaskSessionStateWaitingForInput + sess.UpdatedAt = time.Now().UTC() + _ = repo.UpdateTaskSession(context.Background(), sess) + replacementReady.Store(true) + return + } + case <-timeout.C: + return + } + } + }(pollCtx, req.SessionID) + return &executor.LaunchAgentResponse{AgentExecutionID: "exec-replacement"}, nil + }, + } + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ + ID: "task1", + Title: "Test Task", + State: v1.TaskStateInProgress, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + if _, err := svc.PromptTask(ctx, "task1", "session1", "follow up", "", false, nil, false); err != nil { + t.Fatalf("expected prompt-dead execution to be replaced before send, got: %v", err) + } + if got := launchCalls.Load(); got != 1 { + t.Fatalf("expected one replacement launch, got %d", got) + } + if len(agentMgr.capturedPromptCalls) != 1 { + t.Fatalf("expected one delivered prompt, got %d", len(agentMgr.capturedPromptCalls)) + } + if got := agentMgr.capturedPromptCalls[0].ExecutionID; got != "exec-replacement" { + t.Fatalf("expected prompt to target replacement execution, got %q", got) + } + + running, err := repo.GetExecutorRunningBySessionID(ctx, "session1") + if err != nil { + t.Fatalf("failed to reload executor running row: %v", err) + } + if running.ResumeToken != "resume-token-123" { + t.Fatalf("expected resume token to be preserved, got %q", running.ResumeToken) + } +} + +func TestPromptTask_LazyResumeExecutionNotFoundFallsBackToFreshLaunch(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateIdle) + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + session.AgentExecutionID = "exec-before-restart" + session.AgentProfileID = "profile1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-before-restart") + + var launchCalls atomic.Int32 + launchPrompts := make(chan string, 2) + agentMgr := &mockAgentManager{ + repoForExecutionLookup: repo, + promptErr: lifecycle.ErrExecutionNotFound, + isAgentRunningFn: func(_ context.Context, _ string) bool { + return launchCalls.Load() > 0 + }, + isAgentReadyFn: func(_ context.Context, _ string) bool { + return launchCalls.Load() > 0 + }, + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + call := launchCalls.Add(1) + launchPrompts <- req.TaskDescription + if call == 1 { + go func(sessionID string) { + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + timeout := time.After(5 * time.Second) + for { + select { + case <-tick.C: + sess, err := repo.GetTaskSession(context.Background(), sessionID) + if err == nil && sess != nil && sess.State == models.TaskSessionStateStarting { + sess.State = models.TaskSessionStateWaitingForInput + sess.UpdatedAt = time.Now().UTC() + _ = repo.UpdateTaskSession(context.Background(), sess) + return + } + case <-timeout: + return + } + } + }(req.SessionID) + } + return &executor.LaunchAgentResponse{AgentExecutionID: fmt.Sprintf("exec-resumed-%d", call)}, nil + }, + } + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ + ID: "task1", + Title: "Test Task", + State: v1.TaskStateInProgress, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + exec := executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + svc.executor = exec + svc.scheduler = scheduler.NewScheduler(queue.NewTaskQueue(100), exec, taskRepo, testLogger(), scheduler.DefaultSchedulerConfig()) + + prompt := "follow-up after restart" + if _, err := svc.PromptTask(ctx, "task1", "session1", prompt, "", false, nil, false); err != nil { + t.Fatalf("expected fresh-launch fallback to recover missing execution, got: %v", err) + } + + if got := launchCalls.Load(); got != 2 { + t.Fatalf("expected resume launch plus fresh fallback launch, got %d launches", got) + } + <-launchPrompts // resume prompt; empty for the lazy resume path. + freshPrompt := <-launchPrompts + if !strings.Contains(freshPrompt, prompt) { + t.Fatalf("fresh launch prompt %q does not contain original prompt %q", freshPrompt, prompt) + } + if len(agentMgr.capturedPromptCalls) != 1 { + t.Fatalf("expected one failed PromptAgent attempt before fallback, got %d", len(agentMgr.capturedPromptCalls)) + } +} + +func TestPromptTask_LazyResumeMissingACPSessionFallsBackToFreshLaunch(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateIdle) + session, err := repo.GetTaskSession(ctx, "session1") + if err != nil { + t.Fatalf("failed to load session: %v", err) + } + session.AgentExecutionID = "exec-before-restart" + session.AgentProfileID = "profile1" + if err := repo.UpdateTaskSession(ctx, session); err != nil { + t.Fatalf("failed to update session: %v", err) + } + seedExecutorRunning(t, repo, session.ID, session.TaskID, "exec-before-restart") + + var launchCalls atomic.Int32 + launchPrompts := make(chan string, 2) + agentMgr := &mockAgentManager{ + repoForExecutionLookup: repo, + promptErr: fmt.Errorf( + "%w: session not found: %w", + lifecycle.ErrAgentReported, + lifecycle.ErrExecutionNotFound, + ), + isAgentRunningFn: func(_ context.Context, _ string) bool { + return launchCalls.Load() > 0 + }, + isAgentReadyFn: func(_ context.Context, _ string) bool { + return launchCalls.Load() > 0 + }, + launchAgentFunc: func(_ context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) { + call := launchCalls.Add(1) + launchPrompts <- req.TaskDescription + if call == 1 { + go func(sessionID string) { + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + timeout := time.After(5 * time.Second) + for { + select { + case <-tick.C: + sess, err := repo.GetTaskSession(context.Background(), sessionID) + if err == nil && sess != nil && sess.State == models.TaskSessionStateStarting { + sess.State = models.TaskSessionStateWaitingForInput + sess.UpdatedAt = time.Now().UTC() + _ = repo.UpdateTaskSession(context.Background(), sess) + return + } + case <-timeout: + return + } + } + }(req.SessionID) + } + return &executor.LaunchAgentResponse{AgentExecutionID: fmt.Sprintf("exec-resumed-%d", call)}, nil + }, + } + + taskRepo := newMockTaskRepo() + taskRepo.tasks["task1"] = &v1.Task{ + ID: "task1", + Title: "Test Task", + State: v1.TaskStateInProgress, + } + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + exec := executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + svc.executor = exec + svc.scheduler = scheduler.NewScheduler(queue.NewTaskQueue(100), exec, taskRepo, testLogger(), scheduler.DefaultSchedulerConfig()) + + prompt := "follow-up after missing ACP session" + if _, err := svc.PromptTask(ctx, "task1", "session1", prompt, "", false, nil, false); err != nil { + t.Fatalf("expected fresh-launch fallback to recover missing ACP session, got: %v", err) + } + + if got := launchCalls.Load(); got != 2 { + t.Fatalf("expected resume launch plus fresh fallback launch, got %d launches", got) + } + <-launchPrompts // resume prompt; empty for the lazy resume path. + freshPrompt := <-launchPrompts + if !strings.Contains(freshPrompt, prompt) { + t.Fatalf("fresh launch prompt %q does not contain original prompt %q", freshPrompt, prompt) + } + if len(agentMgr.capturedPromptCalls) != 1 { + t.Fatalf("expected one failed PromptAgent attempt before fallback, got %d", len(agentMgr.capturedPromptCalls)) + } +} diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 6bce7db59c..67678b4241 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -102,6 +102,17 @@ func isTransientPromptError(err error) bool { strings.Contains(msg, "use of closed network connection") } +func isLazyResumePromptRecoveryError(err error) bool { + if err == nil { + return false + } + // Executor prompt paths normalize lifecycle.ErrExecutionNotFound to + // executor.ErrExecutionNotFound. Keep the lifecycle sentinel as an explicit + // defense-in-depth fallback for direct mocks or future paths that bypass that + // conversion. + return errors.Is(err, executor.ErrExecutionNotFound) || errors.Is(err, lifecycle.ErrExecutionNotFound) +} + func isAgentAlreadyRunningError(err error) bool { return err != nil && errors.Is(err, lifecycle.ErrAgentAlreadyRunning) } @@ -2324,7 +2335,7 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom promptCtx := context.WithoutCancel(ctx) result, err := s.executor.Prompt(promptCtx, taskID, sessionID, effectivePrompt, attachments, dispatchOnly, session) if err != nil { - if resumedForPrompt && errors.Is(err, executor.ErrExecutionNotFound) { + if resumedForPrompt && isLazyResumePromptRecoveryError(err) { s.logger.Warn("prompt after lazy resume hit missing execution; falling back to fresh launch", zap.String("task_id", taskID), zap.String("session_id", sessionID)) diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index f02e6ec032..fa7587b804 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -1725,6 +1725,30 @@ func TestErrorClassificationFunctions(t *testing.T) { } }) + t.Run("isLazyResumePromptRecoveryError", func(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil error", nil, false}, + {"unrelated error", errors.New("something else"), false}, + {"executor execution not found", executor.ErrExecutionNotFound, true}, + {"wrapped executor execution not found", fmt.Errorf("resume failed: %w", executor.ErrExecutionNotFound), true}, + {"lifecycle execution not found", lifecycle.ErrExecutionNotFound, true}, + {"wrapped lifecycle execution not found", fmt.Errorf("resume failed: %w", lifecycle.ErrExecutionNotFound), true}, + {"untyped session not found string", errors.New("session not found"), false}, + {"untyped execution not found string", errors.New("execution abc not found"), false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := isLazyResumePromptRecoveryError(tc.err); got != tc.want { + t.Errorf("isLazyResumePromptRecoveryError(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } + }) + t.Run("isTransientPromptError", func(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/pkg/websocket/message.go b/apps/backend/pkg/websocket/message.go index 6947f62941..9ffe8f5902 100644 --- a/apps/backend/pkg/websocket/message.go +++ b/apps/backend/pkg/websocket/message.go @@ -16,14 +16,23 @@ const ( MessageTypeError MessageType = "error" ) -// Message is the base envelope for all WebSocket messages +// Message is the base envelope for all WebSocket messages. +// +// ConnectionID, ConnectionSeq, SessionID, and SessionSeq are stamped by the +// gateway only when the E2E test harness accounting log is enabled. They let +// Playwright detect dropped or misrouted WebSocket events without changing the +// production envelope. 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"` + ConnectionID string `json:"connection_id,omitempty"` + ConnectionSeq int64 `json:"connection_seq,omitempty"` + SessionID string `json:"session_id,omitempty"` + SessionSeq int64 `json:"session_seq,omitempty"` } // EnsureMetadata lazily initializes and returns the Metadata map. diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 0445f6ad3f..03edf32b3a 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -278,6 +278,12 @@ importers: '@tabler/icons-react': specifier: ^3.36.1 version: 3.36.1(react@19.2.3) + '@tanstack/react-query': + specifier: 5.101.1 + version: 5.101.1(react@19.2.3) + '@tanstack/react-query-devtools': + specifier: 5.101.1 + version: 5.101.1(@tanstack/react-query@5.101.1(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) @@ -501,6 +507,9 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.1.18 + '@tanstack/eslint-plugin-query': + specifier: 5.101.1 + version: 5.101.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) '@testing-library/react': specifier: ^16.3.2 version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2938,6 +2947,32 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tanstack/eslint-plugin-query@5.101.1': + resolution: {integrity: sha512-gssErdsLIoeiJI6mbU1YTNlNxktj/Dt8r8QSXNxz8P4gBTnKl9xAPix84orbkIbd133ewAbqieRMZUQmxL+34w==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ^5.4.0 || ^6.0.0 + peerDependenciesMeta: + typescript: + optional: true + + '@tanstack/query-core@5.101.1': + resolution: {integrity: sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==} + + '@tanstack/query-devtools@5.101.1': + resolution: {integrity: sha512-37RQ9U2PxlXQiv1era2t+uHgVhmiyvxqTMu30+KoVf0rufiucu6rpGRKFJk61Wh5OAZFKqCQd6lxTzFWfLZiuQ==} + + '@tanstack/react-query-devtools@5.101.1': + resolution: {integrity: sha512-OXFR9XKdEslraq3cpl3kCUeNvTIq/xGWEZiFZdn2bLB/q4WxSALMEDKYZ5yYjMQytsfnQxwQYqV4qtVEf0nuog==} + peerDependencies: + '@tanstack/react-query': ^5.101.1 + react: ^18 || ^19 + + '@tanstack/react-query@5.101.1': + resolution: {integrity: sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -3507,16 +3542,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/project-service@8.62.0': + resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.53.0': resolution: {integrity: sha512-kWNj3l01eOGSdVBnfAF2K1BTh06WS0Yet6JUgb9Cmkqaz3Jlu0fdVUjj9UI8gPidBWSMqDIglmEXifSgDT/D0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.62.0': + resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.53.0': resolution: {integrity: sha512-K6Sc0R5GIG6dNoPdOooQ+KtvT5KCKAvTcY8h2rIuul19vxH5OTQk7ArKkd4yTzkw66WnNY0kPPzzcmWA+XRmiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/tsconfig-utils@8.62.0': + resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.53.0': resolution: {integrity: sha512-BBAUhlx7g4SmcLhn8cnbxoxtmS7hcq39xKCgiutL3oNx1TaIp+cny51s8ewnKMpVUKQUGb41RAUWZ9kxYdovuw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3528,12 +3579,22 @@ packages: resolution: {integrity: sha512-Bmh9KX31Vlxa13+PqPvt4RzKRN1XORYSLlAE+sO1i28NkisGbTtSLFVB3l7PWdHtR3E0mVMuC7JilWJ99m2HxQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.62.0': + resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.53.0': resolution: {integrity: sha512-pw0c0Gdo7Z4xOG987u3nJ8akL9093yEEKv8QTJ+Bhkghj1xyj8cgPaavlr9rq8h7+s6plUJ4QJYw2gCZodqmGw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/typescript-estree@8.62.0': + resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.53.0': resolution: {integrity: sha512-XDY4mXTez3Z1iRDI5mbRhH4DFSt46oaIFsLg+Zn97+sYrXACziXSQcSelMybnVZ5pa1P6xYkPr5cMJyunM1ZDA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3541,10 +3602,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' + '@typescript-eslint/utils@8.62.0': + resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.53.0': resolution: {integrity: sha512-LZ2NqIHFhvFwxG0qZeLL9DvdNAHPGCY5dIRwBhyYeU+LfLhcStE1ImjsuTG/WaVh3XysGaeLW8Rqq7cGkPCFvw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.62.0': + resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@uiw/codemirror-extensions-basic-setup@4.25.4': resolution: {integrity: sha512-YzNwkm0AbPv1EXhCHYR5v0nqfemG2jEB0Z3Att4rBYqKrlG7AA9Rhjc3IyBaOzsBu18wtrp9/+uhTyu7TXSRng==} peerDependencies: @@ -3721,6 +3793,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.9.14: resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} hasBin: true @@ -3739,6 +3815,10 @@ packages: brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -4401,6 +4481,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.2: resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5466,6 +5550,10 @@ packages: resolution: {integrity: sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==} engines: {node: 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -6367,6 +6455,12 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -9095,6 +9189,30 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 + '@tanstack/eslint-plugin-query@5.101.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/utils': 8.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@tanstack/query-core@5.101.1': {} + + '@tanstack/query-devtools@5.101.1': {} + + '@tanstack/react-query-devtools@5.101.1(@tanstack/react-query@5.101.1(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/query-devtools': 5.101.1 + '@tanstack/react-query': 5.101.1(react@19.2.3) + react: 19.2.3 + + '@tanstack/react-query@5.101.1(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.101.1 + 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 @@ -9689,15 +9807,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.62.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.53.0': dependencies: '@typescript-eslint/types': 8.53.0 '@typescript-eslint/visitor-keys': 8.53.0 + '@typescript-eslint/scope-manager@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + '@typescript-eslint/tsconfig-utils@8.53.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.62.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@typescript-eslint/type-utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.53.0 @@ -9712,6 +9848,8 @@ snapshots: '@typescript-eslint/types@8.53.0': {} + '@typescript-eslint/types@8.62.0': {} + '@typescript-eslint/typescript-estree@8.53.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.53.0(typescript@5.9.3) @@ -9727,6 +9865,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.62.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) @@ -9738,11 +9891,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + eslint: 9.39.2(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.53.0': dependencies: '@typescript-eslint/types': 8.53.0 eslint-visitor-keys: 4.2.1 + '@typescript-eslint/visitor-keys@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + eslint-visitor-keys: 5.0.1 + '@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.1)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.2)(@codemirror/search@6.6.0)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10)': dependencies: '@codemirror/autocomplete': 6.20.0 @@ -9915,6 +10084,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + baseline-browser-mapping@2.9.14: {} body-parser@2.2.2: @@ -9942,6 +10113,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -10647,6 +10822,8 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.1: {} + eslint@9.39.2(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) @@ -11927,6 +12104,10 @@ snapshots: dependencies: '@isaacs/brace-expansion': 5.0.1 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -13093,6 +13274,10 @@ snapshots: dependencies: typescript: 5.9.3 + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + ts-dedent@2.2.0: {} ts-morph@26.0.0: diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 00d4912747..01dd015e95 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -23,25 +23,36 @@ import { Dialog } from "@kandev/ui/dialog"; ## Data Flow Pattern (Critical) ```text -Go Boot Payload -> Hydrate Store -> Components Read Store -> Hooks Subscribe +Go Boot Payload -> Seed Query Cache + Hydrate UI Store -> Domain Hooks -> Components +WebSocket Events -> Query Bridge -> Query Cache -> Mounted UI ``` -**Never fetch data directly in components.** +**Never fetch server data directly in components.** Server-owned data should go +through TanStack Query keys/options and domain hooks. Zustand is for +client-only UI state, persisted user preferences, and the explicitly documented +live/runtime indexes below. ## Store Structure (Domain Slices) ```text +lib/query/ +├── client.ts # Browser QueryClient defaults +├── keys.ts # Typed query-key factories +├── provider.tsx # Query provider and e2e exposure +├── query-options/ # Domain query option factories +└── bridge/ # WS event -> query cache updates + lib/state/ ├── store.ts # Root composition ├── default-state.ts # Default state + initial state merge ├── slices/ # Domain slices -│ ├── kanban/ # boards, tasks, columns -│ ├── session/ # sessions, messages, turns, worktrees +│ ├── kanban/ # active workflow/task/session UI state +│ ├── session/ # live session/message/turn indexes │ ├── session-runtime/ # shell, processes, git, context -│ ├── workspace/ # workspaces, repos, branches -│ ├── settings/ # executors, agents, editors, prompts (incl. userSettings) +│ ├── workspace/ # active workspace UI state +│ ├── settings/ # server-backed persisted userSettings/preferences │ ├── comments/ # code review diff comments -│ ├── github/ # GitHub PRs, reviews +│ ├── github/ # local pending PR URL and feedback caches │ └── ui/ # preview, connection, active state, sidebar views ├── hydration/ # SSR merge strategies @@ -55,16 +66,38 @@ lib/api/domains/ # API clients **Key State Paths:** -- `messages.bySession[sessionId]`, `shell.outputs[sessionId]`, `gitStatus.bySessionId[sessionId]` -- `tasks.activeTaskId`, `tasks.activeSessionId`, `workspaces.activeId` -- `repositories.byWorkspace`, `repositoryBranches.byRepository` - -**Hydration:** Go injects `window.__KANDEV_BOOT_PAYLOAD__` into the SPA shell before React mounts. `lib/state/hydration/merge-strategies.ts` has `deepMerge()`, `mergeSessionMap()`, `mergeLoadingState()` to avoid overwriting live client state. Pass `activeSessionId` to protect active sessions. +- `tasks.activeTaskId`, `tasks.activeSessionId`, `workflows.activeId`, `workspaces.activeId` +- `messages.bySession[sessionId]`, `turns.bySession[sessionId]`, + `taskSessions.items[sessionId]`, `taskSessionsByTask`, `sessionAgentctl`, + `taskPlans`, and `activeModel.bySessionId` are retained live session indexes + for stream ordering, active-session chrome, missed-frame recovery, + plan/model UI, and editor/panel behavior. +- `shell.outputs[environmentId]`, `processes.*`, `gitStatus.byEnvironmentId`, + `sessionCommits.byEnvironmentId`, `contextWindow.bySessionId`, + `prepareProgress.bySessionId`, `sessionModels.bySessionId`, and + `userShells.byEnvironmentId` are retained runtime indexes for high-frequency + streams, environment-scoped cleanup, and terminal/session UI. +- Workspace repositories, repository branches/scripts, workflow lists, workflow + snapshots, task details, session worktrees, feature flags, settings catalogs, + integrations, office data, and system data are TanStack Query data. +- `userSettings` is the retained server-backed persisted preference object; + settings reads also seed `qk.settings.user()` so server-state consumers use + Query where migrated. + +**Hydration:** Go injects `window.__KANDEV_BOOT_PAYLOAD__` into the SPA shell +before React mounts. Boot and app-state payloads seed TanStack Query through +`lib/query/seed.ts` before route hooks fetch. The Zustand hydrator still merges +client-only UI state and persisted preferences; `lib/state/hydration/merge-strategies.ts` +has `deepMerge()`, `mergeSessionMap()`, `mergeLoadingState()` to avoid +overwriting live client state. Pass `activeSessionId` to protect active +sessions. For rebasing or finishing PRs written against the old Next.js runtime, follow [`docs/nextjs-spa-migration.md`](../../docs/nextjs-spa-migration.md). -**Hooks Pattern:** Hooks in `hooks/domains/` encapsulate WS subscription + store selection. WS client deduplicates subscriptions automatically. +**Hooks Pattern:** Hooks in `hooks/domains/` encapsulate query selection, +mutations, and WS subscription intent. WS client deduplicates subscriptions +automatically. ## WebSockets @@ -75,11 +108,15 @@ Use subscription hooks only; the WS client auto-deduplicates. When changing task lifecycle WS handlers (`task.updated`, `task.deleted`, `task.state_changed`), check both kanban and Office surfaces. Archive/delete events may need to update kanban caches, `tasks.activeTaskId` / session pin -state, recent/sidebar prefs, Office refetch triggers such as -`setOfficeRefetchTrigger("tasks")`, and route redirects for `/t/:id`, +state, recent/sidebar prefs, matching Office query keys, and route redirects for `/t/:id`, `/tasks/:id`, and `/office/tasks/:id`. Add focused tests for every affected surface. +Server-state WS handlers should live in `lib/query/bridge/` and write or +invalidate the same query keys the mounted UI reads. Legacy `lib/ws/handlers/*` +files are only for retained client effects, high-frequency streams, or +documented non-query transport paths. + ## Component conventions - **Framework adapters during Next removal:** Client components should import diff --git a/apps/web/app/github/page.tsx b/apps/web/app/github/page.tsx index c0630c1e66..0c7d0550fe 100644 --- a/apps/web/app/github/page.tsx +++ b/apps/web/app/github/page.tsx @@ -8,6 +8,7 @@ import { fetchUserSettings } from "@/lib/api"; import { StateHydrator } from "@/components/state-hydrator"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { GitHubPageClient } from "./github-page-client"; +import type { QuerySeedInitialState } from "@/lib/query/seed"; import type { Workflow, WorkflowStep, @@ -15,7 +16,6 @@ import type { Workspace, UserSettingsResponse, } from "@/lib/types/http"; -import type { AppState } from "@/lib/state/store"; export default async function GitHubPage() { let workspaces: Workspace[] = []; @@ -52,26 +52,23 @@ export default async function GitHubPage() { const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); - const initialState: Partial = { + const initialState: QuerySeedInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null }, workflows: { - items: workflows.map((w) => ({ - id: w.id, - workspaceId: w.workspace_id, - name: w.name, - description: w.description ?? null, - sortOrder: w.sort_order ?? 0, - ...(w.agent_profile_id ? { agent_profile_id: w.agent_profile_id } : {}), - })), activeId: workflows[0]?.id ?? null, }, userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, + ...(workspaceId && workspaceDataLoaded + ? { + workflowLists: { + itemsByWorkspaceId: { [workspaceId]: workflows }, + }, + } + : {}), ...(workspaceId && workspaceDataLoaded ? { repositories: { itemsByWorkspaceId: { [workspaceId]: repositories }, - loadingByWorkspaceId: { [workspaceId]: false }, - loadedByWorkspaceId: { [workspaceId]: true }, }, } : {}), diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx index 33b8a88f06..a38164bd97 100644 --- a/apps/web/app/gitlab/gitlab-page-client.tsx +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -7,8 +7,7 @@ import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet"; import { PageTopbar } from "@/components/page-topbar"; -import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api"; -import type { GitLabStatus, Issue, MR } from "@/lib/types/gitlab"; +import type { Issue, MR } from "@/lib/types/gitlab"; import { MRList } from "@/components/gitlab/my-gitlab/mr-list"; import { IssueList } from "@/components/gitlab/my-gitlab/issue-list"; import { @@ -27,6 +26,7 @@ import { useCommittedQuery } from "@/components/gitlab/my-gitlab/use-committed-q import { ListToolbar } from "@/components/gitlab/my-gitlab/list-toolbar"; import { ResultsPagination } from "@/components/gitlab/my-gitlab/results-pagination"; import { SavePresetDialog } from "@/components/gitlab/my-gitlab/save-preset-dialog"; +import { useGitLabStatus } from "@/hooks/domains/gitlab/use-gitlab-status"; function PageHeader({ host, @@ -273,32 +273,8 @@ function AuthenticatedLayout({ state }: { state: GitLabPageState }) { ); } -function useGitLabStatusFetch() { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - let cancelled = false; - fetchGitLabStatus({ cache: "no-store" }) - .then((s) => { - if (!cancelled) setStatus(s); - }) - .catch(() => { - if (!cancelled) setStatus(null); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, []); - - return { status, loading }; -} - export function GitLabPageClient(_props: { workspaceId?: string } = {}) { - const { status, loading: statusLoading } = useGitLabStatusFetch(); + const { status, loading: statusLoading } = useGitLabStatus(); const state = useGitLabPageState(); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); diff --git a/apps/web/app/gitlab/page.tsx b/apps/web/app/gitlab/page.tsx index 5bbe390865..0b3fa398a2 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 { QuerySeedInitialState } from "@/lib/query/seed"; // 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: QuerySeedInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null }, userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, }; diff --git a/apps/web/app/jira/jira-page-client.tsx b/apps/web/app/jira/jira-page-client.tsx index baed82730f..3b5ee48e21 100644 --- a/apps/web/app/jira/jira-page-client.tsx +++ b/apps/web/app/jira/jira-page-client.tsx @@ -2,12 +2,14 @@ import Link from "@/components/routing/app-link"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconTicket } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { PageTopbar } from "@/components/page-topbar"; -import { getJiraConfig, listJiraProjects, searchJiraTickets } from "@/lib/api/domains/jira-api"; +import { listJiraProjects, searchJiraTickets } from "@/lib/api/domains/jira-api"; import type { JiraProject, JiraTicket } from "@/lib/types/jira"; import type { Workflow, WorkflowStep } from "@/lib/types/http"; +import { jiraConfigQueryOptions } from "@/lib/query/query-options"; import { TicketRow } from "@/components/jira/my-jira/ticket-row"; import { useJiraSearch } from "@/components/jira/my-jira/use-jira-search"; import { JiraErrorMessage } from "@/components/jira/jira-ticket-common"; @@ -71,42 +73,40 @@ async function loadUserProjects(workspaceId: string): Promise { } function useJiraPageData(workspaceId?: string) { - const [loaded, setLoaded] = useState(false); - const [configured, setConfigured] = useState(false); const [projects, setProjects] = useState([]); - const [defaultProjectKey, setDefaultProjectKey] = useState(""); + const [projectsLoaded, setProjectsLoaded] = useState(false); + const configQuery = useQuery({ + ...jiraConfigQueryOptions(workspaceId), + enabled: Boolean(workspaceId), + }); + const configured = Boolean(configQuery.data?.hasSecret); + const defaultProjectKey = configQuery.data?.defaultProjectKey ?? ""; useEffect(() => { let cancelled = false; async function load() { - if (!workspaceId) { - setLoaded(true); + if (!workspaceId || !configured) { + setProjects([]); + setProjectsLoaded(true); return; } try { - const cfg = await getJiraConfig({ workspaceId }); - if (cancelled) return; - const ok = !!cfg && cfg.hasSecret; - setConfigured(ok); - setDefaultProjectKey(cfg?.defaultProjectKey ?? ""); - if (ok) { - try { - const list = await loadUserProjects(workspaceId); - if (!cancelled) setProjects(list); - } catch { - // Non-fatal: pill will just show empty list. Users can still filter by other dims. - } - } + setProjectsLoaded(false); + const list = await loadUserProjects(workspaceId); + if (!cancelled) setProjects(list); + } catch { + // Non-fatal: pill will just show empty list. Users can still filter by other dims. } finally { - if (!cancelled) setLoaded(true); + if (!cancelled) setProjectsLoaded(true); } } void load(); return () => { cancelled = true; }; - }, [workspaceId]); + }, [workspaceId, configured]); + const loaded = !workspaceId || (configQuery.isFetched && (!configured || projectsLoaded)); return { loaded, configured, projects, defaultProjectKey }; } diff --git a/apps/web/app/jira/page.tsx b/apps/web/app/jira/page.tsx index daf99a1af2..0491bcacc0 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 { QuerySeedInitialState } from "@/lib/query/seed"; export default async function JiraPage() { let workspaces: Workspace[] = []; @@ -40,19 +40,18 @@ export default async function JiraPage() { const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); - const initialState: Partial = { + const initialState: QuerySeedInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null }, workflows: { - items: workflows.map((w) => ({ - id: w.id, - workspaceId: w.workspace_id, - name: w.name, - description: w.description ?? null, - sortOrder: w.sort_order ?? 0, - ...(w.agent_profile_id ? { agent_profile_id: w.agent_profile_id } : {}), - })), activeId: workflows[0]?.id ?? null, }, + ...(workspaceId + ? { + workflowLists: { + itemsByWorkspaceId: { [workspaceId]: workflows }, + }, + } + : {}), userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, }; diff --git a/apps/web/app/kanban-active-workspace-cookie.test.ts b/apps/web/app/kanban-active-workspace-cookie.test.ts new file mode 100644 index 0000000000..5c1e165bc8 --- /dev/null +++ b/apps/web/app/kanban-active-workspace-cookie.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { readKanbanActiveWorkspaceCookie } from "./kanban-active-workspace-cookie"; + +function cookieStore(values: Record) { + return { + get: (name: string) => { + const value = values[name]; + return value ? { value } : undefined; + }, + }; +} + +describe("readKanbanActiveWorkspaceCookie", () => { + it("prefers the general active workspace cookie over the legacy office cookie", () => { + expect( + readKanbanActiveWorkspaceCookie( + cookieStore({ + "kandev-active-workspace": "workspace-1", + "office-active-workspace": "workspace-2", + }), + ), + ).toBe("workspace-1"); + }); + + it("falls back to the legacy office cookie", () => { + expect( + readKanbanActiveWorkspaceCookie(cookieStore({ "office-active-workspace": "workspace-2" })), + ).toBe("workspace-2"); + }); +}); diff --git a/apps/web/app/kanban-active-workspace-cookie.ts b/apps/web/app/kanban-active-workspace-cookie.ts new file mode 100644 index 0000000000..3c461d0396 --- /dev/null +++ b/apps/web/app/kanban-active-workspace-cookie.ts @@ -0,0 +1,17 @@ +import { + ACTIVE_WORKSPACE_COOKIE, + LEGACY_OFFICE_ACTIVE_WORKSPACE_COOKIE, +} from "@/lib/routing/route-bootstrap"; + +type CookieReader = { + get(name: string): { value?: string } | undefined; +}; + +export function readKanbanActiveWorkspaceCookie(cookieStore: CookieReader | null): string | null { + if (!cookieStore) return null; + return ( + cookieStore.get(ACTIVE_WORKSPACE_COOKIE)?.value ?? + cookieStore.get(LEGACY_OFFICE_ACTIVE_WORKSPACE_COOKIE)?.value ?? + null + ); +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index d607e1b8c4..58a1ca415d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -16,7 +16,7 @@ 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, getRuntimeDebugModeAction } from "@/app/actions/features"; +import { getRuntimeDebugModeAction } from "@/app/actions/features"; export const metadata = { title: "Kandev - AI Kanban", @@ -38,14 +38,7 @@ export default async function RootLayout({ }>) { const envDebugMode = process.env.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, runtimeDebugMode] = await Promise.all([ - getFeatureFlagsAction(), - getRuntimeDebugModeAction(), - ]); + const runtimeDebugMode = await getRuntimeDebugModeAction(); const debugMode = envDebugMode || runtimeDebugMode; const runtimeConfigScript = debugMode ? "window.__KANDEV_DEBUG = true;" : null; @@ -70,7 +63,7 @@ export default async function RootLayout({ /> - + diff --git a/apps/web/app/linear/linear-page-client.tsx b/apps/web/app/linear/linear-page-client.tsx index 4d9686b1b9..88d7e28175 100644 --- a/apps/web/app/linear/linear-page-client.tsx +++ b/apps/web/app/linear/linear-page-client.tsx @@ -2,6 +2,7 @@ import Link from "@/components/routing/app-link"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconExternalLink, IconHexagon, IconPlus, IconSearch } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar"; @@ -25,9 +26,10 @@ import { } from "@/components/linear/linear-issue-common"; import { LinearIssueDialog } from "@/components/linear/linear-issue-dialog"; import { LinearQuickTaskLauncher } from "@/components/linear/linear-quick-task-launcher"; -import { getLinearConfig, listLinearTeams, searchLinearIssues } from "@/lib/api/domains/linear-api"; +import { listLinearTeams, searchLinearIssues } from "@/lib/api/domains/linear-api"; import type { LinearIssue, LinearTeam } from "@/lib/types/linear"; import type { Workflow, WorkflowStep } from "@/lib/types/http"; +import { linearConfigQueryOptions } from "@/lib/query/query-options"; const PAGE_SIZE = 25; @@ -57,40 +59,39 @@ function NotConfiguredNotice() { } function useLinearPageData(workspaceId?: string) { - const [loaded, setLoaded] = useState(false); - const [configured, setConfigured] = useState(false); const [teams, setTeams] = useState([]); + const [teamsLoaded, setTeamsLoaded] = useState(false); + const configQuery = useQuery({ + ...linearConfigQueryOptions(workspaceId), + enabled: Boolean(workspaceId), + }); + const configured = Boolean(configQuery.data?.hasSecret); useEffect(() => { let cancelled = false; async function load() { - if (!workspaceId) { - setLoaded(true); + if (!workspaceId || !configured) { + setTeams([]); + setTeamsLoaded(true); return; } try { - const cfg = await getLinearConfig({ workspaceId }); - if (cancelled) return; - const ok = !!cfg && cfg.hasSecret; - setConfigured(ok); - if (ok) { - try { - const list = await listLinearTeams({ workspaceId }); - if (!cancelled) setTeams(list.teams ?? []); - } catch { - // Non-fatal: team filter just stays empty. - } - } + setTeamsLoaded(false); + const list = await listLinearTeams({ workspaceId }); + if (!cancelled) setTeams(list.teams ?? []); + } catch { + // Non-fatal: team filter just stays empty. } finally { - if (!cancelled) setLoaded(true); + if (!cancelled) setTeamsLoaded(true); } } void load(); return () => { cancelled = true; }; - }, [workspaceId]); + }, [workspaceId, configured]); + const loaded = !workspaceId || (configQuery.isFetched && (!configured || teamsLoaded)); return { loaded, configured, teams }; } diff --git a/apps/web/app/linear/page.tsx b/apps/web/app/linear/page.tsx index 0e0327c876..4fdc26bd9f 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 { QuerySeedInitialState } from "@/lib/query/seed"; export default async function LinearPage() { let workspaces: Workspace[] = []; @@ -40,19 +40,18 @@ export default async function LinearPage() { const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); - const initialState: Partial = { + const initialState: QuerySeedInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null }, workflows: { - items: workflows.map((w) => ({ - id: w.id, - workspaceId: w.workspace_id, - name: w.name, - description: w.description ?? null, - sortOrder: w.sort_order ?? 0, - ...(w.agent_profile_id ? { agent_profile_id: w.agent_profile_id } : {}), - })), activeId: workflows[0]?.id ?? null, }, + ...(workspaceId + ? { + workflowLists: { + itemsByWorkspaceId: { [workspaceId]: workflows }, + }, + } + : {}), userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, }; diff --git a/apps/web/app/office/agents/[id]/channels/page.tsx b/apps/web/app/office/agents/[id]/channels/page.tsx index bb43282215..f46c333677 100644 --- a/apps/web/app/office/agents/[id]/channels/page.tsx +++ b/apps/web/app/office/agents/[id]/channels/page.tsx @@ -1,14 +1,14 @@ "use client"; import { use } from "react"; -import { useAppStore } from "@/components/state-provider"; import { AgentChannelsTab } from "../components/agent-channels-tab"; +import { useOfficeAgentProfile } from "../use-agent-detail-data"; 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 agent = useOfficeAgentProfile(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 21d57d1999..d63dbef92d 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 @@ -6,12 +6,11 @@ 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 { useAgentsQuerySync } from "@/hooks/domains/settings/use-agents-query-sync"; import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles"; 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"; type Props = { agentProfileId: string; @@ -26,9 +25,7 @@ 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 { settingsAgents, upsertProfile } = useAgentsQuerySync(); const linkedProfile = useMemo( () => findProfile(settingsAgents, agentProfileId) ?? currentAgent, @@ -92,7 +89,7 @@ export function AgentConfigCliCard({ agentProfileId, currentAgent, onAgentProfil profile={linkedProfile} onClose={() => setEditorMode("closed")} onSaved={(saved) => { - optimisticUpsert(setAgentProfiles, agentProfilesState, settingsAgents, saved); + upsertProfile(saved); onAgentProfileChange(saved.id); setEditorMode("closed"); }} @@ -103,7 +100,7 @@ export function AgentConfigCliCard({ agentProfileId, currentAgent, onAgentProfil mode="create" onClose={() => setEditorMode("closed")} onSaved={(saved) => { - optimisticUpsert(setAgentProfiles, agentProfilesState, settingsAgents, saved); + upsertProfile(saved); onAgentProfileChange(saved.id); setEditorMode("closed"); }} @@ -136,20 +133,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 1bb329fd57..1af515f7ec 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,9 +1,11 @@ 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 { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import type { AgentProfile, OfficeMeta } from "@/lib/state/slices/office/types"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; -import { defaultOfficeState } from "@/lib/state/slices/office/office-slice"; import { AgentConfigurationTab } from "./agent-configuration-tab"; // Mock toast so the act-like hooks don't error and we don't need the toast @@ -18,6 +20,12 @@ vi.mock("@/lib/api/domains/office-api", async () => { updateAgentProfile: vi.fn().mockResolvedValue({}), }; }); +vi.mock("@/lib/api/domains/office-routing-api", () => ({ + getAgentRoute: vi.fn(async () => ({ overrides: undefined, provider_order: [] })), + getProviderHealth: vi.fn(async () => ({ health: [] })), + getRoutingPreview: vi.fn(async () => ({ previews: [] })), + getWorkspaceRouting: vi.fn(async () => ({ config: null, known_providers: [] })), +})); afterEach(() => { cleanup(); @@ -50,19 +58,64 @@ const PROFILE_OPTION = { cli_passthrough: false, }; +function officeMeta(): OfficeMeta { + return { + statuses: [], + priorities: [], + roles: [ + { id: "ceo", label: "CEO", description: "Coordinator", color: "bg-purple-100" }, + { id: "worker", label: "Worker", description: "Worker", color: "bg-blue-100" }, + ], + executorTypes: [{ id: "local_pc", label: "Local", description: "Local executor" }], + skillSourceTypes: [], + projectStatuses: [], + agentStatuses: [{ id: "idle", label: "Idle", color: "bg-neutral-400" }], + routineRunStatuses: [], + inboxItemTypes: [], + permissions: [], + permissionDefaults: { + ceo: { create_agent: true }, + worker: { create_agent: false }, + }, + }; +} + +function renderConfigurationTab(agent: AgentProfile) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.meta(), officeMeta()); + queryClient.setQueryData(qk.office.agents("ws-1"), { agents: [agent] }); + queryClient.setQueryData(qk.settings.agents(), { + agents: [ + { + id: CLAUDE_AGENT_ID, + name: CLAUDE_AGENT_ID, + profiles: [ + { + id: PROFILE_OPTION.id, + name: "Default", + agentId: CLAUDE_AGENT_ID, + agentDisplayName: "Claude", + cliPassthrough: false, + createdAt: AGENT_TIMESTAMP, + updatedAt: AGENT_TIMESTAMP, + }, + ], + }, + ], + total: 1, + }); + return render( + + + + + , + ); +} + describe("AgentConfigurationTab", () => { it("renders the CLI configuration card with the linked profile summary", () => { - render( - - - , - ); + renderConfigurationTab(baseAgent); expect(screen.getByText("CLI Configuration")).toBeTruthy(); // Linked profile is surfaced with the CLI client badge. @@ -76,34 +129,14 @@ describe("AgentConfigurationTab", () => { agentId: CLAUDE_AGENT_ID, agentDisplayName: "Claude", }; - render( - - - , - ); + renderConfigurationTab(orphan); expect(screen.queryByText(/no cli profile selected/i)).toBeNull(); expect(screen.getByText("Claude")).toBeTruthy(); }); it("shows create-agent capability for CEO agents", () => { - render( - - - , - ); + renderConfigurationTab(baseAgent); expect(screen.getByTestId("agent-capability-preview").textContent).toContain("Create agent"); }); @@ -115,17 +148,7 @@ describe("AgentConfigurationTab", () => { name: "Worker", role: "worker" as const, }; - render( - - - , - ); + renderConfigurationTab(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 895ed58678..a33c53aba0 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 @@ -8,10 +8,11 @@ import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { toast } from "sonner"; -import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import { updateAgentProfile } from "@/lib/api/domains/office-api"; import type { AgentProfile, AgentRole } from "@/lib/state/slices/office/types"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; +import { useOfficeAgentProfiles, usePatchOfficeAgentProfileCache } from "../use-agent-detail-data"; import { AgentConfigCliCard } from "./agent-config-cli-card"; import { AgentRoutingCard } from "./agent-routing-card"; @@ -70,9 +71,9 @@ 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 meta = useOfficeMetaData().data; + const patchAgentCache = usePatchOfficeAgentProfileCache(); + const allOfficeAgents = useOfficeAgentProfiles(); const roles = meta?.roles.map((r) => ({ id: r.id, label: r.label })) ?? FALLBACK_ROLES; const executorTypes = @@ -104,7 +105,7 @@ export function AgentConfigurationTab({ agent }: AgentConfigurationTabProps) { executorPreference: form.executorType ? { type: form.executorType } : undefined, }; await updateAgentProfile(agent.id, update); - updateStore(agent.id, update); + patchAgentCache(agent.id, update); setDirty(false); toast.success("Agent configuration updated"); } catch (err) { @@ -112,7 +113,7 @@ export function AgentConfigurationTab({ agent }: AgentConfigurationTabProps) { } finally { setSaving(false); } - }, [agent.id, form, updateStore]); + }, [agent.id, form, patchAgentCache]); 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 c28e755381..6d817684d9 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 @@ -8,10 +8,11 @@ import { Button } from "@kandev/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { IconRefresh } from "@tabler/icons-react"; import { toast } from "sonner"; -import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import { updateAgentProfile, getAgentUtilization } from "@/lib/api/domains/office-api"; import type { AgentProfile, AgentRole, ProviderUsage } from "@/lib/state/slices/office/types"; import { UtilizationBars } from "@/app/office/components/utilization-bars"; +import { useOfficeAgentProfiles, usePatchOfficeAgentProfileCache } from "../use-agent-detail-data"; type AgentOverviewTabProps = { agent: AgentProfile; @@ -217,9 +218,9 @@ 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 agents = useOfficeAgentProfiles(); + const meta = useOfficeMetaData().data; + const patchAgentCache = usePatchOfficeAgentProfileCache(); const roles = meta?.roles.map((r) => ({ id: r.id, label: r.label })) ?? FALLBACK_ROLES; const executorTypes = @@ -245,7 +246,7 @@ export function AgentOverviewTab({ agent }: AgentOverviewTabProps) { maxConcurrentSessions: maxConcurrent, executorPreference: executorType ? { type: executorType } : undefined, } as Partial); - updateStore(agent.id, { + patchAgentCache(agent.id, { name, role, budgetMonthlyCents: Math.round(budget * 100), @@ -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, patchAgentCache]); 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 103bb54971..2ce379e791 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 @@ -8,17 +8,18 @@ import { Input } from "@kandev/ui/input"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { toast } from "sonner"; -import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import { updateAgentProfile } from "@/lib/api/domains/office-api"; import type { AgentProfile } from "@/lib/state/slices/office/types"; +import { usePatchOfficeAgentProfileCache } from "../use-agent-detail-data"; type AgentPermissionsTabProps = { agent: AgentProfile; }; export function AgentPermissionsTab({ agent }: AgentPermissionsTabProps) { - const meta = useAppStore((s) => s.office.meta); - const updateStore = useAppStore((s) => s.updateOfficeAgentProfile); + const meta = useOfficeMetaData().data; + const patchAgentCache = usePatchOfficeAgentProfileCache(); const permDefs = meta?.permissions ?? []; const roleDefaults = meta?.permissionDefaults?.[agent.role] ?? {}; @@ -40,7 +41,7 @@ export function AgentPermissionsTab({ agent }: AgentPermissionsTabProps) { await updateAgentProfile(agent.id, { permissions: perms, } as Partial); - updateStore(agent.id, { permissions: perms }); + patchAgentCache(agent.id, { permissions: perms }); setDirty(false); toast.success("Permissions updated"); } catch (err) { @@ -48,7 +49,7 @@ export function AgentPermissionsTab({ agent }: AgentPermissionsTabProps) { } finally { setSaving(false); } - }, [agent.id, perms, updateStore]); + }, [agent.id, perms, patchAgentCache]); 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 2938107c2c..f8a7065c8d 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,16 @@ 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 { makeQueryClient } from "@/lib/query/client"; 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. 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, @@ -38,6 +38,17 @@ const ceo = { maxConcurrentSessions: 1, } as AgentProfile; +function renderTab(agent = ceo) { + const queryClient = makeQueryClient(); + return render( + + + + + , + ); +} + describe("AgentRunsTab", () => { // Pins the regression where the API returns snake_case but the // frontend filter read camelCase, so every run was filtered out and @@ -64,11 +75,7 @@ describe("AgentRunsTab", () => { ], }); - render( - - - , - ); + renderTab(); // The CEO's run should appear; the other agent's run should not. await waitFor(() => { @@ -82,11 +89,7 @@ describe("AgentRunsTab", () => { it("renders the empty state when no runs match the agent", async () => { listRunsMock.mockResolvedValueOnce({ runs: [] }); - render( - - - , - ); + renderTab(); 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 b01b887c98..4313a8f88f 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 { officeRunsQueryOptions } from "@/lib/query/query-options"; import type { AgentProfile, Run } from "@/lib/state/slices/office/types"; import { timeAgo } from "@/lib/utils/time"; @@ -56,33 +56,13 @@ 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); + const runsQuery = useQuery(officeRunsQueryOptions(workspaceId ?? "")); + const runs: Run[] = useMemo( + () => (runsQuery.data?.runs ?? []).filter((run) => run.agent_profile_id === agent.id), + [runsQuery.data, agent.id], + ); - if (loading) { + if (runsQuery.isPending) { return (

Loading runs...

diff --git a/apps/web/app/office/agents/[id]/components/agent-skills-tab.test.tsx b/apps/web/app/office/agents/[id]/components/agent-skills-tab.test.tsx new file mode 100644 index 0000000000..e619b4c87c --- /dev/null +++ b/apps/web/app/office/agents/[id]/components/agent-skills-tab.test.tsx @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { StateProvider } from "@/components/state-provider"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import type { AgentProfile, Skill } from "@/lib/state/slices/office/types"; +import { AgentSkillsTab } from "./agent-skills-tab"; + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/lib/api/domains/office-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/office-api", + ); + return { + ...actual, + updateAgentProfile: vi.fn().mockResolvedValue({}), + }; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const AGENT_TIMESTAMP = "2026-05-04T00:00:00Z"; +const CHECKBOX_STATE_ATTRIBUTE = "data-state"; + +const baseAgent = { + id: "agent-ceo", + workspaceId: "ws-1", + name: "CEO", + role: "ceo", + status: "idle", + createdAt: AGENT_TIMESTAMP, + updatedAt: AGENT_TIMESTAMP, + permissions: {}, + budgetMonthlyCents: 5000, + maxConcurrentSessions: 2, +} as AgentProfile; + +const baseSkill = { + id: "skill-protocol", + workspaceId: "ws-1", + name: "Kandev Protocol", + slug: "kandev-protocol", + sourceType: "system", + isSystem: true, + systemVersion: "test", + defaultForRoles: ["ceo"], + createdAt: AGENT_TIMESTAMP, + updatedAt: AGENT_TIMESTAMP, +} satisfies Skill; + +function renderSkillsTab(agent: AgentProfile, skills: Skill[] = [baseSkill]) { + const queryClient = makeQueryClient(); + queryClient.setQueryDefaults(qk.office.skills("ws-1"), { staleTime: Infinity }); + queryClient.setQueryData(qk.office.skills("ws-1"), { skills }); + const view = render( + + + + + + + , + ); + return { ...view, queryClient }; +} + +function protocolCheckbox() { + return screen.getByTestId("skill-toggle-checkbox-kandev-protocol"); +} + +async function expectProtocolCheckboxState(state: "checked" | "unchecked") { + await waitFor(() => + expect(protocolCheckbox().getAttribute(CHECKBOX_STATE_ATTRIBUTE)).toBe(state), + ); +} + +describe("AgentSkillsTab", () => { + it("checks skills resolved from legacy desiredSkills slugs", async () => { + renderSkillsTab({ ...baseAgent, skillIds: [], desiredSkills: ["kandev-protocol"] }); + + await expectProtocolCheckboxState("checked"); + }); + + it("preserves explicitly cleared default skills", async () => { + renderSkillsTab({ ...baseAgent, skillIds: [], desiredSkills: [] }); + + await expectProtocolCheckboxState("unchecked"); + }); + + it("falls back to role defaults only when skill fields are absent", async () => { + renderSkillsTab({ ...baseAgent, skillIds: undefined, desiredSkills: undefined }); + + await expectProtocolCheckboxState("checked"); + }); + + it("resyncs checked state when the agent query updates after initial render", async () => { + const skillWithoutRoleDefault = { ...baseSkill, defaultForRoles: [] }; + const { queryClient, rerender } = renderSkillsTab( + { ...baseAgent, skillIds: [], desiredSkills: [] }, + [skillWithoutRoleDefault], + ); + expect(protocolCheckbox().getAttribute(CHECKBOX_STATE_ATTRIBUTE)).toBe("unchecked"); + + rerender( + + + + + + + , + ); + + await expectProtocolCheckboxState("checked"); + }); +}); 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 23c433e00e..9a0c365778 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,57 +1,82 @@ "use client"; -import { useEffect, useState, useCallback } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import Link from "@/components/routing/app-link"; 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 type { AgentProfile } from "@/lib/state/slices/office/types"; +import { updateAgentProfile } from "@/lib/api/domains/office-api"; +import type { AgentProfile, Skill } from "@/lib/state/slices/office/types"; +import { useActiveOfficeSkills, usePatchOfficeAgentProfileCache } from "../use-agent-detail-data"; 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]); +function uniqueStrings(values: string[]) { + return Array.from(new Set(values.filter(Boolean))); +} + +function arraysEqual(left: string[], right: string[]) { + if (left.length !== right.length) return false; + return left.every((value, index) => value === right[index]); +} + +function resolveAgentSkillIds(agent: AgentProfile, skills: Skill[]) { + const hasExplicitSkillSelection = + agent.skillIds !== undefined || agent.desiredSkills !== undefined; + const directIds = uniqueStrings(agent.skillIds ?? []); + if (directIds.length > 0) return directIds; + + const desired = uniqueStrings(agent.desiredSkills ?? []); + if (desired.length > 0) { + const skillIds = new Set(skills.map((skill) => skill.id)); + const skillIdBySlug = new Map(skills.map((skill) => [skill.slug, skill.id])); + const resolved = desired + .map((value) => (skillIds.has(value) ? value : skillIdBySlug.get(value))) + .filter((value): value is string => Boolean(value)); + if (resolved.length > 0) return uniqueStrings(resolved); + } + if (hasExplicitSkillSelection) return []; + + return uniqueStrings( + skills + .filter((skill) => skill.isSystem && (skill.defaultForRoles ?? []).includes(agent.role ?? "")) + .map((skill) => skill.id), + ); +} + +function selectedSkillSlugs(skillIds: string[], skills: Skill[]) { + const selected = new Set(skillIds); + return skills.filter((skill) => selected.has(skill.id)).map((skill) => skill.slug); +} + +function EmptySkillsState() { + return ( +
+

No skills registered yet.

+ +
+ ); } export function AgentSkillsTab({ agent }: AgentSkillsTabProps) { - useHydrateSkills(); - const skills = useAppStore((s) => s.office.skills); - const updateStore = useAppStore((s) => s.updateOfficeAgentProfile); - const [skillIds, setSkillIds] = useState(agent.skillIds ?? []); + const skills = useActiveOfficeSkills(); + const patchAgentCache = usePatchOfficeAgentProfileCache(); + const resolvedSkillIds = useMemo(() => resolveAgentSkillIds(agent, skills), [agent, skills]); + const [skillIds, setSkillIds] = useState(resolvedSkillIds); const [saving, setSaving] = useState(false); const [dirty, setDirty] = useState(false); + useEffect(() => { + if (dirty) return; + setSkillIds((current) => (arraysEqual(current, resolvedSkillIds) ? current : resolvedSkillIds)); + }, [dirty, resolvedSkillIds]); + const toggle = useCallback((id: string) => { setSkillIds((prev) => { const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]; @@ -63,8 +88,9 @@ export function AgentSkillsTab({ agent }: AgentSkillsTabProps) { const handleSave = useCallback(async () => { setSaving(true); try { - await updateAgentProfile(agent.id, { skillIds }); - updateStore(agent.id, { skillIds }); + const desiredSkills = selectedSkillSlugs(skillIds, skills); + await updateAgentProfile(agent.id, { skillIds, desiredSkills }); + patchAgentCache(agent.id, { skillIds, desiredSkills }); setDirty(false); toast.success("Skills updated"); } catch (err) { @@ -72,17 +98,10 @@ export function AgentSkillsTab({ agent }: AgentSkillsTabProps) { } finally { setSaving(false); } - }, [agent.id, skillIds, updateStore]); + }, [agent.id, skillIds, skills, patchAgentCache]); if (skills.length === 0) { - return ( -
-

No skills registered yet.

- -
- ); + return ; } const selected = new Set(skillIds); diff --git a/apps/web/app/office/agents/[id]/configuration/page.tsx b/apps/web/app/office/agents/[id]/configuration/page.tsx index 04974fcc35..949d66f7c5 100644 --- a/apps/web/app/office/agents/[id]/configuration/page.tsx +++ b/apps/web/app/office/agents/[id]/configuration/page.tsx @@ -1,14 +1,14 @@ "use client"; import { use } from "react"; -import { useAppStore } from "@/components/state-provider"; import { AgentConfigurationTab } from "../components/agent-configuration-tab"; +import { useOfficeAgentProfile } from "../use-agent-detail-data"; 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 agent = useOfficeAgentProfile(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 538f26bc4b..7d1c68d7b7 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,10 @@ "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 { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import { officeAgentSummaryQueryOptions } from "@/lib/query/query-options/office"; +import type { AgentSummaryResponse } from "@/lib/api/domains/office-extended-api"; import { LatestRunCard } from "./components/latest-run-card"; import { RunActivityChart } from "./components/run-activity-chart"; import { TasksByPriorityChart } from "./components/tasks-by-priority-chart"; @@ -19,34 +21,19 @@ 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. - * - * The chart components are pure route-safe presentational pieces; this - * shell exists so a future "Refresh" / "Date range" UI has somewhere - * to live without lifting state up into the parent route loader. + * Client-side shell for the agent dashboard. TanStack Query owns the + * SSR snapshot and the office bridge invalidates the summary key when + * runs, tasks, costs, agents, or session state changes. */ export function DashboardView({ agentId, initial, days }: Props) { - const [summary, setSummary] = useState(initial); + const queryClient = useQueryClient(); + const summaryQuery = useQuery(officeAgentSummaryQueryOptions(agentId, days)); - 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); + useEffect(() => { + queryClient.setQueryData(qk.office.agentSummary(agentId, days), initial); + }, [agentId, days, initial, queryClient]); + const summary = summaryQuery.data ?? initial; return (
diff --git a/apps/web/app/office/agents/[id]/instructions/page.tsx b/apps/web/app/office/agents/[id]/instructions/page.tsx index 3bd4270156..40f45ac659 100644 --- a/apps/web/app/office/agents/[id]/instructions/page.tsx +++ b/apps/web/app/office/agents/[id]/instructions/page.tsx @@ -1,14 +1,14 @@ "use client"; import { use } from "react"; -import { useAppStore } from "@/components/state-provider"; import { AgentInstructionsTab } from "../components/agent-instructions-tab"; +import { useOfficeAgentProfile } from "../use-agent-detail-data"; 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 agent = useOfficeAgentProfile(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 f4f931cc04..ee48e5a1e2 100644 --- a/apps/web/app/office/agents/[id]/layout.tsx +++ b/apps/web/app/office/agents/[id]/layout.tsx @@ -1,13 +1,10 @@ "use client"; -import { use, useCallback, useEffect, type ReactNode } from "react"; +import { use, type ReactNode } from "react"; import Link from "@/components/routing/app-link"; import { usePathname } from "@/lib/routing/client-router"; 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 { cn } from "@/lib/utils"; import { OfficeTopbarPortal } from "../../components/office-topbar-portal"; import { AgentAvatar } from "../../components/agent-avatar"; @@ -15,6 +12,7 @@ import { AgentStatusDot } from "../components/agent-status-dot"; import { AgentRoleBadge } from "../components/agent-role-badge"; import { BudgetGauge } from "../components/budget-gauge"; import { AgentRouteStrip } from "./components/agent-route-strip"; +import { useActiveOfficeRoutines, useOfficeAgentProfile } from "./use-agent-detail-data"; type AgentDetailLayoutProps = { children: ReactNode; @@ -42,27 +40,9 @@ 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 activeSlug = activeSlugFromPath(pathname, id); + const agent = useOfficeAgentProfile(id); if (!agent) { return ( @@ -145,7 +125,7 @@ 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 routines = useActiveOfficeRoutines(); 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 028385eaa3..51c1e29e14 100644 --- a/apps/web/app/office/agents/[id]/memory/page.tsx +++ b/apps/web/app/office/agents/[id]/memory/page.tsx @@ -1,14 +1,14 @@ "use client"; import { use } from "react"; -import { useAppStore } from "@/components/state-provider"; import { AgentMemoryTab } from "../components/agent-memory-tab"; +import { useOfficeAgentProfile } from "../use-agent-detail-data"; 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 agent = useOfficeAgentProfile(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 f238161e5e..8d8600a634 100644 --- a/apps/web/app/office/agents/[id]/permissions/page.tsx +++ b/apps/web/app/office/agents/[id]/permissions/page.tsx @@ -1,14 +1,14 @@ "use client"; import { use } from "react"; -import { useAppStore } from "@/components/state-provider"; import { AgentPermissionsTab } from "../components/agent-permissions-tab"; +import { useOfficeAgentProfile } from "../use-agent-detail-data"; 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 agent = useOfficeAgentProfile(id); if (!agent) return null; return ; } diff --git a/apps/web/app/office/agents/[id]/runs/[runId]/run-detail-view.tsx b/apps/web/app/office/agents/[id]/runs/[runId]/run-detail-view.tsx index 84e3d0b492..8cf3086ccb 100644 --- a/apps/web/app/office/agents/[id]/runs/[runId]/run-detail-view.tsx +++ b/apps/web/app/office/agents/[id]/runs/[runId]/run-detail-view.tsx @@ -1,7 +1,13 @@ "use client"; -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AgentRunsListPage, RunDetail } from "@/lib/api/domains/office-extended-api"; +import { qk } from "@/lib/query/keys"; +import { + officeAgentRunsInfiniteQueryOptions, + officeRunDetailQueryOptions, +} from "@/lib/query/query-options/office"; import { RunHeader } from "../components/run-header"; import { RecentRunsSidebar } from "../components/recent-runs-sidebar"; import { SessionCollapsible } from "../components/session-collapsible"; @@ -30,26 +36,40 @@ type Props = { * refetch. */ export function RunDetailView({ agentId, initial, recent }: Props) { - const taskId = initial.task_id ?? ""; - const sessionId = initial.session.session_id ?? ""; - const { events, status } = useRunLiveSync(initial.id, initial.events, initial.status); + const queryClient = useQueryClient(); + const detailQuery = useQuery(officeRunDetailQueryOptions(agentId, initial.id)); + const recentQuery = useInfiniteQuery(officeAgentRunsInfiniteQueryOptions(agentId, { limit: 30 })); + + useEffect(() => { + queryClient.setQueryData(qk.office.runDetail(agentId, initial.id), initial); + queryClient.setQueryData(qk.office.agentRuns(agentId, { limit: 30 }), { + pages: [recent], + pageParams: [undefined], + }); + }, [agentId, initial, queryClient, recent]); + + const run = detailQuery.data ?? initial; + const recentRuns = recentQuery.data?.pages[0]?.runs ?? recent.runs; + const taskId = run.task_id ?? ""; + const sessionId = run.session.session_id ?? ""; + const { events, status } = useRunLiveSync(run.id, run.events, run.status); const liveRun = useMemo( - () => (status === initial.status ? initial : { ...initial, status }), - [initial, status], + () => (status === run.status ? run : { ...run, status }), + [run, status], ); return (
- - - - - - + + + + + +
diff --git a/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx b/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx index f8337d6706..d16f9f51fd 100644 --- a/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx +++ b/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx @@ -1,10 +1,12 @@ "use client"; import Link from "@/components/routing/app-link"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; -import { getTask } from "@/lib/api/domains/office-extended-api"; +import { useAppStore } from "@/components/state-provider"; +import { officeTaskQueryOptions } from "@/lib/query/query-options"; import type { OfficeTask } from "@/lib/state/slices/office/types"; import { StatusIcon } from "@/app/office/tasks/status-icon"; @@ -12,8 +14,8 @@ type TasksTouchedProps = { runId: string; /** * Pre-resolved task ids from the run detail response. The component - * fetches the full task rows (identifier, title, status, priority) - * via the existing tasks API. Empty list renders the empty state. + * reads the full task rows (identifier, title, status, priority) + * via the task query cache. Empty list renders the empty state. */ taskIds: string[]; }; @@ -25,8 +27,8 @@ type TasksTouchedProps = { * runs that finished without claiming any task work. * * Prop contract is stable (Wave 0): `runId` + `taskIds`. The real task - * rows are fetched lazily after render so the run detail page can - * stream in even before the tasks API responds. + * rows are fetched lazily through TanStack Query so the run detail page + * can stream in even before the tasks API responds. */ export function TasksTouched({ runId, taskIds }: TasksTouchedProps) { if (taskIds.length === 0) { @@ -148,32 +150,24 @@ function formatStatus(status: string): string { } /** - * Loads each task row in parallel via the per-task API. Kept inline in - * this file because it's only used here; the office store is task-tree - * shaped and not optimal for an arbitrary id list. + * Loads each task row in parallel through the per-task query key. Kept + * inline because this component is the only arbitrary id-list consumer. */ function useTaskRows(taskIds: string[]): RowState[] { - const [results, setResults] = useState>({}); - useEffect(() => { - let cancelled = false; - void Promise.all( - taskIds.map(async (id) => { - try { - const res = await getTask(id); - return [id, { kind: "loaded" as const, task: res.task }] as const; - } catch { - return [id, { kind: "error" as const, id }] as const; - } - }), - ).then((entries) => { - if (!cancelled) setResults(Object.fromEntries(entries)); - }); - return () => { - cancelled = true; - }; - }, [taskIds]); + const workspaceId = useAppStore((s) => s.workspaces.activeId) ?? ""; + const queries = useQueries({ + queries: taskIds.map((taskId) => officeTaskQueryOptions(workspaceId, taskId)), + }); + return useMemo( - () => taskIds.map((id) => results[id] ?? { kind: "loading" as const, id }), - [taskIds, results], + () => + taskIds.map((id, index) => { + if (!workspaceId) return { kind: "error" as const, id }; + const query = queries[index]; + if (query?.data?.task) return { kind: "loaded" as const, task: query.data.task }; + if (query?.isError || query?.isSuccess) return { kind: "error" as const, id }; + return { kind: "loading" as const, id }; + }), + [taskIds, queries, workspaceId], ); } 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 52100d20db..a6ede86705 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 @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "@/components/routing/app-link"; import { IconClock, @@ -13,8 +14,13 @@ import { import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { + officeAgentRunsInfiniteQueryOptions, + officeRoutinesQueryOptions, + officeTaskQueryOptions, +} from "@/lib/query/query-options/office"; import { - listAgentRuns, type AgentRunsListPage, type AgentRunSummary, } from "@/lib/api/domains/office-extended-api"; @@ -54,34 +60,32 @@ function formatReason(reason: string): string { * preserved when a new page is appended. */ export function RunsListView({ initial, agentId }: Props) { - const [pages, setPages] = useState([initial]); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); + const runsQuery = useInfiniteQuery(officeAgentRunsInfiniteQueryOptions(agentId, { limit: 25 })); const containerRef = useRef(null); + useEffect(() => { + queryClient.setQueryData(qk.office.agentRuns(agentId, { limit: 25 }), { + pages: [initial], + pageParams: [undefined], + }); + }, [agentId, initial, queryClient]); + + const pages = runsQuery.data?.pages ?? [initial]; const lastPage = pages[pages.length - 1]; const runs = pages.flatMap((p) => p.runs); const hasMore = Boolean(lastPage?.next_cursor); const loadMore = useCallback(async () => { - if (!hasMore || loading) return; + if (!hasMore || runsQuery.isFetchingNextPage) return; const scrollY = containerRef.current?.scrollTop ?? 0; - setLoading(true); - try { - const next = await listAgentRuns(agentId, { - cursor: lastPage.next_cursor, - cursorId: lastPage.next_id, - limit: 25, - }); - setPages((prev) => [...prev, next]); - requestAnimationFrame(() => { - if (containerRef.current) { - containerRef.current.scrollTop = scrollY; - } - }); - } finally { - setLoading(false); - } - }, [agentId, hasMore, lastPage, loading]); + await runsQuery.fetchNextPage(); + requestAnimationFrame(() => { + if (containerRef.current) { + containerRef.current.scrollTop = scrollY; + } + }); + }, [hasMore, runsQuery]); if (runs.length === 0) { return ( @@ -114,7 +118,11 @@ export function RunsListView({ initial, agentId }: Props) { {runs.map((run) => ( ))} - +
); } @@ -156,56 +164,57 @@ 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, - ); + if (run.routine_id) return ; + if (run.task_id) return ; + return ; +} - if (run.routine_id) { - const label = routine?.name ?? "Routine"; - return ( - - - {label} - - ); - } +function RoutineLinkedEntity({ routineId }: { routineId: string }) { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const routinesQuery = useQuery({ + ...officeRoutinesQueryOptions(workspaceId ?? ""), + enabled: Boolean(workspaceId), + }); + const routine = routinesQuery.data?.routines.find((r) => r.id === routineId); + const label = routine?.name ?? "Routine"; - if (run.task_id && run.comment_id) { - const label = task ? `${task.identifier}: ${task.title}` : "Comment"; - return ( - - - {label} - - ); - } + return ( + + + {label} + + ); +} - if (run.task_id) { - const label = task ? `${task.identifier}: ${task.title}` : "Task"; - return ( - - - {label} - - ); - } +function TaskLinkedEntity({ taskId, commentId }: { taskId: string; commentId?: string }) { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const taskQuery = useQuery({ + ...officeTaskQueryOptions(workspaceId ?? "", taskId), + enabled: Boolean(workspaceId), + }); + const task = taskQuery.data?.task; + const isComment = Boolean(commentId); + let label = isComment ? "Comment" : "Task"; + if (task) label = `${task.identifier}: ${task.title}`; + const href = isComment + ? `/office/tasks/${taskId}#comment-${commentId}` + : `/office/tasks/${taskId}`; + const Icon = isComment ? IconMessageCircle : IconChecklist; - return ; + return ( + + + {label} + + ); } type LoadMoreFooterProps = { diff --git a/apps/web/app/office/agents/[id]/skills/page.tsx b/apps/web/app/office/agents/[id]/skills/page.tsx index 554202de88..cd22ad6a4d 100644 --- a/apps/web/app/office/agents/[id]/skills/page.tsx +++ b/apps/web/app/office/agents/[id]/skills/page.tsx @@ -1,14 +1,14 @@ "use client"; import { use } from "react"; -import { useAppStore } from "@/components/state-provider"; import { AgentSkillsTab } from "../components/agent-skills-tab"; +import { useOfficeAgentProfile } from "../use-agent-detail-data"; 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 agent = useOfficeAgentProfile(id); if (!agent) return null; return ; } diff --git a/apps/web/app/office/agents/[id]/use-agent-detail-data.test.tsx b/apps/web/app/office/agents/[id]/use-agent-detail-data.test.tsx new file mode 100644 index 0000000000..be85e4f54c --- /dev/null +++ b/apps/web/app/office/agents/[id]/use-agent-detail-data.test.tsx @@ -0,0 +1,70 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import type { AgentProfile } from "@/lib/state/slices/office/types"; +import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; +import { usePatchOfficeAgentProfileCache } from "./use-agent-detail-data"; + +const AGENT_TIMESTAMP = "2026-05-04T00:00:00Z"; +const WORKSPACE_ID = "workspace-1"; +const AGENT_ID = toAgentProfileId("agent-1"); +const OTHER_AGENT_ID = toAgentProfileId("agent-2"); + +function agent(overrides: Partial = {}): AgentProfile { + return { + id: AGENT_ID, + workspaceId: WORKSPACE_ID, + name: "Agent", + role: "worker", + status: "idle", + createdAt: AGENT_TIMESTAMP, + updatedAt: AGENT_TIMESTAMP, + permissions: {}, + budgetMonthlyCents: 5000, + maxConcurrentSessions: 2, + ...overrides, + } as AgentProfile; +} + +function renderPatchHook(workspaceId = WORKSPACE_ID) { + const queryClient = makeQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + + return { + ...renderHook(() => usePatchOfficeAgentProfileCache(), { wrapper }), + queryClient, + }; +} + +afterEach(() => { + cleanup(); +}); + +describe("usePatchOfficeAgentProfileCache", () => { + it("patches cached agent profiles without immediately invalidating the list", () => { + const { result, queryClient } = renderPatchHook(); + queryClient.setQueryData(qk.office.agents(WORKSPACE_ID), { + agents: [agent(), agent({ id: OTHER_AGENT_ID, name: "Other" })], + }); + + act(() => result.current(AGENT_ID, { name: "Updated" })); + + expect(queryClient.getQueryData(qk.office.agents(WORKSPACE_ID))).toEqual({ + agents: [ + expect.objectContaining({ id: AGENT_ID, name: "Updated" }), + agent({ id: OTHER_AGENT_ID, name: "Other" }), + ], + }); + expect(queryClient.getQueryState(qk.office.agents(WORKSPACE_ID))?.isInvalidated).toBe(false); + }); +}); diff --git a/apps/web/app/office/agents/[id]/use-agent-detail-data.ts b/apps/web/app/office/agents/[id]/use-agent-detail-data.ts new file mode 100644 index 0000000000..270ea94f5b --- /dev/null +++ b/apps/web/app/office/agents/[id]/use-agent-detail-data.ts @@ -0,0 +1,58 @@ +"use client"; + +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAppStore } from "@/components/state-provider"; +import { useOfficeAgentsData } from "@/hooks/domains/office/use-office-data"; +import { qk } from "@/lib/query/keys"; +import { + officeRoutinesQueryOptions, + officeSkillsQueryOptions, +} from "@/lib/query/query-options/office"; +import type { AgentProfile } from "@/lib/state/slices/office/types"; + +export function useOfficeAgentProfiles() { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + return useOfficeAgentsData(workspaceId).data?.agents ?? []; +} + +export function useOfficeAgentProfile(agentId: string) { + return useOfficeAgentProfiles().find((agent) => agent.id === agentId); +} + +export function useActiveOfficeRoutines() { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const routinesQuery = useQuery(officeRoutinesQueryOptions(workspaceId ?? "")); + return routinesQuery.data?.routines ?? []; +} + +export function useActiveOfficeSkills() { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const skillsQuery = useQuery(officeSkillsQueryOptions(workspaceId ?? "")); + return skillsQuery.data?.skills ?? []; +} + +export function usePatchOfficeAgentProfileCache() { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const queryClient = useQueryClient(); + + return useCallback( + (agentId: string, patch: Partial) => { + if (!workspaceId) return; + + queryClient.setQueryData<{ agents: AgentProfile[] }>( + qk.office.agents(workspaceId), + (current) => { + if (!current) return current; + return { + ...current, + agents: current.agents.map((agent) => + agent.id === agentId ? { ...agent, ...patch } : agent, + ), + }; + }, + ); + }, + [queryClient, workspaceId], + ); +} diff --git a/apps/web/app/office/agents/agents-page-client.tsx b/apps/web/app/office/agents/agents-page-client.tsx index 5b84786fb6..947a889566 100644 --- a/apps/web/app/office/agents/agents-page-client.tsx +++ b/apps/web/app/office/agents/agents-page-client.tsx @@ -1,13 +1,12 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useMemo, useState } from "react"; 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 { useOfficeAgentsData } from "@/hooks/domains/office/use-office-data"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import { AgentCard } from "./components/agent-card"; import { CreateAgentDialog } from "./components/create-agent-dialog"; @@ -15,42 +14,21 @@ import { EmptyState } from "../components/shared/empty-state"; import { PageHeader } from "../components/shared/page-header"; type AgentsPageClientProps = { - initialAgents: AgentProfile[]; + initialAgents?: AgentProfile[]; }; export function AgentsPageClient({ initialAgents }: AgentsPageClientProps) { - const agents = useAppStore((s) => s.office.agentProfiles); - const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); const workspaceId = useAppStore((s) => s.workspaces.activeId); + const agentsQuery = useOfficeAgentsData(workspaceId, initialAgents); const [showCreate, setShowCreate] = useState(false); - // Mounting these hooks fetches workspace routing config + preview once; - // every agent card reads the resolved preview from the store. - useWorkspaceRouting(workspaceId); - useRoutingPreview(workspaceId); + const routing = useWorkspaceRouting(workspaceId); + const routingPreview = 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); + const agents = agentsQuery.data?.agents ?? initialAgents ?? []; + const previewsByAgentId = useMemo( + () => new Map(routingPreview.agents.map((preview) => [preview.agent_id, preview])), + [routingPreview.agents], + ); return (
@@ -83,7 +61,12 @@ export function AgentsPageClient({ initialAgents }: AgentsPageClientProps) { ) : (
{agents.map((agent) => ( - + ))}
)} diff --git a/apps/web/app/office/agents/components/agent-card.tsx b/apps/web/app/office/agents/components/agent-card.tsx index f6feed51d9..c6ecb76ba0 100644 --- a/apps/web/app/office/agents/components/agent-card.tsx +++ b/apps/web/app/office/agents/components/agent-card.tsx @@ -3,7 +3,6 @@ import Link from "@/components/routing/app-link"; import { Badge } from "@kandev/ui/badge"; import { Card, CardContent } from "@kandev/ui/card"; -import { useAppStore } from "@/components/state-provider"; import type { AgentProfile, AgentRoutePreview } from "@/lib/state/slices/office/types"; import { AgentAvatar } from "../../components/agent-avatar"; // (path from /agents/components/ → /office/components/ resolves correctly) @@ -14,21 +13,12 @@ import { providerLabel } from "../../workspace/routing/components/provider-order type AgentCardProps = { agent: AgentProfile; + routingEnabled?: boolean; + routingPreview?: AgentRoutePreview; }; -export function AgentCard({ agent }: AgentCardProps) { +export function AgentCard({ agent, routingEnabled = false, routingPreview }: AgentCardProps) { const isPending = agent.status === "pending_approval"; - const workspaceId = useAppStore((s) => 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, - ); return ( - {routingEnabled && preview && } + {routingEnabled && routingPreview && }
diff --git a/apps/web/app/office/agents/components/agent-role-badge.tsx b/apps/web/app/office/agents/components/agent-role-badge.tsx index 7508843a56..9991333032 100644 --- a/apps/web/app/office/agents/components/agent-role-badge.tsx +++ b/apps/web/app/office/agents/components/agent-role-badge.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@kandev/ui/badge"; -import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import type { AgentRole } from "@/lib/state/slices/office/types"; const FALLBACK_COLORS: Record = { @@ -19,7 +19,7 @@ type AgentRoleBadgeProps = { }; export function AgentRoleBadge({ role }: AgentRoleBadgeProps) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; 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 2894274e88..4ab74e3350 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,7 @@ "use client"; import { cn } from "@/lib/utils"; -import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import type { AgentStatus } from "@/lib/state/slices/office/types"; const FALLBACK_STYLES: Record = { @@ -18,7 +18,7 @@ type AgentStatusDotProps = { }; export function AgentStatusDot({ status, className }: AgentStatusDotProps) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; 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 b6a72feb8c..5547171cd6 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 { useQueryClient } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; @@ -8,7 +9,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from " import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; +import { useOfficeAgentsData, useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import { createAgentProfile } from "@/lib/api/domains/office-api"; +import { qk } from "@/lib/query/keys"; import type { AgentRole, AgentProfile } from "@/lib/state/slices/office/types"; type CreateAgentDialogProps = { @@ -208,9 +211,9 @@ 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 queryClient = useQueryClient(); + const agents = useOfficeAgentsData(workspaceId).data?.agents ?? []; + const meta = useOfficeMetaData().data; const roles = meta?.roles.map((r) => ({ id: r.id, label: r.label })) ?? FALLBACK_ROLES; const executorTypes = @@ -237,7 +240,7 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps executorPreference: state.executorPref ? { type: state.executorPref } : undefined, } as Partial); if (result) { - addOfficeAgentProfile(result); + appendAgent(queryClient, workspaceId, result); } setState(INITIAL_STATE); onOpenChange(false); @@ -249,7 +252,7 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps } finally { setSubmitting(false); } - }, [state, workspaceId, addOfficeAgentProfile, onOpenChange]); + }, [state, workspaceId, queryClient, onOpenChange]); return ( @@ -293,3 +296,15 @@ export function CreateAgentDialog({ open, onOpenChange }: CreateAgentDialogProps ); } + +function appendAgent( + queryClient: ReturnType, + workspaceId: string, + agent: AgentProfile, +) { + queryClient.setQueryData<{ agents: AgentProfile[] }>(qk.office.agents(workspaceId), (current) => { + const agents = current?.agents ?? []; + if (agents.some((item) => item.id === agent.id)) return { agents }; + return { agents: [...agents, agent] }; + }); +} 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 ab93d2c1f3..3f6acebeec 100644 --- a/apps/web/app/office/components/new-task-bottom-bar.tsx +++ b/apps/web/app/office/components/new-task-bottom-bar.tsx @@ -12,7 +12,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 { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import type { IssueDraft } from "./new-task-draft"; type StatusOption = { value: string; label: string; className: string }; @@ -44,7 +44,7 @@ type Props = { }; function useStatusOptions(): StatusOption[] { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; if (!meta) return FALLBACK_STATUS_OPTIONS; // Only show creation-relevant statuses (backlog, todo, in_progress) const creationStatuses = ["backlog", "todo", "in_progress"]; @@ -82,7 +82,7 @@ function StatusChip({ draft, onUpdate }: Props) { } function usePriorityOptions(): PriorityOption[] { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; 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 4ed7aadd09..469dd77b9d 100644 --- a/apps/web/app/office/components/new-task-selector-row.tsx +++ b/apps/web/app/office/components/new-task-selector-row.tsx @@ -11,6 +11,7 @@ import { } from "@kandev/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; +import { useOfficeAgentsData, useOfficeProjectsData } from "@/hooks/domains/office/use-office-data"; 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 +108,9 @@ 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 agents = useOfficeAgentsData(workspaceId).data?.agents ?? []; + const projects = useOfficeProjectsData(workspaceId).data?.projects ?? []; return (
diff --git a/apps/web/app/office/components/new-task-stages.tsx b/apps/web/app/office/components/new-task-stages.tsx index bc03df1efc..98b0506206 100644 --- a/apps/web/app/office/components/new-task-stages.tsx +++ b/apps/web/app/office/components/new-task-stages.tsx @@ -4,6 +4,7 @@ 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 { useOfficeAgentsData } from "@/hooks/domains/office/use-office-data"; import type { AgentProfile } from "@/lib/state/slices/office/types"; // Execution policy stage types @@ -144,7 +145,8 @@ type Props = { }; export function NewTaskStages({ stages, onUpdate }: Props) { - const agents = useAppStore((s) => s.office.agentProfiles); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const agents = useOfficeAgentsData(workspaceId).data?.agents ?? []; return (
diff --git a/apps/web/app/office/inbox/inbox-item-row.tsx b/apps/web/app/office/inbox/inbox-item-row.tsx index 90aa6eb5ad..1b56f9f227 100644 --- a/apps/web/app/office/inbox/inbox-item-row.tsx +++ b/apps/web/app/office/inbox/inbox-item-row.tsx @@ -13,6 +13,7 @@ import { import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import { dismissInboxItem, retryProvider } from "@/lib/api/domains/office-extended-api"; import type { InboxItem } from "@/lib/state/slices/office/types"; import { timeAgo } from "@/lib/utils/time"; @@ -62,7 +63,7 @@ type Props = { }; function useInboxTypeConfig(type: string) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; const metaType = meta?.inboxItemTypes.find((t) => t.id === type); if (metaType) { return { diff --git a/apps/web/app/office/inbox/inbox-page-client.test.tsx b/apps/web/app/office/inbox/inbox-page-client.test.tsx new file mode 100644 index 0000000000..c706c9348a --- /dev/null +++ b/apps/web/app/office/inbox/inbox-page-client.test.tsx @@ -0,0 +1,104 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import type { InboxItem, OfficeMeta } from "@/lib/state/slices/office/types"; + +const getInboxMock = vi.hoisted(() => vi.fn()); +const getMetaMock = vi.hoisted(() => vi.fn()); +const listAgentProfilesMock = vi.hoisted(() => vi.fn(async () => ({ agents: [] }))); +const decideApprovalMock = vi.hoisted(() => vi.fn()); + +const state = { + workspaces: { activeId: "workspace-1" }, + office: { + inboxItems: [ + { + id: "store-item", + type: "approval", + title: "Store-only approval", + status: "pending", + createdAt: "2026-06-24T00:00:00Z", + } satisfies InboxItem, + ], + meta: null, + }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (s: typeof state) => unknown) => selector(state), +})); + +vi.mock("@/lib/api/domains/office-api", () => ({ + decideApproval: decideApprovalMock, + getInbox: getInboxMock, + getMeta: getMetaMock, + listAgentProfiles: listAgentProfilesMock, +})); + +vi.mock("@/components/routing/app-link", () => ({ + default: ({ href, children, ...props }: ComponentProps<"a"> & { href: string }) => ( + + {children} + + ), +})); + +import { InboxPageClient } from "./inbox-page-client"; + +function inboxItem(overrides: Partial = {}): InboxItem { + return { + id: "query-item", + type: "approval", + title: "Query approval", + description: "From TanStack Query", + status: "pending", + createdAt: "2026-06-24T00:00:00Z", + ...overrides, + }; +} + +function officeMeta(): OfficeMeta { + return { + statuses: [], + priorities: [], + roles: [], + executorTypes: [], + skillSourceTypes: [], + projectStatuses: [], + agentStatuses: [], + routineRunStatuses: [], + inboxItemTypes: [], + permissions: [], + permissionDefaults: {}, + }; +} + +describe("InboxPageClient", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("renders inbox items from the TanStack Query cache", () => { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.inbox("workspace-1"), { + items: [inboxItem()], + total_count: 1, + }); + queryClient.setQueryData(qk.office.meta(), officeMeta()); + + render( + + + , + ); + + expect(screen.getByText("Query approval")).toBeTruthy(); + expect(screen.queryByText("Store-only approval")).toBeNull(); + expect(getInboxMock).not.toHaveBeenCalled(); + expect(getMetaMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/office/inbox/inbox-page-client.tsx b/apps/web/app/office/inbox/inbox-page-client.tsx index 8947cc896f..8a816ef637 100644 --- a/apps/web/app/office/inbox/inbox-page-client.tsx +++ b/apps/web/app/office/inbox/inbox-page-client.tsx @@ -1,63 +1,28 @@ "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 { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import * as officeApi from "@/lib/api/domains/office-api"; +import { useOfficeInboxData } from "@/hooks/domains/office/use-office-data"; +import { decideApproval } from "@/lib/api/domains/office-api"; import type { InboxItem } from "@/lib/state/slices/office/types"; import { InboxItemRow } from "./inbox-item-row"; type TabValue = "mine" | "recent" | "all"; type InboxPageClientProps = { - initialItems: InboxItem[]; - initialCount: number; + initialItems?: InboxItem[]; + 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) { const handleApprove = useCallback( async (id: string) => { try { - await officeApi.decideApproval(id, { status: "approved" }); + await decideApproval(id, { status: "approved" }); void fetchInbox(); toast.success("Approved"); } catch (err) { @@ -70,7 +35,7 @@ function useApprovalActions(fetchInbox: () => Promise) { const handleReject = useCallback( async (id: string) => { try { - await officeApi.decideApproval(id, { status: "rejected" }); + await decideApproval(id, { status: "rejected" }); void fetchInbox(); toast.success("Rejected"); } catch (err) { @@ -124,14 +89,13 @@ function InboxToolbar({ export function InboxPageClient({ initialItems, initialCount }: InboxPageClientProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const inboxItems = useAppStore((s) => s.office.inboxItems); const [tab, setTab] = useState("mine"); const [search, setSearch] = useState(""); - const fetchInbox = useInboxData(workspaceId, initialItems, initialCount); + const inboxQuery = useOfficeInboxData(workspaceId, initialItems, initialCount); + const fetchInbox = inboxQuery.refetchAll; const { handleApprove, handleReject } = useApprovalActions(fetchInbox); - - useOfficeRefetch("inbox", fetchInbox); + const inboxItems = inboxQuery.data?.items ?? initialItems ?? []; const filteredItems = useMemo(() => { let items: InboxItem[] = inboxItems; diff --git a/apps/web/app/office/layout.tsx b/apps/web/app/office/layout.tsx index 1a4a41596b..ce54fef092 100644 --- a/apps/web/app/office/layout.tsx +++ b/apps/web/app/office/layout.tsx @@ -12,7 +12,8 @@ import { } from "@/lib/api/domains/office-api"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { readCookies } from "@/lib/server/cookies"; -import type { AppState } from "@/lib/state/store"; +import type { QuerySeedInitialState } from "@/lib/query/seed"; +import type { Workspace } from "@/lib/types/http"; import { OfficeTopbar } from "./components/office-topbar"; function resolveActiveOfficeWorkspaceId( @@ -28,19 +29,7 @@ function resolveActiveOfficeWorkspaceId( ); } -function mapWorkspaceItem(ws: { - id: string; - name: string; - description?: string | null; - owner_id: string; - default_executor_id?: string | null; - default_environment_id?: string | null; - default_agent_profile_id?: string | null; - default_config_agent_profile_id?: string | null; - office_workflow_id?: string | null; - created_at: string; - updated_at: string; -}) { +function mapWorkspaceItem(ws: Workspace): Workspace { return { id: ws.id, name: ws.name, @@ -50,7 +39,7 @@ function mapWorkspaceItem(ws: { default_environment_id: ws.default_environment_id ?? null, default_agent_profile_id: ws.default_agent_profile_id ?? null, default_config_agent_profile_id: ws.default_config_agent_profile_id ?? null, - office_workflow_id: ws.office_workflow_id ?? null, + office_workflow_id: ws.office_workflow_id ?? undefined, created_at: ws.created_at, updated_at: ws.updated_at, }; @@ -120,7 +109,7 @@ export default async function OfficeLayout({ children }: { children: React.React ]) : [{ agents: [] }, { projects: [] }, { items: [], total_count: 0 }]; - const initialState: Partial = { + const initialState: QuerySeedInitialState = { workspaces: { items: workspaceItems, activeId: activeWorkspaceId, @@ -130,35 +119,11 @@ export default async function OfficeLayout({ children }: { children: React.React 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"], + agents: agentsResponse.agents, + projects: projectsResponse.projects, + inboxItems: inboxResponse.items, 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: {} }, }, }; diff --git a/apps/web/app/office/page-client.test.tsx b/apps/web/app/office/page-client.test.tsx index 97a3749ef7..f5da750686 100644 --- a/apps/web/app/office/page-client.test.tsx +++ b/apps/web/app/office/page-client.test.tsx @@ -1,35 +1,29 @@ +/* eslint-disable sonarjs/no-duplicate-string */ import { cleanup, render, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { DashboardData } from "@/lib/state/slices/office/types"; const getDashboardMock = vi.hoisted(() => vi.fn()); -const setDashboardMock = vi.hoisted(() => vi.fn()); +const listAgentProfilesMock = vi.hoisted(() => vi.fn(async () => ({ agents: [] }))); const state = { workspaces: { activeId: "workspace-1" }, - office: { - dashboard: null as DashboardData | null, - agentProfiles: [], - routing: { - byWorkspace: {}, - knownProviders: [], - preview: { byWorkspace: {} }, - }, - providerHealth: { byWorkspace: {} }, - }, - setDashboard: setDashboardMock, }; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), })); -vi.mock("@/hooks/use-office-refetch", () => ({ - useOfficeRefetch: vi.fn(), -})); - vi.mock("@/lib/api/domains/office-api", () => ({ getDashboard: getDashboardMock, + listAgentProfiles: listAgentProfilesMock, +})); + +vi.mock("./components/routing/provider-health-card", () => ({ + ProviderHealthCard: () => null, })); import { OfficePageClient } from "./page-client"; @@ -61,13 +55,14 @@ describe("OfficePageClient boot hydration", () => { cleanup(); vi.clearAllMocks(); state.workspaces.activeId = "workspace-1"; - state.office.dashboard = null; }); it("does not fetch dashboard data when Go boot state already hydrated it", async () => { - state.office.dashboard = dashboard(); + const queryClient = makeQueryClient(); + const data = dashboard(); + queryClient.setQueryData(qk.office.dashboard("workspace-1"), data); - render(); + renderOfficePage(queryClient); await waitFor(() => { expect(getDashboardMock).not.toHaveBeenCalled(); @@ -78,29 +73,46 @@ describe("OfficePageClient boot hydration", () => { const data = dashboard(); getDashboardMock.mockResolvedValue(data); - render(); + const { queryClient } = renderOfficePage(); await waitFor(() => { - expect(getDashboardMock).toHaveBeenCalledWith("workspace-1"); + expect(getDashboardMock).toHaveBeenCalledWith("workspace-1", expect.anything()); }); await waitFor(() => { - expect(setDashboardMock).toHaveBeenCalledWith(data); + expect(queryClient.getQueryData(qk.office.dashboard("workspace-1"))).toEqual(data); }); }); it("refetches dashboard data when the active workspace changes", async () => { - state.office.dashboard = dashboard(); + const initialDashboard = dashboard(); getDashboardMock.mockResolvedValue({ ...dashboard(), agent_count: 2 }); - const { rerender } = render(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.dashboard("workspace-1"), initialDashboard); + const { rerender } = renderOfficePage(queryClient); expect(getDashboardMock).not.toHaveBeenCalled(); state.workspaces.activeId = "workspace-2"; - rerender(); + rerender( + + + , + ); await waitFor(() => { - expect(getDashboardMock).toHaveBeenCalledWith("workspace-2"); + expect(getDashboardMock).toHaveBeenCalledWith("workspace-2", expect.anything()); }); }); }); + +function renderOfficePage(queryClient = makeQueryClient()) { + return { + queryClient, + ...render( + + + , + ), + }; +} diff --git a/apps/web/app/office/page-client.tsx b/apps/web/app/office/page-client.tsx index e7f1b4b617..edc2682143 100644 --- a/apps/web/app/office/page-client.tsx +++ b/apps/web/app/office/page-client.tsx @@ -1,6 +1,5 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; import Link from "@/components/routing/app-link"; import { IconRobot, @@ -11,8 +10,10 @@ import { } from "@tabler/icons-react"; import { Card } from "@kandev/ui/card"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import * as officeApi from "@/lib/api/domains/office-api"; +import { + useOfficeAgentsData, + useOfficeDashboardData, +} from "@/hooks/domains/office/use-office-data"; import { normalizeActivityEntry } from "@/lib/api/domains/office-activity-normalize"; import { StatusIcon } from "./tasks/status-icon"; import type { DashboardData, AgentProfile, RecentTask } from "@/lib/state/slices/office/types"; @@ -212,62 +213,33 @@ function SubscriptionUsageCard({ agents }: { agents: AgentProfile[] }) { ); } +function subscriptionQuotaState(agents: AgentProfile[]) { + const topUtilization = maxUtilization(agents); + return { + label: topUtilization > 0 ? `${Math.round(topUtilization)}%` : "—", + visible: agents.some((a) => a.billingType === "subscription"), + }; +} + export function OfficePageClient({ initialDashboard }: OfficePageClientProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const dashboard = useAppStore((s) => s.office.dashboard); - const agents = useAppStore((s) => s.office.agentProfiles); - const setDashboard = useAppStore((s) => s.setDashboard); - const dashboardWorkspaceIdRef = useRef( - (dashboard || initialDashboard) && workspaceId ? workspaceId : null, - ); - - // 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); - dashboardWorkspaceIdRef.current = workspaceId; - }, [workspaceId, setDashboard]); - - useEffect(() => { - if (!workspaceId || dashboardWorkspaceIdRef.current === workspaceId) return; - dashboardWorkspaceIdRef.current = workspaceId; - void fetchDashboard().catch(() => { - if (dashboardWorkspaceIdRef.current === workspaceId) { - dashboardWorkspaceIdRef.current = null; - } - }); - }, [fetchDashboard, workspaceId]); - - // 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 dashboardQuery = useOfficeDashboardData(workspaceId, initialDashboard); + const agentsQuery = useOfficeAgentsData(workspaceId); + const dashboard = dashboardQuery.data ?? initialDashboard ?? null; + const agents = agentsQuery.data?.agents ?? []; const metrics = extractMetrics(dashboard); - const topUtilization = maxUtilization(agents); - const quotaLabel = topUtilization > 0 ? `${Math.round(topUtilization)}%` : "—"; - const hasSubscriptionAgents = agents.some((a) => a.billingType === "subscription"); + const subscriptionQuota = subscriptionQuotaState(agents); return (
- {hasSubscriptionAgents && ( + {subscriptionQuota.visible && (
diff --git a/apps/web/app/office/projects/[id]/page.tsx b/apps/web/app/office/projects/[id]/page.tsx index 97fc40e55f..75be492075 100644 --- a/apps/web/app/office/projects/[id]/page.tsx +++ b/apps/web/app/office/projects/[id]/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState, use } from "react"; +import { use, useCallback, useEffect, useSyncExternalStore } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "@/components/routing/app-link"; import { useRouter } from "@/lib/routing/client-router"; import { IconChevronRight, IconTrash } from "@tabler/icons-react"; @@ -8,13 +9,20 @@ 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 { deleteProject } from "@/lib/api/domains/office-api"; +import { qk } from "@/lib/query/keys"; +import { officeProjectQueryOptions } from "@/lib/query/query-options"; import type { Project } from "@/lib/state/slices/office/types"; import { OfficeTopbarPortal } from "../../components/office-topbar-portal"; import { ProjectHeader } from "./project-header"; import { ProjectReposSection } from "./project-repos-section"; import { ProjectExecutorSection } from "./project-executor-section"; import { ProjectTasksSection } from "./project-tasks-section"; +import { + readProjectFromListCache, + removeProjectFromList, + type OfficeProjectsCache, +} from "./project-query-cache"; type PageProps = { params: Promise<{ id: string }>; @@ -23,33 +31,28 @@ 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; + const queryClient = useQueryClient(); + const projectQuery = useQuery(officeProjectQueryOptions(id)); + const cachedProject = useCachedProjectFromList(id); + const project = projectQuery.data ?? cachedProject ?? null; 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]); + if (!projectQuery.error) return; + toast.error( + projectQuery.error instanceof Error ? projectQuery.error.message : "Failed to load project", + ); + }, [projectQuery.error]); const handleDelete = async () => { if (!project) return; try { await deleteProject(project.id); - removeProject(project.id); + queryClient.removeQueries({ exact: true, queryKey: qk.office.project(project.id) }); + const projectsKey = qk.office.projects(project.workspaceId); + queryClient.setQueryData(projectsKey, (current) => + removeProjectFromList(current, project.id), + ); + queryClient.invalidateQueries({ exact: true, queryKey: projectsKey }); toast.success("Project deleted"); router.push("/office/projects"); } catch (err) { @@ -105,3 +108,17 @@ export default function ProjectDetailPage({ params }: PageProps) { ); } + +function useCachedProjectFromList(projectId: string): Project | null { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const queryClient = useQueryClient(); + const subscribe = useCallback( + (onStoreChange: () => void) => queryClient.getQueryCache().subscribe(onStoreChange), + [queryClient], + ); + const getSnapshot = useCallback( + () => readProjectFromListCache(queryClient, workspaceId, projectId), + [projectId, queryClient, workspaceId], + ); + return useSyncExternalStore(subscribe, getSnapshot, () => null); +} 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 e42ecbaaeb..092f9ca7a4 100644 --- a/apps/web/app/office/projects/[id]/project-executor-section.tsx +++ b/apps/web/app/office/projects/[id]/project-executor-section.tsx @@ -8,8 +8,8 @@ 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 type { Project } from "@/lib/state/slices/office/types"; +import { useSyncOfficeProjectCache } from "./project-query-cache"; type ProjectExecutorSectionProps = { project: Project; @@ -95,7 +95,7 @@ function ContainerFields({ } export function ProjectExecutorSection({ project }: ProjectExecutorSectionProps) { - const updateProjectStore = useAppStore((s) => s.updateProject); + const syncProjectCache = useSyncOfficeProjectCache(); const config = project.executorConfig ?? {}; const [executorType, setExecutorType] = useState((config.type as string) ?? ""); @@ -123,8 +123,8 @@ export function ProjectExecutorSection({ project }: ProjectExecutorSectionProps) if (cpuCores) limits.cpu_cores = parseInt(cpuCores, 10); newConfig.resource_limits = limits; } - await updateProject(project.id, { executorConfig: newConfig }); - updateProjectStore(project.id, { executorConfig: newConfig }); + const updatedProject = await updateProject(project.id, { executorConfig: newConfig }); + syncProjectCache(updatedProject); setDirty(false); toast.success("Executor configuration saved"); } catch (err) { @@ -132,7 +132,7 @@ export function ProjectExecutorSection({ project }: ProjectExecutorSectionProps) } finally { setSaving(false); } - }, [executorType, image, memoryMb, cpuCores, isContainer, project.id, updateProjectStore]); + }, [executorType, image, memoryMb, cpuCores, isContainer, project.id, syncProjectCache]); return (
diff --git a/apps/web/app/office/projects/[id]/project-header.tsx b/apps/web/app/office/projects/[id]/project-header.tsx index abe0c5313e..3c9e5db0fe 100644 --- a/apps/web/app/office/projects/[id]/project-header.tsx +++ b/apps/web/app/office/projects/[id]/project-header.tsx @@ -8,8 +8,8 @@ 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 type { Project, ProjectStatus } from "@/lib/state/slices/office/types"; +import { useSyncOfficeProjectCache } from "./project-query-cache"; const STATUS_OPTIONS: { value: ProjectStatus; label: string }[] = [ { value: "active", label: "Active" }, @@ -23,7 +23,7 @@ type ProjectHeaderProps = { }; export function ProjectHeader({ project }: ProjectHeaderProps) { - const updateProjectStore = useAppStore((s) => s.updateProject); + const syncProjectCache = useSyncOfficeProjectCache(); const [name, setName] = useState(project.name); const [description, setDescription] = useState(project.description ?? ""); @@ -40,8 +40,8 @@ export function ProjectHeader({ project }: ProjectHeaderProps) { if (status !== project.status) patch.status = status; if (Object.keys(patch).length > 0) { - await updateProject(project.id, patch); - updateProjectStore(project.id, patch); + const updatedProject = await updateProject(project.id, patch); + syncProjectCache(updatedProject); } setDirty(false); toast.success("Project saved"); @@ -50,7 +50,7 @@ export function ProjectHeader({ project }: ProjectHeaderProps) { } finally { setSaving(false); } - }, [name, description, status, project, updateProjectStore]); + }, [name, description, status, project, syncProjectCache]); return (
diff --git a/apps/web/app/office/projects/[id]/project-query-cache.test.ts b/apps/web/app/office/projects/[id]/project-query-cache.test.ts new file mode 100644 index 0000000000..6157854fbe --- /dev/null +++ b/apps/web/app/office/projects/[id]/project-query-cache.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import type { Project } from "@/lib/state/slices/office/types"; +import { + readProjectFromListCache, + removeProjectFromList, + upsertProjectInList, +} from "./project-query-cache"; + +const TIMESTAMP = "2026-06-30T00:00:00Z"; + +function project(overrides: Partial = {}): Project { + return { + id: "project-1", + workspaceId: "workspace-1", + name: "Project", + status: "active", + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + ...overrides, + }; +} + +describe("project query cache helpers", () => { + it("does not create a partial project list when upserting into a missing list", () => { + expect(upsertProjectInList(undefined, project())).toBeUndefined(); + }); + + it("patches an existing project list", () => { + const updated = project({ name: "Updated" }); + + expect( + upsertProjectInList({ projects: [project(), project({ id: "project-2" })] }, updated), + ).toEqual({ + projects: [updated, expect.objectContaining({ id: "project-2" })], + }); + }); + + it("does not create an empty project list when deleting from a missing list", () => { + expect(removeProjectFromList(undefined, "project-1")).toBeUndefined(); + }); + + it("removes a project from an existing project list", () => { + expect( + removeProjectFromList({ projects: [project(), project({ id: "project-2" })] }, "project-1"), + ).toEqual({ + projects: [expect.objectContaining({ id: "project-2" })], + }); + }); + + it("reads a project from the cached workspace project list", () => { + const queryClient = new QueryClient(); + const cachedProject = project({ id: "project-2", name: "Cached Project" }); + queryClient.setQueryData(qk.office.projects("workspace-1"), { + projects: [project(), cachedProject], + }); + + expect(readProjectFromListCache(queryClient, "workspace-1", "project-2")).toBe(cachedProject); + expect(readProjectFromListCache(queryClient, "workspace-2", "project-2")).toBeNull(); + }); +}); diff --git a/apps/web/app/office/projects/[id]/project-query-cache.ts b/apps/web/app/office/projects/[id]/project-query-cache.ts new file mode 100644 index 0000000000..a9953fd4ed --- /dev/null +++ b/apps/web/app/office/projects/[id]/project-query-cache.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useCallback } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import type { Project } from "@/lib/state/slices/office/types"; + +export type OfficeProjectsCache = { projects: Project[] }; + +export function upsertProjectInList( + current: OfficeProjectsCache | undefined, + project: Project, +): OfficeProjectsCache | undefined { + if (!current) return current; + if (current.projects.some((item) => item.id === project.id)) { + return { + ...current, + projects: current.projects.map((item) => (item.id === project.id ? project : item)), + }; + } + return { ...current, projects: [...current.projects, project] }; +} + +export function removeProjectFromList( + current: OfficeProjectsCache | undefined, + projectId: string, +): OfficeProjectsCache | undefined { + if (!current) return current; + return { + ...current, + projects: current.projects.filter((item) => item.id !== projectId), + }; +} + +export function readProjectFromListCache( + queryClient: QueryClient, + workspaceId: string | null, + projectId: string, +): Project | null { + if (!workspaceId) return null; + const current = queryClient.getQueryData(qk.office.projects(workspaceId)); + return current?.projects.find((project) => project.id === projectId) ?? null; +} + +export function useSyncOfficeProjectCache() { + const queryClient = useQueryClient(); + + return useCallback( + (project: Project) => { + const projectsKey = qk.office.projects(project.workspaceId); + queryClient.setQueryData(qk.office.project(project.id), project); + queryClient.setQueryData(projectsKey, (current) => + upsertProjectInList(current, project), + ); + queryClient.invalidateQueries({ exact: true, queryKey: projectsKey }); + }, + [queryClient], + ); +} 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 314beed358..87f09f559a 100644 --- a/apps/web/app/office/projects/[id]/project-repos-section.tsx +++ b/apps/web/app/office/projects/[id]/project-repos-section.tsx @@ -11,6 +11,7 @@ import { formatUserHomePath } from "@/lib/utils"; import type { Project } from "@/lib/state/slices/office/types"; import type { Repository } from "@/lib/types/http"; import { ProjectRepositoryPicker } from "./project-repository-picker"; +import { useSyncOfficeProjectCache } from "./project-query-cache"; import { normalizeRepos } from "../normalize-repos"; type ProjectReposSectionProps = { @@ -19,21 +20,21 @@ type ProjectReposSectionProps = { export function ProjectReposSection({ project }: ProjectReposSectionProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const updateProjectStore = useAppStore((s) => s.updateProject); + const syncProjectCache = useSyncOfficeProjectCache(); const { repositories } = useRepositories(workspaceId); const repos = useMemo(() => normalizeRepos(project.repositories), [project.repositories]); const persist = useCallback( async (next: string[], successMessage: string, failureMessage: string) => { try { - await updateProject(project.id, { repositories: next }); - updateProjectStore(project.id, { repositories: next }); + const updatedProject = await updateProject(project.id, { repositories: next }); + syncProjectCache(updatedProject); toast.success(successMessage); } catch (err) { toast.error(err instanceof Error ? err.message : failureMessage); } }, - [project.id, updateProjectStore], + [project.id, syncProjectCache], ); 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 8851609e2f..db0af0500d 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,10 @@ "use client"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { listTasks } from "@/lib/api/domains/office-tasks-api"; +import { useOfficeAgentsData } from "@/hooks/domains/office/use-office-data"; +import { officeTasksInfiniteQueryOptions } from "@/lib/query/query-options"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; import { TaskRow } from "../../tasks/task-row"; @@ -12,40 +14,27 @@ 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); + const agentProfiles = useOfficeAgentsData(workspaceId).data?.agents ?? []; + const projectTasksQuery = useInfiniteQuery( + officeTasksInfiniteQueryOptions(workspaceId ?? "", { + project: projectId, + limit: 100, + sort: "updated_at", + order: "desc", + }), + ); - // 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]); + const queriedTasks = useMemo( + () => projectTasksQuery.data?.pages.flatMap((page) => page.tasks ?? []) ?? [], + [projectTasksQuery.data], + ); const sorted = useMemo( () => - allTasks - .filter((t) => t.projectId === projectId) - .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()), - [allTasks, projectId], + [...queriedTasks].sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ), + [queriedTasks], ); 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 62b3def126..7315c62905 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 { 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,9 +11,10 @@ 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 { useOfficeAgentsData, useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import { createProject } from "@/lib/api/domains/office-api"; -import type { AgentProfile } from "@/lib/state/slices/office/types"; +import { qk } from "@/lib/query/keys"; +import type { AgentProfile, Project } from "@/lib/state/slices/office/types"; const COLOR_OPTIONS = [ "#ef4444", @@ -193,7 +195,7 @@ const INITIAL_PROJECT_STATE: ProjectFormState = { }; function useProjectForm(workspaceId: string, onClose: () => void) { - const addProject = useAppStore((s) => s.addProject); + const queryClient = useQueryClient(); const [form, setForm] = useState(INITIAL_PROJECT_STATE); const [submitting, setSubmitting] = useState(false); @@ -228,7 +230,7 @@ function useProjectForm(workspaceId: string, onClose: () => void) { ? { type: form.executorType, image: form.dockerImage || undefined } : undefined, }); - if (result) addProject(result); + if (result) appendProject(queryClient, workspaceId, result); onClose(); setForm(INITIAL_PROJECT_STATE); toast.success("Project created"); @@ -237,11 +239,24 @@ function useProjectForm(workspaceId: string, onClose: () => void) { } finally { setSubmitting(false); } - }, [form, workspaceId, addProject, onClose]); + }, [form, workspaceId, queryClient, onClose]); return { form, update, submitting, handleAddRepo, handleRemoveRepo, handleCreate }; } +function appendProject( + queryClient: ReturnType, + workspaceId: string, + project: Project, +) { + queryClient.setQueryData<{ projects: Project[] }>(qk.office.projects(workspaceId), (current) => { + const projects = current?.projects ?? []; + if (projects.some((item) => item.id === project.id)) return { projects }; + return { projects: [...projects, project] }; + }); + queryClient.setQueryData(qk.office.project(project.id), project); +} + function ProjectFormBody({ form, agents, @@ -320,8 +335,8 @@ function ProjectFormBody({ } export function CreateProjectDialog({ open, onOpenChange, workspaceId }: CreateProjectDialogProps) { - const agents = useAppStore((s) => s.office.agentProfiles); - const meta = useAppStore((s) => s.office.meta); + const agents = useOfficeAgentsData(workspaceId).data?.agents ?? []; + const meta = useOfficeMetaData().data; 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/project-card.tsx b/apps/web/app/office/projects/project-card.tsx index 5c1e8f7389..61ad8c12cd 100644 --- a/apps/web/app/office/projects/project-card.tsx +++ b/apps/web/app/office/projects/project-card.tsx @@ -5,7 +5,7 @@ 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 { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import type { Project } from "@/lib/state/slices/office/types"; import { normalizeRepos } from "./normalize-repos"; @@ -29,7 +29,7 @@ type ProjectCardProps = { }; function useProjectStatusDisplay(status: string) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; 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 11c8c71f61..8ed45e7570 100644 --- a/apps/web/app/office/projects/projects-page-client.tsx +++ b/apps/web/app/office/projects/projects-page-client.tsx @@ -1,11 +1,10 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; 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 { useOfficeAgentsData, useOfficeProjectsData } from "@/hooks/domains/office/use-office-data"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; import type { Project } from "@/lib/state/slices/office/types"; import { ProjectCard } from "./project-card"; @@ -13,42 +12,17 @@ import { CreateProjectDialog } from "./create-project-dialog"; import { EmptyState } from "../components/shared/empty-state"; type ProjectsPageClientProps = { - initialProjects: Project[]; + 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); const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const projectsQuery = useOfficeProjectsData(activeWorkspaceId, initialProjects); + const agentsQuery = useOfficeAgentsData(activeWorkspaceId); 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); - setProjects(res?.projects ?? []); - } catch { - // Silently handle fetch errors - } - }, [activeWorkspaceId, setProjects]); - - useEffect(() => { - void loadProjects(); - }, [loadProjects]); - - useOfficeRefetch("projects", loadProjects); - + const projects = projectsQuery.data?.projects ?? initialProjects ?? []; + const agents = agentsQuery.data?.agents ?? []; const agentNameMap = new Map(agents.map((a) => [a.id, a.name])); return ( 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 a0ee4ebcef..27b3c1ff9d 100644 --- a/apps/web/app/office/routines/[id]/routine-detail-view.tsx +++ b/apps/web/app/office/routines/[id]/routine-detail-view.tsx @@ -12,6 +12,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 { useOfficeAgentsData } from "@/hooks/domains/office/use-office-data"; import { updateRoutine, runRoutine, @@ -70,7 +71,8 @@ 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 agents = useOfficeAgentsData(workspaceId).data?.agents ?? []; 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 b7c44479ab..17b9c2ed97 100644 --- a/apps/web/app/office/routines/routines-content.tsx +++ b/apps/web/app/office/routines/routines-content.tsx @@ -1,21 +1,24 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useState } from "react"; +import { useQueries, useQuery } 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 { useOfficeAgentsData, useOfficeRoutinesData } from "@/hooks/domains/office/use-office-data"; import { - listRoutines, createRoutine, updateRoutine, deleteRoutine, runRoutine, - listAllRoutineRuns, createRoutineTrigger, - listRoutineTriggers, } from "@/lib/api/domains/office-api"; +import { + officeRoutineRunsQueryOptions, + officeRoutineTriggersQueryOptions, +} from "@/lib/query/query-options/office"; import type { Routine, AgentProfile, @@ -116,77 +119,47 @@ function useRoutineActions(workspaceId: string | null, fetchRoutines: () => Prom // RoutinesContent component stays under the per-function ceiling. Each // 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 [runs, setRuns] = useState([]); - const [triggersByRoutine, setTriggersByRoutine] = useState>({}); +function useRoutinesData(workspaceId: string | null, initialRoutines?: Routine[]) { + const routinesQuery = useOfficeRoutinesData(workspaceId, initialRoutines); + const runsQuery = useQuery(officeRoutineRunsQueryOptions(workspaceId ?? "")); + const routines = routinesQuery.data?.routines ?? initialRoutines ?? []; + const triggerQueries = useQueries({ + queries: routines.map((routine) => officeRoutineTriggersQueryOptions(routine.id)), + }); const fetchRoutines = useCallback(async () => { if (!workspaceId) return; - const res = await listRoutines(workspaceId); - setRoutines(res.routines ?? []); - }, [workspaceId, setRoutines]); + await routinesQuery.refetch(); + }, [routinesQuery, workspaceId]); const fetchRuns = useCallback(async () => { if (!workspaceId) return [] as RoutineRun[]; - const res = await listAllRoutineRuns(workspaceId); - return res.runs ?? []; - }, [workspaceId]); - - const fetchTriggers = useCallback(async (rs: Routine[]) => { - const entries = await Promise.all( - rs.map(async (r) => { - const res = await listRoutineTriggers(r.id).catch(() => ({ - triggers: [] as RoutineTrigger[], - })); - return [r.id, res.triggers ?? []] as const; - }), - ); - const out: Record = {}; - for (const [id, triggers] of entries) out[id] = triggers; - return out; - }, []); + const res = await runsQuery.refetch(); + return res.data?.runs ?? []; + }, [runsQuery, workspaceId]); - useEffect(() => { - let cancelled = false; - void fetchRoutines(); - fetchRuns().then((next) => { - if (!cancelled) setRuns(next); - }); - return () => { - cancelled = true; - }; - }, [fetchRoutines, fetchRuns]); + const triggersByRoutine: Record = {}; + routines.forEach((routine, index) => { + triggersByRoutine[routine.id] = triggerQueries[index]?.data?.triggers ?? []; + }); - useEffect(() => { - let cancelled = false; - if (routines.length === 0) { - Promise.resolve().then(() => { - if (!cancelled) setTriggersByRoutine({}); - }); - return () => { - cancelled = true; - }; - } - fetchTriggers(routines).then((map) => { - if (!cancelled) setTriggersByRoutine(map); - }); - return () => { - cancelled = true; - }; - }, [routines, fetchTriggers]); - - return { routines, runs, setRuns, triggersByRoutine, fetchRoutines, fetchRuns }; + return { + routines, + runs: runsQuery.data?.runs ?? [], + triggersByRoutine, + fetchRoutines, + fetchRuns, + }; } -export function RoutinesContent() { +export function RoutinesContent({ initialRoutines }: { initialRoutines?: Routine[] }) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const agents = useAppStore((s) => s.office.agentProfiles); + const agents = useOfficeAgentsData(workspaceId).data?.agents ?? []; const [showCreate, setShowCreate] = useState(false); - const { routines, runs, setRuns, triggersByRoutine, fetchRoutines, fetchRuns } = - useRoutinesData(workspaceId); + const { routines, runs, triggersByRoutine, fetchRoutines, fetchRuns } = useRoutinesData( + workspaceId, + initialRoutines, + ); const { handleToggle, handleDelete, handleCreate } = useRoutineActions( workspaceId, @@ -197,13 +170,13 @@ export function RoutinesContent() { async (id: string) => { try { await runRoutine(id); - setRuns(await fetchRuns()); + await fetchRuns(); toast.success("Routine started"); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to run routine"); } }, - [fetchRuns, setRuns], + [fetchRuns], ); return ( diff --git a/apps/web/app/office/routines/routines-page-client.tsx b/apps/web/app/office/routines/routines-page-client.tsx index 22943a42d2..965839a62f 100644 --- a/apps/web/app/office/routines/routines-page-client.tsx +++ b/apps/web/app/office/routines/routines-page-client.tsx @@ -1,37 +1,12 @@ "use client"; -import { useCallback, useEffect } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import { listRoutines } from "@/lib/api/domains/office-api"; import type { Routine } from "@/lib/state/slices/office/types"; import { RoutinesContent } from "./routines-content"; type RoutinesPageClientProps = { - initialRoutines: Routine[]; + initialRoutines?: Routine[]; }; export function RoutinesPageClient({ initialRoutines }: RoutinesPageClientProps) { - const workspaceId = useAppStore((s) => s.workspaces.activeId); - const setRoutines = useAppStore((s) => s.setRoutines); - - useEffect(() => { - if (initialRoutines.length > 0) { - setRoutines(initialRoutines); - } - }, [initialRoutines, setRoutines]); - - const refetchRoutines = useCallback(async () => { - if (!workspaceId) return; - const res = await listRoutines(workspaceId).catch(() => ({ routines: [] as Routine[] })); - setRoutines(res.routines ?? []); - }, [workspaceId, setRoutines]); - - useEffect(() => { - void refetchRoutines(); - }, [refetchRoutines]); - - useOfficeRefetch("routines", refetchRoutines); - - return ; + return ; } diff --git a/apps/web/app/office/routines/run-row.tsx b/apps/web/app/office/routines/run-row.tsx index b6451e234a..ace2000a6f 100644 --- a/apps/web/app/office/routines/run-row.tsx +++ b/apps/web/app/office/routines/run-row.tsx @@ -1,7 +1,7 @@ "use client"; import { Badge } from "@kandev/ui/badge"; -import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import type { RoutineRun } from "@/lib/state/slices/office/types"; const FALLBACK_COLORS: Record = { @@ -28,7 +28,7 @@ type RunRowProps = { }; export function RunRow({ run }: RunRowProps) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; 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/agent-profile-setup-controls.tsx b/apps/web/app/office/setup/agent-profile-setup-controls.tsx index eaa17b3f7c..6174ddb4a8 100644 --- a/apps/web/app/office/setup/agent-profile-setup-controls.tsx +++ b/apps/web/app/office/setup/agent-profile-setup-controls.tsx @@ -3,11 +3,11 @@ import { useMemo } from "react"; import { Button } from "@kandev/ui/button"; import { CliProfileEditor } from "@/components/agent/cli-profile-editor"; -import { useAppStoreApi } from "@/components/state-provider"; import { getCapabilityWarning } from "@/lib/capability-warning"; import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; import type { Tier } from "@/lib/state/slices/office/types"; +import type { AgentProfile } from "@/lib/types/http"; import { useAgentProfileOptions } from "@/components/task-create-dialog-options"; export type ProfileSetupChange = { @@ -67,7 +67,7 @@ export function CreateProfilePanel({ settingsAgents, wizardProfiles, canCancel, - setAgentProfiles, + upsertProfile, onAgentProfilesChange, onProfileSaved, onClose, @@ -75,12 +75,11 @@ export function CreateProfilePanel({ settingsAgents: { id: string; name: string }[]; wizardProfiles: AgentProfileOption[]; canCancel: boolean; - setAgentProfiles: (profiles: AgentProfileOption[]) => void; + upsertProfile: (profile: AgentProfile) => void; onAgentProfilesChange?: (profiles: AgentProfileOption[]) => void; onProfileSaved: (profileId: string) => void; onClose: () => void; }) { - const store = useAppStoreApi(); return (
s.office.meta); + const meta = useOfficeMetaData().data; const executorOptions = meta?.executorTypes ?? FALLBACK_EXECUTOR_OPTIONS; - const settingsAgents = useAppStore((s) => s.settingsAgents.items); - const setAgentProfiles = useAppStore((s) => s.setAgentProfiles); + const { settingsAgents, upsertProfile } = useAgentsQuerySync(); const { sortedProfiles, profileOptions } = useSelectableProfileOptions(agentProfiles); @@ -99,7 +99,7 @@ export function StepAgent({ settingsAgents={settingsAgents} wizardProfiles={agentProfiles} canCancel={profileOptions.length > 0} - setAgentProfiles={setAgentProfiles} + upsertProfile={upsertProfile} onAgentProfilesChange={onAgentProfilesChange} onProfileSaved={(profileId) => onChange({ agentProfileId: profileId })} onClose={() => setShowCreate(false)} diff --git a/apps/web/app/office/setup/step-review.tsx b/apps/web/app/office/setup/step-review.tsx index 8db4b82307..34ef3f7d6d 100644 --- a/apps/web/app/office/setup/step-review.tsx +++ b/apps/web/app/office/setup/step-review.tsx @@ -2,7 +2,7 @@ import { Badge } from "@kandev/ui/badge"; import { Card } from "@kandev/ui/card"; -import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; type StepReviewProps = { workspaceName: string; @@ -28,7 +28,7 @@ export function StepReview({ executorPreference, taskTitle, }: StepReviewProps) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; const executorLabel = meta?.executorTypes.find((e) => e.id === executorPreference)?.label ?? diff --git a/apps/web/app/office/setup/step-tier-profiles.tsx b/apps/web/app/office/setup/step-tier-profiles.tsx index 4663a5c818..d2c5ef72f8 100644 --- a/apps/web/app/office/setup/step-tier-profiles.tsx +++ b/apps/web/app/office/setup/step-tier-profiles.tsx @@ -4,7 +4,7 @@ import { useState } from "react"; import { Label } from "@kandev/ui/label"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { IconInfoCircle } from "@tabler/icons-react"; -import { useAppStore } from "@/components/state-provider"; +import { useAgentsQuerySync } from "@/hooks/domains/settings/use-agents-query-sync"; import { AgentSelector } from "@/components/task-create-dialog-selectors"; import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; import type { Tier } from "@/lib/state/slices/office/types"; @@ -50,8 +50,7 @@ export function StepTierProfiles({ onChange, onAgentProfilesChange, }: StepTierProfilesProps) { - const settingsAgents = useAppStore((s) => s.settingsAgents.items); - const setAgentProfiles = useAppStore((s) => s.setAgentProfiles); + const { settingsAgents, upsertProfile } = useAgentsQuerySync(); const { profileOptions } = useSelectableProfileOptions(agentProfiles); const [showCreate, setShowCreate] = useState(profileOptions.length === 0); @@ -98,7 +97,7 @@ export function StepTierProfiles({ settingsAgents={settingsAgents} wizardProfiles={agentProfiles} canCancel={profileOptions.length > 0} - setAgentProfiles={setAgentProfiles} + upsertProfile={upsertProfile} onAgentProfilesChange={onAgentProfilesChange} onProfileSaved={(profileId) => onChange({ 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 4949426fb1..b4929b0d69 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 { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useFileEditors } from "@/hooks/use-file-editors"; import { getWebSocketClient } from "@/lib/ws/connection"; import { buildStartRequest } from "@/lib/services/session-launch-helpers"; @@ -142,7 +142,7 @@ export function AdvancedChatPanel({ taskId, sessionId, hideInput }: AdvancedChat const { session } = useSession(sessionId); const { messages, isLoading } = useSessionMessages(sessionId); const { openFile } = useFileEditors(); - const agentProfiles = useAppStore((s) => s.agentProfiles.items ?? []); + const { agentProfiles } = useSettingsData(true); 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 8d986177f8..2b2c2fb1df 100644 --- a/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx +++ b/apps/web/app/office/tasks/[id]/office-dockview-layout.tsx @@ -83,9 +83,10 @@ export function OfficeDockviewLayout({ taskId, sessionId }: OfficeDockviewLayout // before this page subscribed to the session. useEffect(() => { if (!taskId) return; - ensureTaskSession(taskId, { ensureExecution: true }) + ensureTaskSession(taskId, { ensureExecution: true, timeout: 45_000 }) .then((resp) => { if (resp.session_id) { + setActiveSession(taskId, resp.session_id); setAgentctlStatus(resp.session_id, { status: "ready", updatedAt: new Date().toISOString(), @@ -103,7 +104,7 @@ export function OfficeDockviewLayout({ taskId, sessionId }: OfficeDockviewLayout .catch(() => { // Non-fatal: panels will show appropriate empty/retry states. }); - }, [taskId, setAgentctlStatus, setTaskSession, appStore]); + }, [taskId, setActiveSession, setAgentctlStatus, setTaskSession, appStore]); // 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 d97cab7a83..23ccee69ff 100644 --- a/apps/web/app/office/tasks/[id]/page.tsx +++ b/apps/web/app/office/tasks/[id]/page.tsx @@ -1,18 +1,25 @@ "use client"; -import { use, useState, useEffect, useRef, useCallback, useMemo, Suspense } from "react"; +import { + use, + useState, + useEffect, + useCallback, + useMemo, + useSyncExternalStore, + Suspense, +} from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter, useSearchParams } from "@/lib/routing/client-router"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; import { TaskOptimisticContextProvider } from "@/hooks/use-optimistic-task-mutation"; +import { type TaskCommentResponse, type TaskDecisionDTO } from "@/lib/api/domains/office-api"; import { - getTask, - listActivityForTarget, - listComments, - type TaskCommentResponse, - type TaskDecisionDTO, -} from "@/lib/api/domains/office-api"; -import { listTaskSessions } from "@/lib/api/domains/session-api"; + officeTaskActivityQueryOptions, + officeTaskCommentsQueryOptions, + officeTaskQueryOptions, + taskSessionsQueryOptions, +} from "@/lib/query/query-options"; import { getWebSocketClient } from "@/lib/ws/connection"; import { OfficeSimplePane } from "@/components/task/simple/OfficeSimplePane"; import { TaskAdvancedMode } from "./task-advanced-mode"; @@ -28,6 +35,7 @@ import type { } from "./types"; import type { ActivityEntry, OfficeTask } from "@/lib/state/slices/office/types"; import type { TaskSession as ApiTaskSession } from "@/lib/types/http"; +import { readOfficeTaskFromCachedPages } from "./task-detail-query-cache"; type IssueDetailPageProps = { params: Promise<{ id: string }>; @@ -158,45 +166,6 @@ function mapTaskSession(session: ApiTaskSession): TaskSession { }; } -type IssueDetailData = { - activity: TaskActivityEntry[] | null; - sessions: TaskSession[] | null; - rawSessions: ApiTaskSession[] | null; - comments: TaskComment[] | null; -}; - -// --------------------------------------------------------------------------- -// Task fetchers — pure async functions, no React state -// --------------------------------------------------------------------------- - -async function fetchIssueComments(id: string): Promise { - try { - const res = await listComments(id); - return (res.comments ?? []).map(mapCommentResponse); - } catch { - return []; - } -} - -async function fetchIssueDetailData(workspaceId: string, id: string): Promise { - const [activityResult, sessionsResult, commentsResult] = await Promise.allSettled([ - listActivityForTarget(workspaceId, id), - listTaskSessions(id), - fetchIssueComments(id), - ]); - const rawSessions = - sessionsResult.status === "fulfilled" ? (sessionsResult.value.sessions ?? []) : null; - return { - activity: - activityResult.status === "fulfilled" - ? (activityResult.value.activity ?? []).map(mapActivityEntry) - : null, - sessions: rawSessions ? rawSessions.map(mapTaskSession) : null, - rawSessions, - comments: commentsResult.status === "fulfilled" ? commentsResult.value : null, - }; -} - // --------------------------------------------------------------------------- // Live sync — WS subscriptions + re-fetch on session state changes // --------------------------------------------------------------------------- @@ -204,7 +173,7 @@ async function fetchIssueDetailData(workspaceId: string, id: string): Promise void; + onTaskRefetch: () => Promise; onCommentsRefetch: () => Promise; }; @@ -243,11 +212,7 @@ function useSessionLiveSync({ const taskId = task?.id; useEffect(() => { if (!taskId || !sessionStatesKey) return; - getTask(taskId) - .then((res) => { - if (res.task) onTaskRefetch(mapOfficeTaskToTask(res.task), res.timeline ?? []); - }) - .catch(() => {}); + void onTaskRefetch(); void onCommentsRefetch(); }, [sessionStatesKey, taskId, onTaskRefetch, onCommentsRefetch]); @@ -258,30 +223,7 @@ function useSessionLiveSync({ // Optimistic update helpers // --------------------------------------------------------------------------- -function useTaskOptimisticHelpers( - id: string, - setTask: React.Dispatch>, - setTimeline: React.Dispatch>, -) { - // Refetch the canonical task DTO when the backend broadcasts an update - // (priority / project / parent / blockers / participants / assignee). - // The optimistic patch we applied locally gets reconciled with server - // state. - const refetchTask = useCallback(async () => { - try { - const res = await getTask(id); - if (res.task) { - setTask(mapOfficeTaskToTask(res.task)); - if (res.timeline) setTimeline(res.timeline); - } - } catch { - /* swallow — next user action will retry */ - } - }, [id, setTask, setTimeline]); - useOfficeRefetch(`task:${id}`, () => { - void refetchTask(); - }); - +function useTaskOptimisticHelpers(setTask: React.Dispatch>) { const applyTaskPatch = useCallback( (patch: Partial) => { setTask((prev) => (prev ? { ...prev, ...patch } : prev)); @@ -299,104 +241,128 @@ function useTaskOptimisticHelpers( return { applyTaskPatch, restoreTask }; } +function resolveIssueError( + task: Task | null, + isSuccess: boolean, + hasQueryTask: boolean, + isError: boolean, +): string | null { + if (task) return null; + if (isSuccess && !hasQueryTask) return "Task not found"; + if (isError) return "Failed to load task"; + return null; +} + +function resolveTaskWorkspaceId( + task: Task | null, + queryTask: OfficeTask | undefined, + fallbackWorkspaceId: string, +): string { + return task?.workspaceId ?? queryTask?.workspaceId ?? fallbackWorkspaceId; +} + +function mergeSessionStates( + baseSessions: TaskSession[], + sessionStoreStates: Array, +): TaskSession[] { + return baseSessions.map((s, i) => ({ + ...s, + state: (sessionStoreStates[i] ?? s.state) as TaskSession["state"], + })); +} + // --------------------------------------------------------------------------- // Primary data hook // --------------------------------------------------------------------------- function useIssueData(id: string) { - const storeIssues = useAppStore((s) => s.office.tasks.items); + const queryClient = useQueryClient(); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); 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); - useEffect(() => { - storeIssuesRef.current = storeIssues; - }, [storeIssues]); + const queryWorkspaceId = activeWorkspaceId ?? ""; + const cachedOfficeTask = useCachedOfficeTask(queryWorkspaceId, id); + const taskQuery = useQuery(officeTaskQueryOptions(queryWorkspaceId, id)); const [task, setTask] = useState(null); - const [comments, setComments] = useState([]); const [timeline, setTimeline] = useState([]); - const [activity, setActivity] = useState([]); - const [baseSessions, setBaseSessions] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const applyDetail = useCallback( - (detail: IssueDetailData) => { - if (detail.activity) setActivity(detail.activity); - if (detail.sessions) setBaseSessions(detail.sessions); - if (detail.rawSessions) setTaskSessionsForTask(id, detail.rawSessions); - if (detail.comments) setComments(detail.comments); - }, - [id, setTaskSessionsForTask], + + useEffect(() => { + if (taskQuery.data?.task) { + setTask(mapOfficeTaskToTask(taskQuery.data.task)); + setTimeline(taskQuery.data.timeline ?? []); + return; + } + if (cachedOfficeTask) { + setTask(mapOfficeTaskToTask(cachedOfficeTask)); + setTimeline([]); + return; + } + setTask((current) => (current?.id === id ? current : null)); + setTimeline([]); + }, [cachedOfficeTask, id, taskQuery.data]); + + const taskWorkspaceId = resolveTaskWorkspaceId(task, taskQuery.data?.task, queryWorkspaceId); + const commentsQuery = useQuery(officeTaskCommentsQueryOptions(id)); + const activityQuery = useQuery(officeTaskActivityQueryOptions(taskWorkspaceId, id)); + const sessionsQuery = useQuery(taskSessionsQueryOptions(id)); + + const comments = useMemo( + () => (commentsQuery.data?.comments ?? []).map(mapCommentResponse), + [commentsQuery.data], ); + const activity = useMemo( + () => (activityQuery.data?.activity ?? []).map(mapActivityEntry), + [activityQuery.data], + ); + + const rawSessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data]); + const baseSessions = useMemo(() => rawSessions.map(mapTaskSession), [rawSessions]); + useEffect(() => { - let cancelled = false; - - async function load() { - setLoading(true); - setError(null); - const fromStore = storeIssuesRef.current.find((i) => i.id === id); - if (fromStore && !cancelled) setTask(mapOfficeTaskToTask(fromStore)); - - try { - const res = await getTask(id); - if (cancelled) return; - if (!res.task) { - if (!fromStore) setError("Task not found"); - } else { - const freshTask = mapOfficeTaskToTask(res.task); - setTask(freshTask); - if (res.timeline) setTimeline(res.timeline); - const detail = await fetchIssueDetailData(freshTask.workspaceId, id); - if (!cancelled) applyDetail(detail); - } - } catch { - if (!cancelled && !fromStore) setError("Failed to load task"); - } + if (sessionsQuery.data) setTaskSessionsForTask(id, rawSessions); + }, [id, rawSessions, sessionsQuery.data, setTaskSessionsForTask]); - if (!cancelled) setLoading(false); + const refetchTask = useCallback(async () => { + if (!taskWorkspaceId) return; + try { + const res = await queryClient.fetchQuery({ + ...officeTaskQueryOptions(taskWorkspaceId, id), + staleTime: 0, + }); + if (res.task) { + setTask(mapOfficeTaskToTask(res.task)); + setTimeline(res.timeline ?? []); + } + } catch { + /* query state carries the error */ } + }, [id, queryClient, taskWorkspaceId]); - void load(); - return () => { - cancelled = true; - }; - }, [id, applyDetail]); - + const { refetch: refetchComments } = commentsQuery; const fetchComments = useCallback(async () => { - const result = await fetchIssueComments(id); - setComments(result); - }, [id]); - - const onTaskRefetch = useCallback((updated: Task, updatedTimeline: TimelineEvent[]) => { - setTask(updated); - setTimeline(updatedTimeline); - }, []); + await refetchComments(); + }, [refetchComments]); const sessionStoreStates = useSessionLiveSync({ task, baseSessions, - onTaskRefetch, + onTaskRefetch: refetchTask, onCommentsRefetch: fetchComments, }); const sessions = useMemo( - () => - baseSessions.map((s, i) => ({ - ...s, - state: (sessionStoreStates[i] ?? s.state) as TaskSession["state"], - })), + () => mergeSessionStates(baseSessions, sessionStoreStates), [baseSessions, sessionStoreStates], ); - // Refetch comments when a new comment is created via office WS event - useOfficeRefetch("comments", fetchComments); - - const { applyTaskPatch, restoreTask } = useTaskOptimisticHelpers(id, setTask, setTimeline); + const { applyTaskPatch, restoreTask } = useTaskOptimisticHelpers(setTask); + const loading = taskQuery.isPending && !task; + const error = resolveIssueError( + task, + taskQuery.isSuccess, + Boolean(taskQuery.data?.task), + taskQuery.isError, + ); return { task, @@ -412,6 +378,19 @@ function useIssueData(id: string) { }; } +function useCachedOfficeTask(workspaceId: string, taskId: string): OfficeTask | null { + const queryClient = useQueryClient(); + const subscribe = useCallback( + (onStoreChange: () => void) => queryClient.getQueryCache().subscribe(onStoreChange), + [queryClient], + ); + const getSnapshot = useCallback( + () => readOfficeTaskFromCachedPages(queryClient, workspaceId, taskId), + [queryClient, taskId, workspaceId], + ); + return useSyncExternalStore(subscribe, getSnapshot, () => null); +} + export default function IssueDetailPage({ params }: IssueDetailPageProps) { return ( }> diff --git a/apps/web/app/office/tasks/[id]/task-detail-query-cache.test.ts b/apps/web/app/office/tasks/[id]/task-detail-query-cache.test.ts new file mode 100644 index 0000000000..2ff27caaa8 --- /dev/null +++ b/apps/web/app/office/tasks/[id]/task-detail-query-cache.test.ts @@ -0,0 +1,49 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import { qk } from "@/lib/query/keys"; +import type { OfficeTask } from "@/lib/state/slices/office/types"; +import { readOfficeTaskFromCachedPages } from "./task-detail-query-cache"; + +const TIMESTAMP = "2026-07-01T00:00:00Z"; +const WORKSPACE_ID = "workspace-1"; +const CACHED_TASK_ID = "task-2"; + +function task(id: string, overrides: Partial = {}): OfficeTask { + return { + id, + workspaceId: WORKSPACE_ID, + identifier: id.toUpperCase(), + title: id, + status: "todo", + priority: "medium", + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + ...overrides, + }; +} + +describe("task detail query cache helpers", () => { + it("reads an office task from cached infinite task pages", () => { + const queryClient = new QueryClient(); + const cachedTask = task(CACHED_TASK_ID, { title: "Cached task" }); + queryClient.setQueryData(qk.office.tasks(WORKSPACE_ID, { limit: 200 }), { + pages: [{ tasks: [task("task-1"), cachedTask] }], + pageParams: [undefined], + }); + + expect(readOfficeTaskFromCachedPages(queryClient, WORKSPACE_ID, CACHED_TASK_ID)).toBe( + cachedTask, + ); + expect(readOfficeTaskFromCachedPages(queryClient, "workspace-2", CACHED_TASK_ID)).toBeNull(); + }); + + it("ignores malformed cached task pages", () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(qk.office.tasks(WORKSPACE_ID, { limit: 200 }), { + pages: [null, { other: [] }, { tasks: [null, { id: null }, task("task-1")] }], + pageParams: [undefined], + }); + + expect(readOfficeTaskFromCachedPages(queryClient, WORKSPACE_ID, "missing")).toBeNull(); + }); +}); diff --git a/apps/web/app/office/tasks/[id]/task-detail-query-cache.ts b/apps/web/app/office/tasks/[id]/task-detail-query-cache.ts new file mode 100644 index 0000000000..54368129e0 --- /dev/null +++ b/apps/web/app/office/tasks/[id]/task-detail-query-cache.ts @@ -0,0 +1,47 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { OfficeTask } from "@/lib/state/slices/office/types"; + +type OfficeTasksPage = { + tasks?: unknown; +}; + +type OfficeTasksInfiniteCache = { + pages?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isOfficeTasksQueryKey(key: readonly unknown[], workspaceId: string): boolean { + return ( + key[0] === "office" && key[1] === "workspaces" && key[2] === workspaceId && key[3] === "tasks" + ); +} + +function readTaskFromPage(page: OfficeTasksPage, taskId: string): OfficeTask | null { + if (!Array.isArray(page.tasks)) return null; + for (const task of page.tasks) { + if (isRecord(task) && task.id === taskId) return task as OfficeTask; + } + return null; +} + +export function readOfficeTaskFromCachedPages( + queryClient: QueryClient, + workspaceId: string | null, + taskId: string, +): OfficeTask | null { + if (!workspaceId) return null; + for (const query of queryClient.getQueryCache().findAll()) { + if (!isOfficeTasksQueryKey(query.queryKey, workspaceId)) continue; + const current = queryClient.getQueryData(query.queryKey); + if (!Array.isArray(current?.pages)) continue; + for (const page of current.pages) { + if (!isRecord(page)) continue; + const task = readTaskFromPage(page, taskId); + if (task) return task; + } + } + return null; +} diff --git a/apps/web/app/office/tasks/page.tsx b/apps/web/app/office/tasks/page.tsx index fe0ddcb493..e90c95d0f0 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 028542f7ff..e8964ed674 100644 --- a/apps/web/app/office/tasks/task-board.tsx +++ b/apps/web/app/office/tasks/task-board.tsx @@ -2,7 +2,7 @@ import { useRouter } from "@/lib/routing/client-router"; import { ScrollArea } from "@kandev/ui/scroll-area"; -import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import type { OfficeTask, OfficeTaskStatus } from "@/lib/state/slices/office/types"; import { StatusIcon } from "./status-icon"; @@ -68,7 +68,7 @@ function BoardColumn({ } export function TaskBoard({ tasks }: TaskBoardProps) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; 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 441cf99d7a..a3b639b16f 100644 --- a/apps/web/app/office/tasks/task-filters.tsx +++ b/apps/web/app/office/tasks/task-filters.tsx @@ -6,7 +6,7 @@ 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 { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import type { TaskFilterState, OfficeTaskStatus, @@ -42,7 +42,7 @@ function toggleInArray(arr: T[], value: T): T[] { } export function TaskFilters({ filters, onFilterChange }: IssueFiltersProps) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; 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/tasks-list.tsx b/apps/web/app/office/tasks/tasks-list.tsx index a7b1885614..da2fc62f4f 100644 --- a/apps/web/app/office/tasks/tasks-list.tsx +++ b/apps/web/app/office/tasks/tasks-list.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; +import { useOfficeAgentsData } from "@/hooks/domains/office/use-office-data"; import type { OfficeTask } from "@/lib/state/slices/office/types"; import { NewTaskDialog } from "../components/new-task-dialog"; import { TasksToolbar } from "./tasks-toolbar"; @@ -45,7 +45,7 @@ function useShowSystemPref(): [boolean, (next: boolean) => void] { const value = useSyncExternalStore( subscribeShowSystem, readShowSystemPref, - () => false, // SSR snapshot — toggle defaults to off pre-hydration. + () => false, // SSR snapshot: toggle defaults to off pre-hydration. ); const set = useCallback((next: boolean) => { if (typeof window === "undefined") return; @@ -103,15 +103,12 @@ function useExpandedTaskIds( 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); @@ -124,15 +121,16 @@ export function TasksList() { const [newTaskOpen, setNewTaskOpen] = useState(false); const [showSystem, setShowSystem] = useShowSystemPref(); const { searchResults, triggerSearch } = useServerSearch(workspaceId); + const agentsQuery = useOfficeAgentsData(workspaceId); + const agents = agentsQuery.data?.agents ?? []; const agentMap = new Map(agents.map((a) => [a.id, a.name])); 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); + const { tasks, isLoading, loadMore, hasMore, isLoadingMore } = usePaginatedTasks( + workspaceId, + showSystem, + ); const handleFilterChange = useCallback( (patch: Record) => { @@ -233,7 +231,7 @@ function LoadMoreButton({ disabled={loading} className="cursor-pointer" > - {loading ? "Loading…" : "Load more"} + {loading ? "Loading..." : "Load more"}
); diff --git a/apps/web/app/office/tasks/tasks-page-client.tsx b/apps/web/app/office/tasks/tasks-page-client.tsx index 48354c5106..7d5d7161de 100644 --- a/apps/web/app/office/tasks/tasks-page-client.tsx +++ b/apps/web/app/office/tasks/tasks-page-client.tsx @@ -1,23 +1,7 @@ "use client"; -import { useEffect } from "react"; -import { useAppStore } from "@/components/state-provider"; -import type { OfficeTask } from "@/lib/state/slices/office/types"; import { TasksList } from "./tasks-list"; -type IssuesPageClientProps = { - initialIssues: OfficeTask[]; -}; - -export function TasksPageClient({ initialIssues }: IssuesPageClientProps) { - const setTasks = useAppStore((s) => 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]); - +export function TasksPageClient() { return ; } diff --git a/apps/web/app/office/tasks/use-paginated-tasks.test.tsx b/apps/web/app/office/tasks/use-paginated-tasks.test.tsx new file mode 100644 index 0000000000..c2e7d85667 --- /dev/null +++ b/apps/web/app/office/tasks/use-paginated-tasks.test.tsx @@ -0,0 +1,105 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import type { OfficeTask } from "@/lib/state/slices/office/types"; +import { listTasks as listOfficeTasks } from "@/lib/api/domains/office-tasks-api"; +import { listTasks as listOfficeTasksLegacy } from "@/lib/api/domains/office-extended-api"; +import { usePaginatedTasks } from "./use-paginated-tasks"; + +vi.mock("sonner", () => ({ + toast: { error: vi.fn() }, +})); + +vi.mock("@/lib/api/domains/office-tasks-api", () => ({ + listTasks: vi.fn(), +})); + +vi.mock("@/lib/api/domains/office-extended-api", () => ({ + listTasks: vi.fn(), +})); + +const listTasksMock = vi.mocked(listOfficeTasks); +const legacyListTasksMock = vi.mocked(listOfficeTasksLegacy); + +function task(id: string, title = id): OfficeTask { + return { + id, + workspaceId: "workspace-1", + identifier: id.toUpperCase(), + title, + status: "todo", + priority: "medium", + createdAt: "2026-06-23T00:00:00Z", + updatedAt: "2026-06-23T00:00:00Z", + }; +} + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: PropsWithChildren) { + return ( + + {children} + + ); + }; +} + +function renderPaginatedTasks(queryClient: QueryClient) { + return renderHook(() => usePaginatedTasks("workspace-1", false), { + wrapper: wrapperFor(queryClient), + }); +} + +describe("usePaginatedTasks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns flattened infinite query pages from the query cache", async () => { + listTasksMock + .mockResolvedValueOnce({ + tasks: [task("task-1")], + next_cursor: "cursor-2", + next_id: "task-1", + }) + .mockResolvedValueOnce({ tasks: [task("task-2")], next_cursor: "", next_id: "" }); + legacyListTasksMock.mockImplementation(listTasksMock); + const queryClient = createQueryClient(); + const result = renderPaginatedTasks(queryClient); + + await waitFor(() => expect(result.result.current.tasks.map((t) => t.id)).toEqual(["task-1"])); + + const cacheEntries = queryClient.getQueryCache().findAll({ + queryKey: ["office", "workspaces", "workspace-1", "tasks"], + }); + expect(cacheEntries).toHaveLength(1); + expect(result.result.current.hasMore).toBe(true); + + await act(async () => { + result.result.current.loadMore(); + }); + + await waitFor(() => + expect(result.result.current.tasks.map((t) => t.id)).toEqual(["task-1", "task-2"]), + ); + expect(listTasksMock).toHaveBeenLastCalledWith( + "workspace-1", + expect.objectContaining({ cursor: "cursor-2", cursor_id: "task-1" }), + expect.anything(), + ); + expect(result.result.current.hasMore).toBe(false); + expect(result.result.current.isLoading).toBe(false); + }); +}); diff --git a/apps/web/app/office/tasks/use-paginated-tasks.ts b/apps/web/app/office/tasks/use-paginated-tasks.ts index f2a4d38238..04c16e842f 100644 --- a/apps/web/app/office/tasks/use-paginated-tasks.ts +++ b/apps/web/app/office/tasks/use-paginated-tasks.ts @@ -1,8 +1,15 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useInfiniteQuery, type InfiniteData } 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 { officeTasksInfiniteQueryOptions } from "@/lib/query/query-options/office"; +import type { OfficeTaskFilters } from "@/lib/query/keys"; +import type { + OfficeTask, + TaskFilterState, + TaskSortDir, + TaskSortField, +} from "@/lib/state/slices/office/types"; import { canonicalStatusesToBackend } from "./normalize-status"; const DEFAULT_PAGE_LIMIT = 200; @@ -10,7 +17,7 @@ const DEFAULT_PAGE_LIMIT = 200; // Server-side sort allow-list (mirror of taskListSortColumns on the // backend). Frontend "title" / "status" sorts have no SQL equivalent and // are handled by the local re-sort in useIssuesTree. -function mapSortField(field: TaskSortField): ListTasksParams["sort"] | undefined { +function mapSortField(field: TaskSortField): OfficeTaskFilters["sort"] { switch (field) { case "updated": return "updated_at"; @@ -23,38 +30,55 @@ function mapSortField(field: TaskSortField): ListTasksParams["sort"] | undefined } } -function buildParams( +function buildQueryFilters( filters: TaskFilterState, sortField: TaskSortField, sortDir: TaskSortDir, limit: number, includeSystem: boolean, -): ListTasksParams { - const params: ListTasksParams = { limit }; +): OfficeTaskFilters { + const params: OfficeTaskFilters = { limit }; const status = canonicalStatusesToBackend(filters.statuses); if (status.length > 0) params.status = status; if (filters.priorities.length > 0) params.priority = filters.priorities; - // Backend currently accepts a single assignee/project. With multi-select we - // omit the server filter and let the page-local filter narrow what's - // already loaded — so multi-value selections can miss matches beyond the - // first page until the backend grows repeated `assignee[]` / `project[]` - // params. - if (filters.assigneeIds.length === 1) params.assignee = filters.assigneeIds[0]; - if (filters.projectIds.length === 1) params.project = filters.projectIds[0]; + // Keep the full UI selection in the query key. The query option factory + // only sends single assignee/project values to the backend because the + // endpoint does not yet accept repeated values. + if (filters.assigneeIds.length > 0) params.assignee = filters.assigneeIds; + if (filters.projectIds.length > 0) params.project = filters.projectIds; const sort = mapSortField(sortField); if (sort) { params.sort = sort; params.order = sortDir; + } else { + params.sort = null; + params.order = null; } - if (includeSystem) params.include_system = true; + if (includeSystem) params.includeSystem = true; return params; } +type OfficeTasksInfiniteData = InfiniteData<{ tasks?: OfficeTask[] }>; + +function flattenPages(data: OfficeTasksInfiniteData | undefined): OfficeTask[] { + const tasks: OfficeTask[] = []; + const seen = new Set(); + for (const page of data?.pages ?? []) { + for (const task of page.tasks ?? []) { + if (seen.has(task.id)) continue; + seen.add(task.id); + tasks.push(task); + } + } + return tasks; +} + export type UsePaginatedTasksResult = { + tasks: OfficeTask[]; + isLoading: boolean; loadMore: () => void; hasMore: boolean; isLoadingMore: boolean; - refetch: () => Promise; }; /** @@ -62,116 +86,44 @@ export type UsePaginatedTasksResult = { * 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. + * or sort change. Exposes loadMore() to fetch the next page through the + * TanStack Query infinite cache; websocket invalidations are handled by + * the query bridge. */ export function usePaginatedTasks( workspaceId: string | null, includeSystem: boolean, ): 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), + const queryFilters = useMemo( + () => buildQueryFilters(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. + const query = useInfiniteQuery(officeTasksInfiniteQueryOptions(workspaceId ?? "", queryFilters)); + + const lastErrorRef = useRef(null); 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]); + if (!query.error || lastErrorRef.current === query.error) return; + lastErrorRef.current = query.error; + toast.error(query.error instanceof Error ? query.error.message : "Failed to load tasks"); + }, [query.error]); - 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]); + const queryTasks = useMemo(() => flattenPages(query.data), [query.data]); - 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, - }); - } catch (err) { - toast.error(err instanceof Error ? err.message : "Failed to refresh tasks"); - } - }, [workspaceId, setTasks]); + const loadMore = useCallback(() => { + if (!workspaceId || !query.hasNextPage || query.isFetchingNextPage) return; + void query.fetchNextPage(); + }, [query, workspaceId]); return { + tasks: queryTasks, + isLoading: Boolean(workspaceId) && query.isPending, loadMore, - hasMore: !!page.cursor && page.key === paramsKey, - isLoadingMore, - refetch, + hasMore: query.hasNextPage, + isLoadingMore: query.isFetchingNextPage, }; } diff --git a/apps/web/app/office/tasks/use-server-search.ts b/apps/web/app/office/tasks/use-server-search.ts index 3fb0dd6a62..1fcfc8420b 100644 --- a/apps/web/app/office/tasks/use-server-search.ts +++ b/apps/web/app/office/tasks/use-server-search.ts @@ -1,5 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { searchTasks } from "@/lib/api/domains/office-api"; +import { useQuery } from "@tanstack/react-query"; +import { useCallback, useEffect, useState } from "react"; +import { officeTaskSearchQueryOptions } from "@/lib/query/query-options"; import type { OfficeTask } from "@/lib/state/slices/office/types"; const DEBOUNCE_MS = 300; @@ -10,33 +11,31 @@ const DEBOUNCE_MS = 300; * a handler to trigger searches. */ export function useServerSearch(workspaceId: string | null) { - const [results, setResults] = useState(null); - const debounceRef = useRef | null>(null); + const [searchInput, setSearchInput] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); - const search = useCallback( - (query: string) => { - if (debounceRef.current) clearTimeout(debounceRef.current); - - if (!query.trim()) { - setResults(null); - return; - } - if (!workspaceId) return; - - debounceRef.current = setTimeout(() => { - searchTasks(workspaceId, query) - .then((res) => setResults(res.tasks ?? [])) - .catch(() => setResults(null)); - }, DEBOUNCE_MS); - }, - [workspaceId], - ); + const search = useCallback((query: string) => { + setSearchInput(query); + }, []); useEffect(() => { + const normalizedSearch = searchInput.trim(); + if (!normalizedSearch || !workspaceId) { + setDebouncedSearch(""); + return; + } + const timeout = setTimeout(() => { + setDebouncedSearch(normalizedSearch); + }, DEBOUNCE_MS); return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); + clearTimeout(timeout); }; - }, []); + }, [searchInput, workspaceId]); + + const searchQuery = useQuery(officeTaskSearchQueryOptions(workspaceId ?? "", debouncedSearch)); + const isActiveSearch = Boolean(workspaceId && debouncedSearch); + const results: OfficeTask[] | null = + isActiveSearch && !searchQuery.isError ? (searchQuery.data?.tasks ?? []) : null; return { searchResults: results, triggerSearch: search }; } diff --git a/apps/web/app/office/tasks/use-tasks-tree.ts b/apps/web/app/office/tasks/use-tasks-tree.ts index 40930a7a97..eb66a3dc7b 100644 --- a/apps/web/app/office/tasks/use-tasks-tree.ts +++ b/apps/web/app/office/tasks/use-tasks-tree.ts @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { useAppStore } from "@/components/state-provider"; +import { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import type { OfficeTask, OfficeTaskStatus, @@ -204,7 +204,7 @@ export function buildTaskTreeNodes({ export function useIssuesTree(opts: UseIssuesTreeOptions): FlatTaskNode[] { const { tasks, filters, sortField, sortDir, nestingEnabled, expandedIds } = opts; - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; const STATUS_ORDER = useMemo(() => { if (!meta) return FALLBACK_STATUS_ORDER; diff --git a/apps/web/app/office/workspace/activity/activity-feed.tsx b/apps/web/app/office/workspace/activity/activity-feed.tsx index 56c042e1d5..c633535aed 100644 --- a/apps/web/app/office/workspace/activity/activity-feed.tsx +++ b/apps/web/app/office/workspace/activity/activity-feed.tsx @@ -1,9 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { toast } from "sonner"; -import { listActivity } from "@/lib/api/domains/office-api"; +import { useOfficeActivityData } from "@/hooks/domains/office/use-office-data"; import type { ActivityEntry } from "@/lib/state/slices/office/types"; import { ActivityRow } from "./activity-row"; import { EmptyState } from "../../components/shared/empty-state"; @@ -19,17 +18,17 @@ const FILTER_OPTIONS = [ { value: "system", label: "System" }, ]; -export function ActivityFeed({ workspaceId }: { workspaceId: string }) { - const [entries, setEntries] = useState([]); +export function ActivityFeed({ + workspaceId, + initialActivity, +}: { + workspaceId: string; + initialActivity?: ActivityEntry[]; +}) { const [filterType, setFilterType] = useState("all"); - - useEffect(() => { - listActivity(workspaceId, filterType) - .then((res) => setEntries(res.activity ?? [])) - .catch((err) => { - toast.error(err instanceof Error ? err.message : "Failed to load activity"); - }); - }, [workspaceId, filterType]); + const activityQuery = useOfficeActivityData(workspaceId, filterType, initialActivity); + const entries = + activityQuery.data?.activity ?? (filterType === "all" ? (initialActivity ?? []) : []); return (
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 700bcc51be..8ceeb26167 100644 --- a/apps/web/app/office/workspace/activity/activity-page-client.tsx +++ b/apps/web/app/office/workspace/activity/activity-page-client.tsx @@ -1,39 +1,15 @@ "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[]; + initialActivity?: ActivityEntry[]; }; export function ActivityPageClient({ initialActivity }: ActivityPageClientProps) { 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]); - - useEffect(() => { - void refetchActivity(); - }, [refetchActivity]); - - useOfficeRefetch("activity", refetchActivity); if (!activeWorkspaceId) { return ( @@ -45,7 +21,7 @@ export function ActivityPageClient({ initialActivity }: ActivityPageClientProps) return (
- +
); } diff --git a/apps/web/app/office/workspace/costs/budgets-tab.tsx b/apps/web/app/office/workspace/costs/budgets-tab.tsx index 307763d14d..25e0667710 100644 --- a/apps/web/app/office/workspace/costs/budgets-tab.tsx +++ b/apps/web/app/office/workspace/costs/budgets-tab.tsx @@ -1,31 +1,28 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { IconPlus } from "@tabler/icons-react"; import { toast } from "sonner"; -import { listBudgets, deleteBudget } from "@/lib/api/domains/office-api"; -import type { BudgetPolicy } from "@/lib/state/slices/office/types"; +import { deleteBudget } from "@/lib/api/domains/office-api"; +import { qk } from "@/lib/query/keys"; +import { officeBudgetsQueryOptions } from "@/lib/query/query-options/office"; import { BudgetPolicyCard } from "./budget-policy-card"; import { CreateBudgetForm } from "./create-budget-form"; export function BudgetsTab({ workspaceId }: { workspaceId: string }) { - const [policies, setPolicies] = useState([]); + const queryClient = useQueryClient(); + const budgetsQuery = useQuery(officeBudgetsQueryOptions(workspaceId)); + const policies = budgetsQuery.data?.budgets ?? []; const [showCreate, setShowCreate] = useState(false); - const [reloadKey, setReloadKey] = useState(0); - - useEffect(() => { - listBudgets(workspaceId) - .then((res) => setPolicies(res.budgets ?? [])) - .catch((err) => { - toast.error(err instanceof Error ? err.message : "Failed to load budgets"); - }); - }, [workspaceId, reloadKey]); const handleDelete = async (id: string) => { try { await deleteBudget(id); - setPolicies((prev) => prev.filter((p) => p.id !== id)); + queryClient.setQueryData(qk.office.budgets(workspaceId), { + budgets: policies.filter((p) => p.id !== id), + }); toast.success("Budget policy deleted"); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to delete budget policy"); @@ -34,7 +31,7 @@ export function BudgetsTab({ workspaceId }: { workspaceId: string }) { const handleCreated = () => { setShowCreate(false); - setReloadKey((k) => k + 1); + void queryClient.invalidateQueries({ queryKey: qk.office.budgets(workspaceId) }); }; return ( diff --git a/apps/web/app/office/workspace/costs/cost-overview.tsx b/apps/web/app/office/workspace/costs/cost-overview.tsx index ab82c78055..133462bfc9 100644 --- a/apps/web/app/office/workspace/costs/cost-overview.tsx +++ b/apps/web/app/office/workspace/costs/cost-overview.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { IconCurrencyDollar, @@ -9,8 +10,7 @@ import { IconCpu, IconBuilding, } from "@tabler/icons-react"; -import { toast } from "sonner"; -import { getCostsBreakdown } from "@/lib/api/domains/office-api"; +import { officeCostBreakdownQueryOptions } from "@/lib/query/query-options/office"; import { MetricCard } from "../../components/metric-card"; import type { CostBreakdownItem } from "@/lib/state/slices/office/types"; import { CostBreakdownTable } from "./cost-breakdown-table"; @@ -20,27 +20,12 @@ type DateRange = "mtd" | "30d"; export function CostOverview({ workspaceId }: { workspaceId: string }) { const [range, setRange] = useState("mtd"); - const [totalSubcents, setTotalSubcents] = useState(0); - const [byAgent, setByAgent] = useState([]); - const [byProject, setByProject] = useState([]); - const [byModel, setByModel] = useState([]); - const [byProvider, setByProvider] = useState([]); - - useEffect(() => { - // Single composed call (Stream D of office optimization). Was four - // parallel round-trips (summary + by-agent + by-project + by-model). - getCostsBreakdown(workspaceId) - .then((res) => { - setTotalSubcents(res.total_subcents ?? 0); - setByAgent((res.by_agent ?? []) as CostBreakdownItem[]); - setByProject((res.by_project ?? []) as CostBreakdownItem[]); - setByModel((res.by_model ?? []) as CostBreakdownItem[]); - setByProvider((res.by_provider ?? []) as CostBreakdownItem[]); - }) - .catch((err) => { - toast.error(err instanceof Error ? err.message : "Failed to load cost data"); - }); - }, [workspaceId, range]); + const costsQuery = useQuery(officeCostBreakdownQueryOptions(workspaceId)); + const totalSubcents = costsQuery.data?.total_subcents ?? 0; + const byAgent = (costsQuery.data?.by_agent ?? []) as CostBreakdownItem[]; + const byProject = (costsQuery.data?.by_project ?? []) as CostBreakdownItem[]; + const byModel = (costsQuery.data?.by_model ?? []) as CostBreakdownItem[]; + const byProvider = (costsQuery.data?.by_provider ?? []) as CostBreakdownItem[]; 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 189cbbfc16..89daebe794 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 9bd9364e24..5b07a22825 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 ; + return ; } diff --git a/apps/web/app/office/workspace/org/page.tsx b/apps/web/app/office/workspace/org/page.tsx index 7ef1b5a54c..41186427bc 100644 --- a/apps/web/app/office/workspace/org/page.tsx +++ b/apps/web/app/office/workspace/org/page.tsx @@ -1,10 +1,12 @@ "use client"; import { useAppStore } from "@/components/state-provider"; +import { useOfficeAgentsData } from "@/hooks/domains/office/use-office-data"; 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 agents = useOfficeAgentsData(workspaceId).data?.agents ?? []; return (
diff --git a/apps/web/app/office/workspace/settings/components/danger-zone-section.test.ts b/apps/web/app/office/workspace/settings/components/danger-zone-section.test.ts index 0d4d18d020..eb1f9876f9 100644 --- a/apps/web/app/office/workspace/settings/components/danger-zone-section.test.ts +++ b/apps/web/app/office/workspace/settings/components/danger-zone-section.test.ts @@ -1,15 +1,16 @@ import { describe, expect, it } from "vitest"; import { postDeleteWorkspaceHref, resolvePostDeleteWorkspace } from "./danger-zone-section"; -import type { WorkspaceState } from "@/lib/state/slices/workspace/types"; - -type Workspace = WorkspaceState["items"][number]; +import type { Workspace } from "@/lib/types/http"; function workspace(id: string, officeWorkflowId?: string | null): Workspace { return { id, name: id, description: "", + owner_id: "user-1", office_workflow_id: officeWorkflowId, + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", } as Workspace; } 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 ca4cf38a83..a9dd19e0f9 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 @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "@/lib/routing/client-router"; import { IconTrash } from "@tabler/icons-react"; import { toast } from "sonner"; @@ -20,13 +21,12 @@ import { getWorkspaceDeletionSummary, type WorkspaceDeletionSummary, } from "@/lib/api/domains/office-api"; -import type { WorkspaceState } from "@/lib/state/slices/workspace/types"; import { isOfficeWorkspace, workspaceHomeHref, } from "@/components/app-sidebar/app-sidebar-workspace-navigation"; - -type Workspace = WorkspaceState["items"][number]; +import { removeWorkspaceCache } from "@/lib/query/workspace-cache"; +import type { Workspace } from "@/lib/types/http"; export function resolvePostDeleteWorkspace( deletedWorkspaceId: string, @@ -112,15 +112,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); @@ -148,9 +147,8 @@ export function DangerZoneSection({ setDeleting(true); try { await deleteWorkspace(workspace.id, confirmName); - const remaining = workspaces.filter((item) => item.id !== workspace.id); const nextWorkspace = resolvePostDeleteWorkspace(workspace.id, workspaces); - setWorkspaces(remaining); + removeWorkspaceCache(queryClient, workspace.id); setActiveWorkspace(nextWorkspace?.id ?? null); router.push(postDeleteWorkspaceHref(nextWorkspace)); 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 84601e49b4..21a133ae9b 100644 --- a/apps/web/app/office/workspace/settings/components/settings-content.tsx +++ b/apps/web/app/office/workspace/settings/components/settings-content.tsx @@ -8,8 +8,9 @@ import { Input } from "@kandev/ui/input"; import { Switch } from "@kandev/ui/switch"; import { Button } from "@kandev/ui/button"; import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { updateWorkspaceSettings, getWorkspaceSettings } from "@/lib/api/domains/office-api"; -import type { WorkspaceState } from "@/lib/state/slices/workspace/types"; +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,9 @@ function useSettingsState(activeWorkspace: Workspace | undefined) { } export function SettingsContent() { - const workspaces = useAppStore((s) => s.workspaces); - const setWorkspaces = useAppStore((s) => s.setWorkspaces); + const { items: workspaceItems, activeId: activeWorkspaceId } = useWorkspaces(); const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); - const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); + const activeWorkspace = workspaceItems.find((w) => w.id === activeWorkspaceId); const s = useSettingsState(activeWorkspace); const initial = (s.name || "W").charAt(0).toUpperCase(); @@ -469,8 +467,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 a9fbf4ccb0..515c8323c5 100644 --- a/apps/web/app/office/workspace/settings/export/export-preview.tsx +++ b/apps/web/app/office/workspace/settings/export/export-preview.tsx @@ -3,7 +3,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"; @@ -11,9 +11,7 @@ import { buildFileTree, bundleToExportFiles, countSelectedFiles } from "./export 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 { activeId: activeWorkspaceId, activeWorkspace } = useWorkspaces(); 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 20cacc9760..2712f1b180 100644 --- a/apps/web/app/office/workspace/skills/create-skill-form.tsx +++ b/apps/web/app/office/workspace/skills/create-skill-form.tsx @@ -5,7 +5,7 @@ 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 { useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; import type { SkillSourceType } from "@/lib/state/slices/office/types"; const FALLBACK_SOURCE_TYPES = [ @@ -26,7 +26,7 @@ type CreateSkillFormProps = { }; export function CreateSkillForm({ onCreate, onCancel }: CreateSkillFormProps) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; // 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/skill-detail.tsx b/apps/web/app/office/workspace/skills/skill-detail.tsx index 5bf6b2b8ac..d05e449706 100644 --- a/apps/web/app/office/workspace/skills/skill-detail.tsx +++ b/apps/web/app/office/workspace/skills/skill-detail.tsx @@ -18,6 +18,7 @@ import { Separator } from "@kandev/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useAppStore } from "@/components/state-provider"; +import { useOfficeAgentsData, useOfficeMetaData } from "@/hooks/domains/office/use-office-data"; 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 +51,7 @@ function SourceIcon({ sourceType }: { sourceType: SkillSourceType }) { } function useSkillSourceMeta(sourceType: SkillSourceType) { - const meta = useAppStore((s) => s.office.meta); + const meta = useOfficeMetaData().data; const metaSource = meta?.skillSourceTypes.find((s) => s.id === sourceType); return { label: metaSource?.label ?? FALLBACK_SOURCE_LABELS[sourceType] ?? sourceType, @@ -68,7 +69,8 @@ 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 agents = useOfficeAgentsData(workspaceId).data?.agents ?? []; const usedByCount = useMemo( () => agents.filter((a) => a.desiredSkills?.includes(skill.id)).length, [agents, skill.id], @@ -79,6 +81,8 @@ export function SkillDetail({ skill, onSave, onDelete }: SkillDetailProps) { const activeFilePath = selectedFile ?? "SKILL.md"; const isDirty = !readOnly && draft !== (skill.content ?? ""); + const readOnlyReason = getSkillReadOnlyReason(skill, sourceMeta.readOnlyReason); + const handleDelete = skill.isSystem ? undefined : () => onDelete(skill.id); // Reset the draft when the user navigates to a different skill (or // the skill row gets re-synced and the canonical content shifts). @@ -100,62 +104,133 @@ export function SkillDetail({ skill, onSave, onDelete }: SkillDetailProps) { onDelete(skill.id) : undefined} + readOnlyReason={readOnlyReason} + onDelete={handleDelete} /> - {hasFiles && ( -
- -
- )} + -
-
- {activeFilePath} - {!readOnly && ( - - )} -
-
- {readOnly &&
+ +
+ ); +} + +function getSkillReadOnlyReason(skill: Skill, sourceReadOnlyReason: string | undefined) { + if (!skill.isSystem) return sourceReadOnlyReason; + return `Bundled with kandev${skill.systemVersion ? ` v${skill.systemVersion}` : ""}`; +} + +function SkillFilesPanel({ + hasFiles, + fileTree, + activeFilePath, + onSelectFile, +}: { + hasFiles: boolean; + fileTree: FileTreeNode[]; + activeFilePath: string; + onSelectFile: (path: string) => void; +}) { + if (!hasFiles) return null; + return ( +
+ +
+ ); +} + +function SkillEditorPanel({ + activeFilePath, + draft, + readOnly, + isDirty, + isSaving, + onDraftChange, + onSave, +}: { + activeFilePath: string; + draft: string; + readOnly: boolean; + isDirty: boolean; + isSaving: boolean; + onDraftChange: (value: string) => void; + onSave: () => void; +}) { + return ( +
+
+ {activeFilePath} + +
+
+ {readOnly &&
); } +function SkillSaveButton({ + readOnly, + isDirty, + isSaving, + onSave, +}: { + readOnly: boolean; + isDirty: boolean; + isSaving: boolean; + onSave: () => void; +}) { + if (readOnly) return null; + return ( + + ); +} + const FALLBACK_READ_ONLY_REASONS: Partial> = { git: "GitHub-managed skills are read-only", skills_sh: "skills.sh-managed skills are read-only", 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 05712a54c1..8f3e333acb 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 { useQueryClient } from "@tanstack/react-query"; import { IconBoxMultiple } from "@tabler/icons-react"; import { toast } from "sonner"; import { useAppStore } from "@/components/state-provider"; +import { useOfficeSkillsData } from "@/hooks/domains/office/use-office-data"; import * as officeApi from "@/lib/api/domains/office-api"; +import { qk } from "@/lib/query/keys"; import type { Skill } from "@/lib/state/slices/office/types"; import { SkillList } from "./skill-list"; import { SkillDetail } from "./skill-detail"; @@ -13,7 +16,7 @@ import { CreateSkillForm } from "./create-skill-form"; type ViewMode = "view" | "create"; type SkillsPageClientProps = { - initialSkills: Skill[]; + initialSkills?: Skill[]; }; function useSkillActions( @@ -23,16 +26,14 @@ 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 queryClient = useQueryClient(); const handleCreate = useCallback( async (data: Partial) => { if (!activeWorkspaceId) return; try { const res = await officeApi.createSkill(activeWorkspaceId, data); - addSkill(res.skill); + appendSkill(queryClient, activeWorkspaceId, res.skill); setSelectedId(res.skill.id); setViewMode("view"); } catch (err) { @@ -44,27 +45,31 @@ function useSkillActions( } } }, - [activeWorkspaceId, addSkill, setSelectedId, setViewMode], + [activeWorkspaceId, queryClient, setSelectedId, setViewMode], ); const handleSave = useCallback( async (id: string, patch: Partial) => { - await officeApi.updateSkill(id, patch); - updateSkillInStore(id, patch); + const res = await officeApi.updateSkill(id, patch); + if (activeWorkspaceId) { + patchSkill(queryClient, activeWorkspaceId, id, res.skill); + } }, - [updateSkillInStore], + [activeWorkspaceId, queryClient], ); const handleDelete = useCallback( async (id: string) => { await officeApi.deleteSkill(id); - removeSkillFromStore(id); + if (activeWorkspaceId) { + removeSkillFromCache(queryClient, activeWorkspaceId, id); + } if (selectedId === id) { const remaining = skills.filter((s) => s.id !== id); setSelectedId(remaining[0]?.id ?? null); } }, - [removeSkillFromStore, selectedId, skills, setSelectedId], + [activeWorkspaceId, queryClient, selectedId, skills, setSelectedId], ); const handleImport = useCallback( @@ -72,47 +77,27 @@ function useSkillActions( if (!activeWorkspaceId) return; const res = await officeApi.importSkill(activeWorkspaceId, source); for (const skill of res.skills) { - addSkill(skill); + appendSkill(queryClient, activeWorkspaceId, skill); } if (res.skills.length > 0) { setSelectedId(res.skills[0].id); setViewMode("view"); } }, - [activeWorkspaceId, addSkill, setSelectedId, setViewMode], + [activeWorkspaceId, queryClient, 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); const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const skillsQuery = useOfficeSkillsData(activeWorkspaceId, initialSkills); 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) => { - setSkills(res.skills ?? []); - }) - .catch(() => {}); - }, [activeWorkspaceId, setSkills]); - - useEffect(() => { - fetchSkills(); - }, [fetchSkills]); - + const skills = skillsQuery.data?.skills ?? initialSkills ?? []; const selectedSkill = skills.find((s) => s.id === selectedId) ?? null; const { handleCreate, handleSave, handleDelete, handleImport } = useSkillActions( activeWorkspaceId, @@ -135,7 +120,7 @@ export function SkillsPageClient({ initialSkills }: SkillsPageClientProps) { setSelectedId(null); setViewMode("create"); }} - onRefresh={fetchSkills} + onRefresh={() => void skillsQuery.refetch()} onImport={handleImport} />
@@ -152,6 +137,41 @@ export function SkillsPageClient({ initialSkills }: SkillsPageClientProps) { ); } +function appendSkill( + queryClient: ReturnType, + workspaceId: string, + skill: Skill, +) { + queryClient.setQueryData<{ skills: Skill[] }>(qk.office.skills(workspaceId), (current) => { + const skills = current?.skills ?? []; + if (skills.some((item) => item.id === skill.id)) return { skills }; + return { skills: [...skills, skill] }; + }); +} + +function patchSkill( + queryClient: ReturnType, + workspaceId: string, + id: string, + patch: Partial, +) { + queryClient.setQueryData<{ skills: Skill[] }>(qk.office.skills(workspaceId), (current) => ({ + skills: (current?.skills ?? []).map((skill) => + skill.id === id ? { ...skill, ...patch } : skill, + ), + })); +} + +function removeSkillFromCache( + queryClient: ReturnType, + workspaceId: string, + id: string, +) { + queryClient.setQueryData<{ skills: Skill[] }>(qk.office.skills(workspaceId), (current) => ({ + skills: (current?.skills ?? []).filter((skill) => skill.id !== id), + })); +} + function SkillContentPanel({ viewMode, selectedSkill, diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 56692f7b3c..ae6ba828b0 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -13,11 +13,13 @@ import { listWorkspaceTaskPRs } from "@/lib/api/domains/github-api"; import { snapshotToState } from "@/lib/ssr/mapper"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; -import { ACTIVE_WORKSPACE_COOKIE } from "@/lib/routing/route-bootstrap"; import { resolveActiveId } from "@/lib/ssr/resolve-active-id"; import { readCookies } from "@/lib/server/cookies"; +import type { QuerySeedInitialState } from "@/lib/query/seed"; +import type { UserSettingsState } from "@/lib/state/slices"; import type { AppState } from "@/lib/state/store"; import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http"; +import { readKanbanActiveWorkspaceCookie } from "./kanban-active-workspace-cookie"; // Root page loader: keeps the old route shape while SPA boot data owns hydration. type PageProps = { @@ -39,7 +41,7 @@ function mapWorkspaceItem(ws: WorkspaceItem) { default_environment_id: ws.default_environment_id ?? null, default_agent_profile_id: ws.default_agent_profile_id ?? null, default_config_agent_profile_id: ws.default_config_agent_profile_id ?? null, - office_workflow_id: ws.office_workflow_id ?? null, + office_workflow_id: ws.office_workflow_id ?? undefined, created_at: ws.created_at, updated_at: ws.updated_at, }; @@ -48,7 +50,7 @@ function mapWorkspaceItem(ws: WorkspaceItem) { function buildUserSettingsState( resp: UserSettingsResponse | null, workspaceId: string | null, -): AppState["userSettings"] { +): UserSettingsState { return { ...mapUserSettingsResponse(resp), workspaceId }; } @@ -85,7 +87,7 @@ function buildBaseState( workspaces: ListWorkspacesResponse, userSettingsResponse: UserSettingsResponse | null, activeWorkspaceId: string | null, -): Partial { +): QuerySeedInitialState { return { workspaces: { items: workspaces.workspaces.map(mapWorkspaceItem), @@ -109,7 +111,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 @@ -120,7 +122,7 @@ async function loadSnapshotState( ).catch(() => null) : Promise.resolve(null), ]); - const state: Partial = { ...snapshotToState(snapshot) }; + const state: QuerySeedInitialState = { ...snapshotToState(snapshot) }; if (sessionId && messagesResponse) { const messages = [...(messagesResponse.messages ?? [])].reverse(); @@ -158,12 +160,9 @@ export default async function Page({ searchParams }: PageProps) { const settingsWorkflowId = userSettingsResponse?.settings?.workflow_filter_id || null; // The sidebar picker writes the selected workspace to this cookie so the // choice survives a refresh even when userSettings is not updated on select. - // Kanban home only resolves against kanban workspaces; office workspaces - // belong under /office. - const cookieWorkspaceId = cookieStore?.get(ACTIVE_WORKSPACE_COOKIE)?.value ?? null; - // `readCookies()` is client-only in this code path; during SSR this is empty. - // Workspace selection still works because spa-routes.tsx re-hydrates from - // `readActiveWorkspaceCookie()` and the generic resolver on first client render. + // Priority: URL param > active workspace cookie > saved setting. Kanban home + // only resolves against kanban workspaces; office workspaces belong under /office. + const cookieWorkspaceId = readKanbanActiveWorkspaceCookie(cookieStore); const activeWorkspaceId = resolveActiveKanbanWorkspaceId( workspaces.workspaces, workspaceId, @@ -207,22 +206,18 @@ export default async function Page({ searchParams }: PageProps) { initialState = { ...initialState, userSettings: { - ...(initialState.userSettings as AppState["userSettings"]), + ...buildUserSettingsState(userSettingsResponse, activeWorkspaceId), workflowId, }, workflows: { - items: workflowList.workflows.map((w) => ({ - id: w.id, - workspaceId: w.workspace_id, - name: w.name, - hidden: w.hidden, - })), activeId: workflowId, }, + workflowLists: { + itemsByWorkspaceId: { [activeWorkspaceId]: workflowList.workflows }, + includeHiddenByWorkspaceId: { [activeWorkspaceId]: true }, + }, repositories: { itemsByWorkspaceId: { [activeWorkspaceId]: repositoriesResponse.repositories }, - loadingByWorkspaceId: { [activeWorkspaceId]: false }, - loadedByWorkspaceId: { [activeWorkspaceId]: true }, }, quickChat: { isOpen: false, diff --git a/apps/web/app/settings/agents/[agentId]/page.tsx b/apps/web/app/settings/agents/[agentId]/page.tsx index 6465259eb4..0b39834af8 100644 --- a/apps/web/app/settings/agents/[agentId]/page.tsx +++ b/apps/web/app/settings/agents/[agentId]/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import Link from "@/components/routing/app-link"; import { useParams, useRouter, useSearchParams } from "@/lib/routing/client-router"; import { Button } from "@kandev/ui/button"; @@ -18,8 +19,10 @@ 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 { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { qk } from "@/lib/query/keys"; import { deleteAgentAction } from "@/app/actions/agents"; import { saveNewAgent, saveExistingAgent, isProfileDirty } from "./agent-save-helpers"; import type { DraftProfile, DraftAgent } from "./agent-save-helpers"; @@ -145,36 +148,34 @@ function useAgentFormState( }; } -function useAgentStoreSync() { - const settingsAgents = useAppStore((state) => state.settingsAgents.items); - const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); - const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); - - const syncAgentsToStore = (nextAgents: Agent[]) => { - setSettingsAgents(nextAgents); - setAgentProfiles( - 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, - })), - ), - ); +type AgentsQueryData = { agents: Agent[]; total?: number }; + +function useAgentQuerySync() { + const queryClient = useQueryClient(); + const { settingsAgents } = useSettingsData(true); + + const syncAgentsToQuery = (nextAgents: Agent[]) => { + queryClient.setQueryData(qk.settings.agents(), (previous) => ({ + ...(previous ?? {}), + agents: nextAgents, + total: nextAgents.length, + })); }; const upsertAgent = (agent: Agent) => { const exists = settingsAgents.some((item: Agent) => item.id === agent.id); - syncAgentsToStore( + syncAgentsToQuery( exists ? settingsAgents.map((item: Agent) => (item.id === agent.id ? agent : item)) : [...settingsAgents, agent], ); }; - return { upsertAgent }; + const removeAgent = (agentId: string) => { + syncAgentsToQuery(settingsAgents.filter((item: Agent) => item.id !== agentId)); + }; + + return { removeAgent, upsertAgent }; } type AgentSaveHandlersProps = { @@ -188,6 +189,7 @@ type AgentSaveHandlersProps = { setDraftAgent: (agent: DraftAgent) => void; setSaveStatus: (status: "idle" | "loading" | "success" | "error") => void; upsertAgent: (agent: Agent) => void; + removeAgent: (agentId: string) => void; onToastError: (error: unknown) => void; replaceRoute: (path: string) => void; }; @@ -203,6 +205,7 @@ function useAgentSaveHandlers({ setDraftAgent, setSaveStatus, upsertAgent, + removeAgent, onToastError, replaceRoute, }: AgentSaveHandlersProps) { @@ -248,6 +251,7 @@ function useAgentSaveHandlers({ if (!savedAgent) return; try { await deleteAgentAction(savedAgent.id); + removeAgent(savedAgent.id); replaceRoute("/settings/agents"); } catch (err) { onToastError(err); @@ -354,7 +358,7 @@ function AgentSetupForm({ }: AgentSetupFormProps) { const router = useRouter(); const availableAgents = useAvailableAgents().items; - const { upsertAgent } = useAgentStoreSync(); + const { removeAgent, upsertAgent } = useAgentQuerySync(); const { draftAgent, @@ -393,6 +397,7 @@ function AgentSetupForm({ setDraftAgent, setSaveStatus, upsertAgent, + removeAgent, onToastError, replaceRoute: (path: string) => router.replace(path), }); @@ -438,8 +443,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 { settingsAgents: savedAgents } = useSettingsData(true); 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 26ad1b10db..9a34641f3c 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 { useQueryClient } from "@tanstack/react-query"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; -import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { listAgents } from "@/lib/api"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; +import { qk } from "@/lib/query/keys"; import type { Agent, AgentProfile, @@ -48,9 +49,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 queryClient = useQueryClient(); + const { settingsAgents } = useSettingsData(true); const availableAgents = useAvailableAgents().items; const refreshKeyRef = useRef(null); @@ -76,12 +76,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)), - ), - ); + queryClient.setQueryData(qk.settings.agents(), response); }) .catch(() => { refreshKeyRef.current = null; @@ -90,7 +85,7 @@ export function useAgentProfileSettings( return () => { cancelled = true; }; - }, [agentKey, profile, profileId, setAgentProfiles, setSettingsAgents]); + }, [agentKey, profile, profileId, queryClient]); 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 8a63f07644..965c8ab4a2 100644 --- a/apps/web/app/settings/agents/page.tsx +++ b/apps/web/app/settings/agents/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "@/components/routing/app-link"; import { IconAlertTriangle, @@ -17,24 +18,24 @@ 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 { createCustomTUIAgent, installAgent, listAgentDiscovery, listAgents, listAvailableAgents, - listInstallJobs, } from "@/lib/api"; import type { InstallJob } from "@/lib/api"; import { useAgentDiscovery } from "@/hooks/domains/settings/use-agent-discovery"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { qk } from "@/lib/query/keys"; +import { installJobsQueryOptions } from "@/lib/query/query-options/settings"; import { AgentLogo } from "@/components/agent-logo"; 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 type { AgentDiscovery, Agent, @@ -379,36 +380,33 @@ function AgentProfilesSection({ savedAgents }: AgentProfilesSectionProps) { ); } -/** - * Install state is held in the store (driven by WS events - * agent.install.{started,output,finished}). This hook: - * - Rehydrates jobs on mount so a page reload picks up in-flight installs. - * - Subscribes to agent.available.updated → calls onSuccess() to rescan. - * - Exposes handleInstall(name) which POSTs to enqueue (idempotent on the - * server: clicking again while running returns the same job_id). - */ -function useInstallAgent(onSuccess: () => Promise) { - const installJobs = useAppStore((state) => state.installJobs.byAgent); - const upsertInstallJob = useAppStore((state) => state.upsertInstallJob); +function upsertInstallJob(jobs: InstallJob[], next: InstallJob): InstallJob[] { + const existing = jobs.findIndex((job) => job.job_id === next.job_id); + if (existing < 0) return [...jobs, next]; + const copy = [...jobs]; + copy[existing] = next; + return copy; +} - useEffect(() => { - let cancelled = false; - listInstallJobs() - .then((resp) => { - if (cancelled) return; - // Upsert per-job rather than wholesale-replace: if a WS event - // already seeded an in-flight job with output chunks between page - // mount and this HTTP response, the snapshot from the server may - // be older, and a full replace would clobber the live output. - for (const job of resp.jobs) upsertInstallJob(job); - }) - .catch(() => { - /* page mount; ignore */ - }); - return () => { - cancelled = true; - }; - }, [upsertInstallJob]); +function useInstallAgent(onSuccess: () => Promise) { + const queryClient = useQueryClient(); + const installJobsQuery = useQuery(installJobsQueryOptions()); + const installJobs = useMemo( + () => + Object.fromEntries( + (installJobsQuery.data?.jobs ?? []).map((job) => [job.agent_name, job]), + ) as Record, + [installJobsQuery.data?.jobs], + ); + const seedInstallJob = useCallback( + (job: InstallJob) => { + queryClient.setQueryData(qk.settings.installJob(job.job_id), job); + queryClient.setQueryData<{ jobs: InstallJob[] }>(qk.settings.installJobs(), (previous) => ({ + jobs: upsertInstallJob(previous?.jobs ?? [], job), + })); + }, + [queryClient], + ); // When any install finishes successfully, trigger the page-level rescan so // the agent disappears from "Available to Install" and shows up under @@ -427,11 +425,11 @@ function useInstallAgent(onSuccess: () => Promise) { async (name: string) => { try { const job = await installAgent(name); - // The WS event will normally arrive first, but seed the store in case + // The WS event will normally arrive first, but seed the query cache in case // the WS round-trip is slower than the HTTP response. - upsertInstallJob(job); + seedInstallJob(job); } catch (err) { - upsertInstallJob({ + seedInstallJob({ job_id: `local-error-${name}`, agent_name: name, status: "failed", @@ -440,7 +438,7 @@ function useInstallAgent(onSuccess: () => Promise) { }); } }, - [upsertInstallJob], + [seedInstallJob], ); return { installJobs, handleInstall }; @@ -448,12 +446,9 @@ function useInstallAgent(onSuccess: () => Promise) { function useAgentPageState() { 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 { settingsAgents: savedAgents } = useSettingsData(true); const { items: availableAgents, tools } = useAvailableAgents(); + const queryClient = useQueryClient(); const [rescanning, setRescanning] = useState(false); const [tuiDialogOpen, setTuiDialogOpen] = useState(false); @@ -484,8 +479,8 @@ function useAgentPageState() { listAgentDiscovery({ cache: "no-store" }), listAvailableAgents({ cache: "no-store" }), ]); - setAgentDiscovery(discoveryResp.agents); - setAvailableAgents(availableResp.agents, availableResp.tools ?? []); + queryClient.setQueryData(qk.settings.agentDiscovery(), discoveryResp); + queryClient.setQueryData(qk.settings.availableAgents(), availableResp); } finally { setRescanning(false); } @@ -504,14 +499,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 ?? []); + queryClient.setQueryData(qk.settings.agentDiscovery(), discoveryResp); + queryClient.setQueryData(qk.settings.agents(), agentsResp); + queryClient.setQueryData(qk.settings.availableAgents(), availableResp); }; return { diff --git a/apps/web/app/settings/automations/page.test.tsx b/apps/web/app/settings/automations/page.test.tsx index 829ab1f010..ecbae5d151 100644 --- a/apps/web/app/settings/automations/page.test.tsx +++ b/apps/web/app/settings/automations/page.test.tsx @@ -1,5 +1,9 @@ +import { QueryClientProvider } from "@tanstack/react-query"; import { cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; // Regression coverage for the top-level /settings/automations page. // @@ -14,14 +18,25 @@ import { afterEach, describe, expect, it, vi } from "vitest"; type Workspace = { id: string; name: string; description?: string | null }; let mockWorkspaces: Workspace[] = []; +let queryClient: ReturnType; const { replaceSpy, listWorkspacesSpy } = vi.hoisted(() => ({ replaceSpy: vi.fn(), listWorkspacesSpy: vi.fn(), })); vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (state: { workspaces: { items: Workspace[] } }) => unknown) => - selector({ workspaces: { items: mockWorkspaces } }), + useAppStore: ( + selector: (state: { + workspaces: { activeId: string | null }; + setActiveWorkspace: (workspaceId: string | null) => void; + setActiveWorkflow: (workflowId: string | null) => void; + }) => unknown, + ) => + selector({ + workspaces: { activeId: mockWorkspaces[0]?.id ?? null }, + setActiveWorkspace: vi.fn(), + setActiveWorkflow: vi.fn(), + }), })); // Mirror the production useRouter(), which returns a memoized (useMemo([])) @@ -49,6 +64,15 @@ vi.mock("@/lib/api", () => ({ import AutomationsTopLevelPage from "./page"; +function renderPage() { + queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workspaces.all(), mockWorkspaces); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return render(, { wrapper }); +} + afterEach(() => { cleanup(); vi.clearAllMocks(); @@ -62,7 +86,7 @@ describe("AutomationsTopLevelPage", () => { { id: "ws-2", name: "Beta", description: "second workspace" }, ]; - render(); + renderPage(); expect(screen.getByText("Alpha")).toBeTruthy(); expect(screen.getByText("Beta")).toBeTruthy(); @@ -74,7 +98,7 @@ describe("AutomationsTopLevelPage", () => { it("redirects to the single workspace's automations when exactly one exists", () => { mockWorkspaces = [{ id: "only-ws", name: "Solo" }]; - render(); + renderPage(); // Exactly once — `useRouter()` returns a stable (useMemo([])) reference, so the // effect must not re-fire and re-redirect on re-render. @@ -89,7 +113,7 @@ describe("AutomationsTopLevelPage", () => { it("shows an empty state when there are no workspaces", () => { mockWorkspaces = []; - render(); + renderPage(); expect(screen.getByText("No workspaces yet")).toBeTruthy(); expect(listWorkspacesSpy).not.toHaveBeenCalled(); diff --git a/apps/web/app/settings/automations/page.tsx b/apps/web/app/settings/automations/page.tsx index 10fb804b1d..9c08684031 100644 --- a/apps/web/app/settings/automations/page.tsx +++ b/apps/web/app/settings/automations/page.tsx @@ -3,12 +3,12 @@ import { useEffect } from "react"; import Link from "@/components/routing/app-link"; -import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useRouter } from "@/lib/routing/client-router"; export default function AutomationsTopLevelPage() { const router = useRouter(); - const workspaces = useAppStore((s) => s.workspaces.items); + const { items: workspaces } = useWorkspaces(); const soleWorkspaceId = workspaces.length === 1 ? workspaces[0].id : null; diff --git a/apps/web/app/settings/executor/[id]/page.tsx b/apps/web/app/settings/executor/[id]/page.tsx index de5e058540..7e15a9e470 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 { useExecutorsQuerySync } from "@/hooks/domains/settings/use-executors-query-sync"; 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,8 @@ 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 { executors } = useExecutorsQuerySync(); + const executor = executors.find((item: Executor) => item.id === id) ?? null; if (!executor) { return ( @@ -187,8 +186,7 @@ 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 { removeExecutor } = useExecutorsQuerySync(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteConfirmText, setDeleteConfirmText] = useState(""); const [isDeleting, setIsDeleting] = useState(false); @@ -203,7 +201,7 @@ function DeleteExecutorSection({ executor }: { executor: Executor }) { } else { await deleteExecutorAction(executor.id); } - setExecutors(executors.filter((item: Executor) => item.id !== executor.id)); + removeExecutor(executor.id); router.push(EXECUTORS_ROUTE); } finally { setIsDeleting(false); @@ -274,8 +272,7 @@ 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 { upsertExecutor } = useExecutorsQuerySync(); const [mcpPolicy, setMcpPolicy] = useState(executor.config?.mcp_policy ?? ""); const [savedMcpPolicy, setSavedMcpPolicy] = useState(executor.config?.mcp_policy ?? ""); const [isSaving, setIsSaving] = useState(false); @@ -295,11 +292,7 @@ function ExecutorEditForm({ executor }: { executor: Executor }) { ? await client.request("executor.update", { id: executor.id, ...payload }) : await updateExecutorAction(executor.id, payload); setSavedMcpPolicy(updated.config?.mcp_policy ?? ""); - setExecutors( - executors.map((item: Executor) => - item.id === updated.id ? { ...item, ...updated } : item, - ), - ); + upsertExecutor(updated); } finally { setIsSaving(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 d9989a93cc..7346fc34ce 100644 --- a/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx +++ b/apps/web/app/settings/executor/[id]/profile/[profileId]/page.tsx @@ -17,10 +17,10 @@ import { DialogTitle, } from "@kandev/ui/dialog"; import { IconPlus, IconTrash } from "@tabler/icons-react"; -import { useAppStore } from "@/components/state-provider"; import { RequestIndicator } from "@/components/request-indicator"; import { useToast } from "@/components/toast-provider"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; +import { useExecutorsQuerySync } from "@/hooks/domains/settings/use-executors-query-sync"; import { updateExecutorProfile, deleteExecutorProfile, @@ -29,7 +29,6 @@ import { import type { ScriptPlaceholder } from "@/lib/api/domains/settings-api"; import { getSaveButtonLabel, - upsertExecutorProfile, type SaveStatus, } from "@/components/settings/profile-edit/profile-edit-page-chrome"; import { @@ -74,9 +73,8 @@ 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 { executors } = useExecutorsQuerySync(); + const executor = executors.find((e: Executor) => e.id === executorId) ?? null; const profile = executor?.profiles?.find((p: ExecutorProfile) => p.id === profileId) ?? null; if (!executor || !profile) { @@ -322,8 +320,7 @@ 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 { removeExecutorProfile, upsertExecutorProfile } = useExecutorsQuerySync(); const [saveStatus, setSaveStatus] = useState("idle"); const [error, setError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -342,7 +339,7 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { const updated = await updateExecutorProfile(executor.id, profile.id, data); setSaveStatus("success"); toast({ title: "Profile saved", variant: "success" }); - setExecutors(upsertExecutorProfile(executors, executor, updated)); + upsertExecutorProfile(executor.id, updated); window.setTimeout(() => setSaveStatus("idle"), 1500); } catch (err) { const message = err instanceof Error ? err.message : "Failed to save profile"; @@ -351,26 +348,20 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { toast({ title: "Failed to save profile", description: message, variant: "error" }); } }, - [executor, profile.id, executors, setExecutors, toast], + [executor.id, profile.id, toast, upsertExecutorProfile], ); const remove = useCallback(async () => { setDeleting(true); try { await deleteExecutorProfile(executor.id, profile.id); - setExecutors( - executors.map((e: Executor) => - e.id === executor.id - ? { ...e, profiles: e.profiles?.filter((p) => p.id !== profile.id) } - : e, - ), - ); + removeExecutorProfile(executor.id, profile.id); router.push(`/settings/executor/${executor.id}`); } catch { setDeleting(false); setDeleteDialogOpen(false); } - }, [executor.id, profile.id, executors, setExecutors, router]); + }, [executor.id, profile.id, removeExecutorProfile, router]); return { saveStatus, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; } diff --git a/apps/web/app/settings/executor/new/page.tsx b/apps/web/app/settings/executor/new/page.tsx index 1db3386160..0adc3ac8cc 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 { useExecutorsQuerySync } from "@/hooks/domains/settings/use-executors-query-sync"; import type { Executor } from "@/lib/types/http"; const EXECUTOR_TYPES = ["local_docker", "remote_docker"] as const; @@ -217,8 +217,7 @@ 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 { upsertExecutor } = useExecutorsQuerySync(); const handleTypeChange = (value: ExecutorType) => { setType(value); @@ -246,7 +245,7 @@ function ExecutorCreatePageContent() { const created = client ? await client.request("executor.create", payload) : await createExecutorAction(payload); - setExecutors([...executors.filter((item: Executor) => item.id !== created.id), created]); + upsertExecutor(created); router.push("/settings/executors"); } finally { setIsCreating(false); diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index 15a05a019e..a978cb39a3 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -5,8 +5,8 @@ import { useRouter } from "@/lib/routing/client-router"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { IconShieldLock } from "@tabler/icons-react"; -import { useAppStore } from "@/components/state-provider"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; +import { useExecutorsQuerySync } from "@/hooks/domains/settings/use-executors-query-sync"; import { updateExecutorProfile, deleteExecutorProfile, @@ -41,7 +41,6 @@ import { ProfileHeader, ProfileFormActions, DeleteProfileDialog, - upsertExecutorProfile, type SaveStatus, } from "@/components/settings/profile-edit/profile-edit-page-chrome"; import { useToast } from "@/components/toast-provider"; @@ -51,11 +50,9 @@ 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 { executors } = useExecutorsQuerySync(); + const executor = + executors.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; } @@ -209,8 +206,7 @@ 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 { removeExecutorProfile, upsertExecutorProfile } = useExecutorsQuerySync(); const [saveStatus, setSaveStatus] = useState("idle"); const [error, setError] = useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -231,7 +227,7 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { const updated = await updateExecutorProfile(executor.id, profile.id, data); setSaveStatus("success"); toast({ title: "Profile saved", variant: "success" }); - setExecutors(upsertExecutorProfile(executors, executor, updated)); + upsertExecutorProfile(executor.id, updated); window.setTimeout(() => setSaveStatus("idle"), 1500); } catch (err) { const message = err instanceof Error ? err.message : "Failed to save profile"; @@ -240,7 +236,7 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { toast({ title: "Failed to save profile", description: message, variant: "error" }); } }, - [executor, profile.id, executors, setExecutors, toast], + [executor.id, profile.id, toast, upsertExecutorProfile], ); const remove = useCallback( @@ -249,20 +245,14 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) { try { await beforeDelete?.(); await deleteExecutorProfile(executor.id, profile.id); - setExecutors( - executors.map((e: Executor) => - e.id === executor.id - ? { ...e, profiles: e.profiles?.filter((p) => p.id !== profile.id) } - : e, - ), - ); + removeExecutorProfile(executor.id, profile.id); router.push(EXECUTORS_ROUTE); } catch { setDeleting(false); setDeleteDialogOpen(false); } }, - [executor.id, profile.id, executors, setExecutors, router], + [executor.id, profile.id, removeExecutorProfile, router], ); return { saveStatus, error, deleting, deleteDialogOpen, setDeleteDialogOpen, save, remove }; diff --git a/apps/web/app/settings/executors/new/[type]/page.tsx b/apps/web/app/settings/executors/new/[type]/page.tsx index fde7e4b399..7338319a30 100644 --- a/apps/web/app/settings/executors/new/[type]/page.tsx +++ b/apps/web/app/settings/executors/new/[type]/page.tsx @@ -7,8 +7,8 @@ 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 { useSecrets } from "@/hooks/domains/settings/use-secrets"; +import { useExecutorsQuerySync } from "@/hooks/domains/settings/use-executors-query-sync"; import { createExecutorProfile, fetchLocalGitIdentity, @@ -40,7 +40,7 @@ import { type GitIdentityState, } from "@/components/settings/profile-edit/remote-credentials-card"; import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; -import type { Executor, ExecutorType, ProfileEnvVar } from "@/lib/types/http"; +import type { ExecutorType, ProfileEnvVar } from "@/lib/types/http"; import { EXECUTOR_TYPE_MAP } from "./executor-types"; import { SSHCreatePage } from "./ssh-create-page"; @@ -418,8 +418,7 @@ function useCreateProfileSave( executorId: string, ) { const router = useRouter(); - const executors = useAppStore((state) => state.executors.items); - const setExecutors = useAppStore((state) => state.setExecutors); + const { upsertExecutorProfile } = useExecutorsQuerySync(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -457,17 +456,13 @@ function useCreateProfileSave( cleanup_script: form.cleanupScript, env_vars: form.buildEnvVars(), }); - setExecutors( - executors.map((e: Executor) => - e.id === executorId ? { ...e, profiles: [...(e.profiles ?? []), profile] } : e, - ), - ); + upsertExecutorProfile(executorId, profile); router.push(`/settings/executors/${profile.id}`); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create profile"); setSaving(false); } - }, [form, executorId, executors, setExecutors, router]); + }, [form, executorId, router, upsertExecutorProfile]); return { saving, error, handleSave }; } 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 9a243f3107..fe5781c536 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,7 @@ 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 { useExecutorsQuerySync } from "@/hooks/domains/settings/use-executors-query-sync"; 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 +30,7 @@ const EXECUTORS_ROUTE = "/settings/executors"; */ export function SSHCreatePage() { const router = useRouter(); - const store = useAppStoreApi(); + const { upsertExecutor } = useExecutorsQuerySync(); const handleSave = useCallback( async (cfg: SSHExecutorConfig) => { @@ -58,17 +58,10 @@ export function SSHCreatePage() { created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; - // Read the latest store snapshot at write time so a concurrent - // updater (another fetch, a WS event, etc.) that landed while - // 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 merged = current.some((e) => e.id === next.id) ? current : [...current, next]; - store.getState().setExecutors(merged); + upsertExecutor(next); router.push(`/settings/executors/${profile.id}`); }, - [router, store], + [router, upsertExecutor], ); return ( diff --git a/apps/web/app/settings/executors/page.tsx b/apps/web/app/settings/executors/page.tsx index bdec98430c..5878804d01 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 { useExecutorsQuerySync } from "@/hooks/domains/settings/use-executors-query-sync"; 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 } = useExecutorsQuerySync(); return useMemo( () => executors.flatMap((e: Executor) => @@ -177,8 +177,7 @@ 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 { removeExecutorProfile } = useExecutorsQuerySync(); const [deleteProfileId, setDeleteProfileId] = useState(null); const [deleting, setDeleting] = useState(false); @@ -191,13 +190,7 @@ export default function ExecutorsHubPage() { setDeleting(true); try { await deleteExecutorProfile(profileToDelete.parent_executor_id, profileToDelete.id); - setExecutors( - executors.map((e: Executor) => - e.id === profileToDelete.parent_executor_id - ? { ...e, profiles: (e.profiles ?? []).filter((p) => p.id !== profileToDelete.id) } - : e, - ), - ); + removeExecutorProfile(profileToDelete.parent_executor_id, profileToDelete.id); setDeleteProfileId(null); } finally { setDeleting(false); diff --git a/apps/web/app/settings/executors/ssh/[executorId]/page.tsx b/apps/web/app/settings/executors/ssh/[executorId]/page.tsx index 226d24b82e..be4dc9ead6 100644 --- a/apps/web/app/settings/executors/ssh/[executorId]/page.tsx +++ b/apps/web/app/settings/executors/ssh/[executorId]/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 { IconTerminal2 } from "@tabler/icons-react"; -import { useAppStoreApi } from "@/components/state-provider"; +import { useExecutorsQuerySync } from "@/hooks/domains/settings/use-executors-query-sync"; 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"; @@ -18,7 +18,6 @@ import { buildSSHExecutorConfig, parseSSHExecutorConfig, } from "@/app/settings/executors/new/[type]/ssh-config"; -import type { Executor } from "@/lib/types/http"; const EXECUTORS_ROUTE = "/settings/executors"; @@ -168,32 +167,32 @@ function useRunningSessionCount(executorId: string): number { } function useSaveExecutor(executorId: string, onSaved: () => void | Promise) { - const store = useAppStoreApi(); + const { setExecutors, upsertExecutor } = useExecutorsQuerySync(); 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 query cache so the executor list reflects the new name + config. try { const fresh = await listExecutors(); - store.getState().setExecutors(fresh.executors); + setExecutors(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, - ), - ); + // Non-fatal: the local view still reloads via onSaved(). + const now = new Date().toISOString(); + upsertExecutor({ + id: executorId, + name: cfg.name, + type: "ssh", + status: "active", + is_system: false, + config, + created_at: now, + updated_at: now, + }); } await onSaved(); }, - [executorId, store, onSaved], + [executorId, onSaved, setExecutors, upsertExecutor], ); } diff --git a/apps/web/app/settings/general/editors/page.tsx b/apps/web/app/settings/general/editors/page.tsx index 6aefe42b9e..3c978001fd 100644 --- a/apps/web/app/settings/general/editors/page.tsx +++ b/apps/web/app/settings/general/editors/page.tsx @@ -1,5 +1,5 @@ import { EditorsSettings } from "@/components/settings/editors-settings"; -import { StateProvider } from "@/components/state-provider"; +import { StateHydrator } from "@/components/state-hydrator"; import { fetchUserSettings, listEditors } from "@/lib/api"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; @@ -24,8 +24,9 @@ export default async function GeneralEditorsPage() { } return ( - + <> + - + ); } diff --git a/apps/web/app/settings/general/notifications/page.tsx b/apps/web/app/settings/general/notifications/page.tsx index b09f2284c8..e4b3ecae7b 100644 --- a/apps/web/app/settings/general/notifications/page.tsx +++ b/apps/web/app/settings/general/notifications/page.tsx @@ -1,5 +1,5 @@ import { NotificationsSettings } from "@/components/settings/notifications-settings"; -import { StateProvider } from "@/components/state-provider"; +import { StateHydrator } from "@/components/state-hydrator"; import { listNotificationProviders } from "@/lib/api"; export default async function GeneralNotificationsPage() { @@ -20,8 +20,9 @@ export default async function GeneralNotificationsPage() { } return ( - + <> + - + ); } diff --git a/apps/web/app/settings/general/secrets/page.tsx b/apps/web/app/settings/general/secrets/page.tsx index b952e39a1a..ee5fecd8b5 100644 --- a/apps/web/app/settings/general/secrets/page.tsx +++ b/apps/web/app/settings/general/secrets/page.tsx @@ -1,5 +1,5 @@ import { SecretsSettings } from "@/components/settings/secrets-settings"; -import { StateProvider } from "@/components/state-provider"; +import { StateHydrator } from "@/components/state-hydrator"; import { listSecrets } from "@/lib/api/domains/secrets-api"; export default async function GeneralSecretsPage() { @@ -18,8 +18,9 @@ export default async function GeneralSecretsPage() { } return ( - + <> + - + ); } diff --git a/apps/web/app/settings/general/sprites/page.tsx b/apps/web/app/settings/general/sprites/page.tsx index 100bfbda57..824805a409 100644 --- a/apps/web/app/settings/general/sprites/page.tsx +++ b/apps/web/app/settings/general/sprites/page.tsx @@ -1,5 +1,5 @@ import { SpritesSettings } from "@/components/settings/sprites-settings"; -import { StateProvider } from "@/components/state-provider"; +import { StateHydrator } from "@/components/state-hydrator"; import { getSpritesStatus, listSpritesInstances } from "@/lib/api/domains/sprites-api"; export default async function GeneralSpritesPage() { @@ -22,8 +22,9 @@ export default async function GeneralSpritesPage() { } return ( - + <> + - + ); } diff --git a/apps/web/app/settings/integrations/github/page.tsx b/apps/web/app/settings/integrations/github/page.tsx index fba2c2d7de..1257d308f1 100644 --- a/apps/web/app/settings/integrations/github/page.tsx +++ b/apps/web/app/settings/integrations/github/page.tsx @@ -1,5 +1,4 @@ import { GitHubIntegrationPage } from "@/components/github/github-settings"; -import { StateHydrator } from "@/components/state-hydrator"; import { fetchGitHubStatus } from "@/lib/api/domains/github-api"; type IntegrationsGitHubPageProps = { @@ -10,19 +9,5 @@ export default async function IntegrationsGitHubPage({ workspaceId, }: IntegrationsGitHubPageProps = {}) { const status = await fetchGitHubStatus({ cache: "no-store" }).catch(() => null); - const initialState = status - ? { - githubStatus: { - status, - loaded: true, - loading: false, - }, - } - : {}; - return ( - <> - - - - ); + return ; } diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx index 28aa3aeab7..b29a1f4fe3 100644 --- a/apps/web/app/settings/layout.tsx +++ b/apps/web/app/settings/layout.tsx @@ -15,7 +15,6 @@ import { } from "@/lib/routing/route-bootstrap"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { readCookies } from "@/lib/server/cookies"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; export default function SettingsLayout({ children }: { children: React.ReactNode }) { return {children}; @@ -59,11 +58,6 @@ async function SettingsLayoutServer({ children }: { children: React.ReactNode }) executors: { items: executors.executors, }, - agentProfiles: { - items: agents.agents.flatMap((agent) => - agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), - ), - }, settingsAgents: { items: agents.agents, }, @@ -78,10 +72,6 @@ async function SettingsLayoutServer({ children }: { children: React.ReactNode }) loading: false, loaded: true, }, - settingsData: { - executorsLoaded: true, - agentsLoaded: true, - }, ...(mappedUserSettings.loaded ? { userSettings: { diff --git a/apps/web/app/settings/prompts/page.tsx b/apps/web/app/settings/prompts/page.tsx index 0964eb0bc4..1cdbc877ff 100644 --- a/apps/web/app/settings/prompts/page.tsx +++ b/apps/web/app/settings/prompts/page.tsx @@ -1,5 +1,5 @@ import { PromptsSettings } from "@/components/settings/prompts-settings"; -import { StateProvider } from "@/components/state-provider"; +import { StateHydrator } from "@/components/state-hydrator"; import { listPrompts } from "@/lib/api"; export default async function PromptsSettingsPage() { @@ -18,8 +18,9 @@ export default async function PromptsSettingsPage() { } return ( - + <> + - + ); } diff --git a/apps/web/app/settings/workspace/workspace-edit-client.tsx b/apps/web/app/settings/workspace/workspace-edit-client.tsx index 35fadf2dd3..322fb15c78 100644 --- a/apps/web/app/settings/workspace/workspace-edit-client.tsx +++ b/apps/web/app/settings/workspace/workspace-edit-client.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import Link from "@/components/routing/app-link"; import { useRouter } from "@/lib/routing/client-router"; import { IconGitBranch, IconLayoutColumns, IconTrash } from "@tabler/icons-react"; @@ -19,23 +20,22 @@ import { DialogTitle, } from "@kandev/ui/dialog"; 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/state/slices"; import { useRequest } from "@/lib/http/use-request"; import { useToast } from "@/components/toast-provider"; -import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator"; +import { patchWorkspaceCache, removeWorkspaceCache } from "@/lib/query/workspace-cache"; type WorkspaceEditClientProps = { workspaceId: string; }; export function WorkspaceEditClient({ workspaceId }: WorkspaceEditClientProps) { - const workspace = useAppStore( - (state) => state.workspaces.items.find((item: Workspace) => item.id === workspaceId) ?? null, - ); + const { items } = useWorkspaces(); + const workspace = items.find((item) => item.id === workspaceId) ?? null; if (!workspace) { return ( @@ -336,8 +336,7 @@ type WorkspaceSaveHandlerOptions = { isDirty: boolean; setSavedState: (s: SavedState) => void; setCurrentWorkspace: (fn: (prev: Workspace) => Workspace) => void; - workspaces: Workspace[]; - setWorkspaces: (items: Workspace[]) => void; + queryClient: QueryClient; saveWorkspaceRequest: SaveRequestLike; toast: ReturnType["toast"]; }; @@ -349,8 +348,7 @@ function buildSaveHandler({ isDirty, setSavedState, setCurrentWorkspace, - workspaces, - setWorkspaces, + queryClient, saveWorkspaceRequest, toast, }: WorkspaceSaveHandlerOptions) { @@ -372,19 +370,7 @@ 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, - ), - ); + patchWorkspaceCache(queryClient, updated.id, updated); } catch (error) { toast({ title: "Failed to save workspace", @@ -398,6 +384,7 @@ function buildSaveHandler({ function useWorkspaceEditForm(workspace: Workspace) { const router = useRouter(); const { toast } = useToast(); + const queryClient = useQueryClient(); const [currentWorkspace, setCurrentWorkspace] = useState(workspace); const [workspaceNameDraft, setWorkspaceNameDraft] = useState(workspace.name ?? ""); const [defaultExecutorId, setDefaultExecutorId] = useState(workspace.default_executor_id ?? ""); @@ -412,10 +399,7 @@ 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, agentProfiles } = useSettingsData(true); const saveWorkspaceRequest = useRequest(updateWorkspaceAction); const deleteWorkspaceRequest = useRequest(deleteWorkspaceAction); @@ -433,8 +417,7 @@ function useWorkspaceEditForm(workspace: Workspace) { isDirty, setSavedState, setCurrentWorkspace, - workspaces, - setWorkspaces, + queryClient, saveWorkspaceRequest, toast, }); @@ -443,7 +426,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)); + removeWorkspaceCache(queryClient, currentWorkspace.id); 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 7b0b169210..7c5bf73a37 100644 --- a/apps/web/app/settings/workspace/workspace-repositories-client.tsx +++ b/apps/web/app/settings/workspace/workspace-repositories-client.tsx @@ -1,6 +1,7 @@ "use client"; -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import Link from "@/components/routing/app-link"; import { useRouter } from "@/lib/routing/client-router"; import { IconGitBranch } from "@tabler/icons-react"; @@ -28,7 +29,7 @@ import { } from "@/lib/types/http"; 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, @@ -88,7 +89,7 @@ type RepoHandlerArgs = { setRepositoryItems: React.Dispatch>; setSavedRepositoryItems: React.Dispatch>; savedRepositoriesById: Map; - clearRepositoryScripts: (id: string) => void; + syncRepositoryScripts: (id: string, scripts: RepositoryScript[]) => void; }; async function saveNewRepository( @@ -135,7 +136,7 @@ type SaveExistingArgs = { repo: RepositoryItem; repoId: string; savedRepositoriesById: Map; - clearRepositoryScripts: (id: string) => void; + syncRepositoryScripts: (id: string, scripts: RepositoryScript[]) => void; setRepositoryItems: React.Dispatch>; setSavedRepositoryItems: React.Dispatch>; }; @@ -144,7 +145,7 @@ async function saveExistingRepository({ repo, repoId, savedRepositoriesById, - clearRepositoryScripts, + syncRepositoryScripts, setRepositoryItems, setSavedRepositoryItems, }: SaveExistingArgs) { @@ -195,7 +196,7 @@ async function saveExistingRepository({ ? prev.map((item) => (item.id === repoId ? cloneRepository(nextRepo) : item)) : [...prev, cloneRepository(nextRepo)], ); - clearRepositoryScripts(repoId); + syncRepositoryScripts(repoId, nextScripts); } function useRepositoryHandlers({ @@ -204,7 +205,7 @@ function useRepositoryHandlers({ setRepositoryItems, setSavedRepositoryItems, savedRepositoriesById, - clearRepositoryScripts, + syncRepositoryScripts, }: RepoHandlerArgs) { const handleUpdateRepository = (repoId: string, updates: Partial) => { setRepositoryItems((prev) => @@ -265,7 +266,7 @@ function useRepositoryHandlers({ repo, repoId, savedRepositoriesById, - clearRepositoryScripts, + syncRepositoryScripts, setRepositoryItems, setSavedRepositoryItems, }); @@ -400,7 +401,13 @@ function useWorkspaceRepositoriesPage( ) { const router = useRouter(); const { toast } = useToast(); - const clearRepositoryScripts = useAppStore((state) => state.clearRepositoryScripts); + const queryClient = useQueryClient(); + const syncRepositoryScripts = useCallback( + (repoId: string, scripts: RepositoryScript[]) => { + queryClient.setQueryData(qk.workspaces.repositoryScripts(repoId), scripts); + }, + [queryClient], + ); const [repositoryItems, setRepositoryItems] = useState(repositories); const [savedRepositoryItems, setSavedRepositoryItems] = useState(repositories); @@ -415,7 +422,7 @@ function useWorkspaceRepositoriesPage( setRepositoryItems, setSavedRepositoryItems, savedRepositoriesById, - clearRepositoryScripts, + syncRepositoryScripts, }); const { handleUpdateRepository, diff --git a/apps/web/app/settings/workspace/workspaces-page-client.tsx b/apps/web/app/settings/workspace/workspaces-page-client.tsx index 84e2f48573..406e64c1cb 100644 --- a/apps/web/app/settings/workspace/workspaces-page-client.tsx +++ b/apps/web/app/settings/workspace/workspaces-page-client.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import Link from "@/components/routing/app-link"; import { IconFolder, IconPlus, IconChevronRight } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -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 { upsertWorkspaceCache } from "@/lib/query/workspace-cache"; +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 { items } = useWorkspaces(); + const queryClient = useQueryClient(); const [isAdding, setIsAdding] = useState(false); const [newWorkspaceName, setNewWorkspaceName] = useState(""); const createRequest = useRequest(createWorkspaceAction); @@ -104,30 +104,7 @@ export function WorkspacesPageClient() { 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, - })), - ]); + upsertWorkspaceCache(queryClient, created); setNewWorkspaceName(""); setIsAdding(false); } catch (error) { diff --git a/apps/web/app/tasks/page.tsx b/apps/web/app/tasks/page.tsx index 87f6bb5a12..da11de0a04 100644 --- a/apps/web/app/tasks/page.tsx +++ b/apps/web/app/tasks/page.tsx @@ -8,6 +8,7 @@ import { fetchUserSettings } from "@/lib/api"; import { StateHydrator } from "@/components/state-hydrator"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; +import type { QuerySeedInitialState } from "@/lib/query/seed"; import { TasksPageClient } from "./tasks-page-client"; import { parseTasksListGroup, @@ -16,7 +17,6 @@ import { type TasksListSort, } from "@/lib/tasks/tasks-list-options"; import type { Workflow, Task, Repository, Workspace, UserSettingsResponse } from "@/lib/types/http"; -import type { AppState } from "@/lib/state/store"; type WorkspaceData = { workflows: Workflow[]; @@ -136,20 +136,12 @@ export default async function TasksPage({ const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); - const initialState: Partial = { + const initialState: QuerySeedInitialState = { workspaces: { items: workspaces, activeId: workspaceId ?? null, }, workflows: { - items: workflows.map((w) => ({ - id: w.id, - workspaceId: w.workspace_id, - name: w.name, - description: w.description ?? null, - sortOrder: w.sort_order ?? 0, - ...(w.agent_profile_id ? { agent_profile_id: w.agent_profile_id } : {}), - })), activeId: activeWorkflowId, }, userSettings: { @@ -158,12 +150,17 @@ export default async function TasksPage({ tasksListSort, tasksListGroup, }, + ...(workspaceId + ? { + workflowLists: { + itemsByWorkspaceId: { [workspaceId]: workflows }, + }, + } + : {}), ...(workspaceId ? { repositories: { itemsByWorkspaceId: { [workspaceId]: repositories }, - loadingByWorkspaceId: { [workspaceId]: false }, - loadedByWorkspaceId: { [workspaceId]: true }, }, } : {}), diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index cad2bc0e42..76cc4d9874 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -1,9 +1,10 @@ "use client"; -import { useState, useCallback, useMemo, useEffect, useRef } from "react"; +import { useState, useCallback, useMemo, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useRouter, useSearchParams } from "@/lib/routing/client-router"; import type { PaginationState } from "@tanstack/react-table"; -import { archiveTask, deleteTask, listTasksByWorkspace, updateUserSettings } from "@/lib/api"; +import { archiveTask, deleteTask, updateUserSettings } from "@/lib/api"; import { KanbanHeader } from "@/components/kanban/kanban-header"; import { MobileSearchBar } from "@/components/kanban/mobile-search-bar"; import type { Task, Workspace, Workflow, Repository } from "@/lib/types/http"; @@ -13,7 +14,7 @@ import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { useDebounce } from "@/hooks/use-debounce"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { linkToTask } from "@/lib/links"; -import { shouldSkipInitialTasksFetch } from "./tasks-page-fetch-policy"; +import { workspaceTasksQueryOptions } from "@/lib/query/query-options"; import { TasksListView } from "./tasks-list-view"; import { parseTasksListGroup, @@ -47,27 +48,8 @@ type UseTaskOperationsParams = { setTotal: (total: number) => void; }; -function useLatestWorkspaceRequest(activeWorkspaceId: string | null) { - const latestFetchRef = useRef({ seq: 0, workspaceId: activeWorkspaceId }); - - useEffect(() => { - latestFetchRef.current.workspaceId = activeWorkspaceId; - }, [activeWorkspaceId]); - - const beginRequest = useCallback((workspaceId: string) => { - const seq = latestFetchRef.current.seq + 1; - latestFetchRef.current = { seq, workspaceId }; - return seq; - }, []); - - const isCurrentRequest = useCallback( - (seq: number, workspaceId: string) => - latestFetchRef.current.seq === seq && latestFetchRef.current.workspaceId === workspaceId, - [], - ); - - return { beginRequest, isCurrentRequest }; -} +const LOAD_TASKS_ERROR_TITLE = "Failed to load tasks"; +const UNKNOWN_ERROR = "Unknown error"; function useTaskOperations({ activeWorkspaceId, @@ -81,53 +63,48 @@ function useTaskOperations({ setTotal, }: UseTaskOperationsParams) { const { toast } = useToast(); - const [isLoading, setIsLoading] = useState(false); const [deletingTaskId, setDeletingTaskId] = useState(null); - const { beginRequest, isCurrentRequest } = useLatestWorkspaceRequest(activeWorkspaceId); + const taskQuery = useQuery({ + ...workspaceTasksQueryOptions(activeWorkspaceId ?? "", { + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + query: debouncedQuery, + includeArchived: showArchived, + workflowId: activeWorkflowId, + repositoryId: selectedRepositoryId, + sort: tasksListSort, + }), + enabled: Boolean(activeWorkspaceId), + }); + + useEffect(() => { + if (!taskQuery.data) return; + setTasks(taskQuery.data.tasks); + setTotal(taskQuery.data.total); + }, [setTasks, setTotal, taskQuery.data]); + + useEffect(() => { + if (!taskQuery.error) return; + const error = taskQuery.error; + toast({ + title: LOAD_TASKS_ERROR_TITLE, + description: error instanceof Error ? error.message : UNKNOWN_ERROR, + variant: "error", + }); + }, [taskQuery.error, toast]); const fetchTasks = useCallback(async () => { if (!activeWorkspaceId) return; - const requestSeq = beginRequest(activeWorkspaceId); - const shouldCommit = () => isCurrentRequest(requestSeq, activeWorkspaceId); - setIsLoading(true); - try { - const result = await listTasksByWorkspace(activeWorkspaceId, { - page: pagination.pageIndex + 1, - pageSize: pagination.pageSize, - query: debouncedQuery, - includeArchived: showArchived, - workflowId: activeWorkflowId, - repositoryId: selectedRepositoryId, - sort: tasksListSort, - }); - if (!shouldCommit()) return; - setTasks(result.tasks); - setTotal(result.total); - } catch (err) { - if (!shouldCommit()) return; + const result = await taskQuery.refetch(); + if (result.error) { + const err = result.error; toast({ - title: "Failed to load tasks", - description: err instanceof Error ? err.message : "Unknown error", + title: LOAD_TASKS_ERROR_TITLE, + description: err instanceof Error ? err.message : UNKNOWN_ERROR, variant: "error", }); - } finally { - if (shouldCommit()) setIsLoading(false); } - }, [ - activeWorkspaceId, - activeWorkflowId, - selectedRepositoryId, - pagination.pageIndex, - pagination.pageSize, - debouncedQuery, - showArchived, - tasksListSort, - beginRequest, - isCurrentRequest, - toast, - setTasks, - setTotal, - ]); + }, [activeWorkspaceId, taskQuery, toast]); const handleArchive = useCallback( async (taskId: string, opts?: { cascade?: boolean }) => { @@ -138,7 +115,7 @@ function useTaskOperations({ } catch (err) { toast({ title: "Failed to archive task", - description: err instanceof Error ? err.message : "Unknown error", + description: err instanceof Error ? err.message : UNKNOWN_ERROR, variant: "error", }); } @@ -155,7 +132,7 @@ function useTaskOperations({ } catch (err) { toast({ title: "Failed to delete task", - description: err instanceof Error ? err.message : "Unknown error", + description: err instanceof Error ? err.message : UNKNOWN_ERROR, variant: "error", }); } finally { @@ -165,7 +142,13 @@ function useTaskOperations({ [fetchTasks, toast], ); - return { isLoading, deletingTaskId, fetchTasks, handleArchive, handleDelete }; + return { + isLoading: taskQuery.isFetching, + deletingTaskId, + fetchTasks, + handleArchive, + handleDelete, + }; } function useTasksPageViewState({ @@ -222,53 +205,17 @@ function useTasksPageViewState({ function useTasksPageEffects({ debouncedQuery, setPagination, - activeWorkspaceId, - fetchTasks, - pagination, - showArchived, activeWorkflowId, selectedRepositoryId, - initialDataLoaded = false, }: { debouncedQuery: string; setPagination: (next: PaginationState | ((prev: PaginationState) => PaginationState)) => void; - activeWorkspaceId: string | null; - fetchTasks: () => void; - pagination: PaginationState; - showArchived: boolean; activeWorkflowId: string | null; selectedRepositoryId: string | null; - initialDataLoaded?: boolean; }) { - const skippedInitialFetchRef = useRef(false); - useEffect(() => { void Promise.resolve().then(() => setPagination((prev) => ({ ...prev, pageIndex: 0 }))); }, [debouncedQuery, activeWorkflowId, selectedRepositoryId, setPagination]); - - useEffect(() => { - if ( - shouldSkipInitialTasksFetch({ - hasInitialData: initialDataLoaded, - alreadySkipped: skippedInitialFetchRef.current, - pageIndex: pagination.pageIndex, - debouncedQuery, - showArchived, - }) - ) { - skippedInitialFetchRef.current = true; - return; - } - if (activeWorkspaceId) fetchTasks(); - }, [ - activeWorkspaceId, - pagination.pageIndex, - pagination.pageSize, - debouncedQuery, - showArchived, - fetchTasks, - initialDataLoaded, - ]); } function useTasksPageComputed({ @@ -326,13 +273,8 @@ function useTasksPageSetup(props: TasksPageClientProps) { useTasksPageEffects({ debouncedQuery, setPagination: viewState.setPagination, - activeWorkspaceId, - fetchTasks: ops.fetchTasks, - pagination: viewState.pagination, - showArchived: viewState.showArchived, activeWorkflowId, selectedRepositoryId, - initialDataLoaded: props.initialDataLoaded, }); const computed = useTasksPageComputed({ total: viewState.total, diff --git a/apps/web/components/agent/cli-profile-editor.test.tsx b/apps/web/components/agent/cli-profile-editor.test.tsx index 342b037474..26bfed233e 100644 --- a/apps/web/components/agent/cli-profile-editor.test.tsx +++ b/apps/web/components/agent/cli-profile-editor.test.tsx @@ -1,6 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, fireEvent, waitFor } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { StateProvider } from "@/components/state-provider"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import { CliProfileEditor } from "./cli-profile-editor"; import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; @@ -47,6 +51,21 @@ const baseAvailableAgent = { updated_at: UPDATED_AT, }; +function renderWithSettings(element: ReactElement, availableAgents = [baseAvailableAgent]) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.settings.agents(), { agents: [], total: 0 }); + queryClient.setQueryData(qk.settings.availableAgents(), { + agents: availableAgents, + tools: [], + total: availableAgents.length, + }); + return render( + + {element} + , + ); +} + const agentWithRecommendedFlag = { ...baseAvailableAgent, name: "copilot", @@ -66,20 +85,8 @@ const agentWithRecommendedFlag = { describe("CliProfileEditor", () => { it("renders the create form with the CLI client picker, profile name, and model selector", () => { - render( - - - , + renderWithSettings( + , ); expect(screen.getByLabelText("Profile name")).toBeTruthy(); @@ -101,21 +108,7 @@ describe("CliProfileEditor", () => { createdAt: CREATED_AT, updatedAt: CREATED_AT, }; - render( - - - , - ); + renderWithSettings(); const nameInput = screen.getByLabelText("Profile name") as HTMLInputElement; expect(nameInput.value).toBe("default"); @@ -124,21 +117,7 @@ describe("CliProfileEditor", () => { it("invokes onCancel when the Cancel button is clicked", () => { const onCancel = vi.fn(); - render( - - - , - ); + renderWithSettings(); fireEvent.click(screen.getByText("Cancel")); expect(onCancel).toHaveBeenCalled(); @@ -147,25 +126,9 @@ describe("CliProfileEditor", () => { describe("CliProfileEditor recommended flags", () => { it("can hide passthrough while showing recommended CLI flags", () => { - render( - - - , + renderWithSettings( + , + [agentWithRecommendedFlag], ); expect(screen.queryByText("CLI passthrough")).toBeNull(); @@ -191,20 +154,9 @@ describe("CliProfileEditor recommended flags", () => { ], }); - render( - - - , + renderWithSettings( + , + [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 1cfb57d404..bf18d7e6eb 100644 --- a/apps/web/components/agent/cli-profile-editor.tsx +++ b/apps/web/components/agent/cli-profile-editor.tsx @@ -6,8 +6,8 @@ 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 { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { ModelCombobox } from "@/components/settings/model-combobox"; import { ModeCombobox } from "@/components/settings/mode-combobox"; import { CLIFlagsField } from "@/components/settings/cli-flags-field"; @@ -112,7 +112,7 @@ export function CliProfileEditor({ allowCliPassthrough = true, }: CliProfileEditorProps) { const availableAgents = useAvailableAgents(); - const settingsAgents = useAppStore((s) => s.settingsAgents.items); + const { settingsAgents } = useSettingsData(true); const installed = useMemo( () => availableAgents.items.filter((a) => a.available), [availableAgents.items], diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx index dc22b26cc0..b576bdb625 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx @@ -10,17 +10,17 @@ const mocks = vi.hoisted(() => ({ const state = { workspaces: { activeId: "kanban-1" as string | null, - items: [ - { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, - { id: "office-1", name: "Office", office_workflow_id: "wf-office" }, - { id: "office-2", name: "Office 2", office_workflow_id: "wf-office-2" }, - ], }, appSidebar: { settingsMode: false }, toggleAppSidebarSettingsMode: mocks.toggleSettingsMode, }; let officeEnabled = false; +let workspaceItems = [ + { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, + { id: "office-1", name: "Office", office_workflow_id: "wf-office" }, + { id: "office-2", name: "Office 2", office_workflow_id: "wf-office-2" }, +]; vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ push: mocks.routerPush }), @@ -34,6 +34,14 @@ vi.mock("@/hooks/domains/features/use-feature", () => ({ useFeature: () => officeEnabled, })); +vi.mock("@/hooks/domains/workspace/use-workspaces", () => ({ + useWorkspaces: () => ({ + items: workspaceItems, + activeId: state.workspaces.activeId, + activeWorkspace: workspaceItems.find((workspace) => workspace.id === state.workspaces.activeId), + }), +})); + vi.mock("@/hooks/use-release-notes", () => ({ useReleaseNotes: () => ({ unseenEntries: [], @@ -73,7 +81,7 @@ describe("AppSidebarFooter", () => { beforeEach(() => { officeEnabled = false; state.workspaces.activeId = "kanban-1"; - state.workspaces.items = [ + workspaceItems = [ { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, { id: "office-1", name: "Office", office_workflow_id: "wf-office" }, { id: "office-2", name: "Office 2", office_workflow_id: "wf-office-2" }, @@ -129,7 +137,7 @@ describe("AppSidebarFooter", () => { it("navigates to office setup when no office workspace exists", () => { officeEnabled = true; - state.workspaces.items = [{ id: "kanban-1", name: "Kanban", office_workflow_id: "" }]; + workspaceItems = [{ id: "kanban-1", name: "Kanban", office_workflow_id: "" }]; renderFooter(); diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index 1df28655b5..c00e535f0a 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -17,6 +17,7 @@ import { ImproveKandevDialog } from "@/components/improve-kandev-dialog"; import { ReleaseNotesDialog } from "@/components/release-notes/release-notes-dialog"; import { useAppStore } from "@/components/state-provider"; import { useFeature } from "@/hooks/domains/features/use-feature"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useReleaseNotes } from "@/hooks/use-release-notes"; import { ThemeToggle } from "@/components/theme-toggle"; import { linkToTask } from "@/lib/links"; @@ -97,13 +98,12 @@ function FooterIconButton({ export function AppSidebarFooter({ collapsed }: AppSidebarFooterProps) { const router = useRouter(); - const workspaces = useAppStore((s) => s.workspaces); - const workspaceId = workspaces.activeId; - const activeWorkspace = workspaces.items.find((workspace) => workspace.id === workspaceId); + const { items: workspaceItems, activeId: workspaceId } = useWorkspaces(); + const activeWorkspace = workspaceItems.find((workspace) => workspace.id === workspaceId); const activeIsOffice = isOfficeWorkspace(activeWorkspace); const targetWorkspace = activeIsOffice - ? resolveLastKanbanWorkspace(workspaces.items) - : resolveLastOfficeWorkspace(workspaces.items); + ? resolveLastKanbanWorkspace(workspaceItems) + : resolveLastOfficeWorkspace(workspaceItems); const settingsMode = useAppStore((s) => s.appSidebar.settingsMode); const toggleSettingsMode = useAppStore((s) => s.toggleAppSidebarSettingsMode); const officeEnabled = useFeature("office"); diff --git a/apps/web/components/app-sidebar/app-sidebar-header.test.tsx b/apps/web/components/app-sidebar/app-sidebar-header.test.tsx index 29c57dc084..2ab6d27b18 100644 --- a/apps/web/components/app-sidebar/app-sidebar-header.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-header.test.tsx @@ -1,15 +1,16 @@ import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; const state = { workspaces: { activeId: "kanban-1" as string | null, - items: [ - { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, - { id: "office-1", name: "Office", office_workflow_id: "wf-office" }, - ], }, + setActiveWorkspace: vi.fn(), + setActiveWorkflow: vi.fn(), }; vi.mock("@/components/state-provider", () => ({ @@ -22,11 +23,21 @@ vi.mock("./app-sidebar-workspace-picker", () => ({ import { AppSidebarHeader } from "./app-sidebar-header"; +const workspaces = [ + { id: "kanban-1", name: "Kanban", office_workflow_id: "" }, + { id: "office-1", name: "Office", office_workflow_id: "wf-office" }, +]; + function renderHeader(collapsed = false) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workspaces.all(), workspaces); + return render( - - - , + + + + + , ); } diff --git a/apps/web/components/app-sidebar/app-sidebar-header.tsx b/apps/web/components/app-sidebar/app-sidebar-header.tsx index 4531a3e6ed..0e362f06ff 100644 --- a/apps/web/components/app-sidebar/app-sidebar-header.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-header.tsx @@ -4,7 +4,7 @@ import Link from "@/components/routing/app-link"; import { IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand } from "@tabler/icons-react"; 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"; import { cn } from "@/lib/utils"; import { workspaceHomeHref } from "./app-sidebar-workspace-navigation"; import { AppSidebarWorkspacePicker } from "./app-sidebar-workspace-picker"; @@ -17,11 +17,8 @@ type AppSidebarHeaderProps = { const COLLAPSE_BUTTON_CLASS = "h-7 w-7 shrink-0 cursor-pointer"; export function AppSidebarHeader({ collapsed, onToggleCollapse }: AppSidebarHeaderProps) { - const workspaces = useAppStore((s) => s.workspaces); - const activeWorkspace = workspaces.items.find( - (workspace) => workspace.id === workspaces.activeId, - ); - const homeHref = workspaceHomeHref(activeWorkspace); + const { activeWorkspace } = useWorkspaces(); + const homeHref = workspaceHomeHref(activeWorkspace ?? undefined); if (collapsed) { // Minimal rail: brand home + expand. The workspace switcher lives only in diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx index e282066246..916b7de6f8 100644 --- a/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.test.tsx @@ -9,6 +9,16 @@ const mocks = vi.hoisted(() => ({ openQuickChat: vi.fn(), dialogTaskSessionId: null as string | null, dialogWillNavigate: false, + lastRegularDialogProps: null as null | { + workflowId: string | null; + defaultStepId: string | null; + steps: Array<{ id: string; title: string }>; + }, + workflowSnapshotWorkflowId: null as string | null, + workflowSnapshotState: { + steps: [{ id: "s1", title: "Todo" }], + } as { steps: Array<{ id: string; title: string }> } | null, + taskById: null as { id: string; title: string } | null, })); function renderItem(collapsed: boolean) { @@ -21,11 +31,7 @@ function renderItem(collapsed: boolean) { const state = { workspaces: { activeId: "ws-1" as string | null }, - kanban: { - workflowId: "wf-1" as string | null, - steps: [{ id: "s1", title: "Todo" }], - tasks: [{ id: "t-1", title: "Parent task" }] as Array<{ id: string; title: string }>, - }, + workflows: { activeId: "wf-1" as string | null }, tasks: { activeTaskId: null as string | null }, setActiveTask: mocks.setActiveTask, setActiveSession: mocks.setActiveSession, @@ -36,6 +42,15 @@ let pathname = "/"; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), })); +vi.mock("@/hooks/use-workflow-snapshot", () => ({ + useWorkflowSnapshot: (workflowId: string | null) => { + mocks.workflowSnapshotWorkflowId = workflowId; + return { snapshotState: mocks.workflowSnapshotState }; + }, +})); +vi.mock("@/hooks/domains/kanban/use-task-by-id", () => ({ + useTaskById: () => mocks.taskById, +})); vi.mock("@/hooks/use-quick-chat-launcher", () => ({ useQuickChatLauncher: () => mocks.openQuickChat, })); @@ -51,30 +66,41 @@ vi.mock("@/app/office/components/new-task-dialog", () => ({ })); vi.mock("@/components/task-create-dialog", () => ({ TaskCreateDialog: ({ + workflowId, + defaultStepId, + steps, onSuccess, }: { + workflowId: string | null; + defaultStepId: string | null; + steps: Array<{ id: string; title: string }>; onSuccess?: ( task: { id: string }, mode: "create" | "edit", meta?: { taskSessionId?: string | null; willNavigate?: boolean }, ) => void; - }) => ( - - ), + }) => { + mocks.lastRegularDialogProps = { workflowId, defaultStepId, steps }; + return ( + + ); + }, })); vi.mock("@/components/task/new-subtask-dialog", () => ({ - NewSubtaskDialog: () =>
, + NewSubtaskDialog: ({ parentTaskTitle }: { parentTaskTitle: string }) => ( +
+ ), })); import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; @@ -85,9 +111,7 @@ const REGULAR_DIALOG_TESTID = "regular-task-create-dialog"; function resetTestState() { state.workspaces.activeId = "ws-1"; - state.kanban.workflowId = "wf-1"; - state.kanban.steps = [{ id: "s1", title: "Todo" }]; - state.kanban.tasks = [{ id: "t-1", title: "Parent task" }]; + state.workflows.activeId = "wf-1"; state.tasks.activeTaskId = null; mocks.routerPush.mockClear(); mocks.setActiveTask.mockClear(); @@ -95,6 +119,10 @@ function resetTestState() { mocks.openQuickChat.mockClear(); mocks.dialogTaskSessionId = null; mocks.dialogWillNavigate = false; + mocks.lastRegularDialogProps = null; + mocks.workflowSnapshotWorkflowId = null; + mocks.workflowSnapshotState = { steps: [{ id: "s1", title: "Todo" }] }; + mocks.taskById = null; officeEnabled = false; pathname = "/"; } @@ -162,6 +190,15 @@ describe("AppSidebarNewTaskItem row actions", () => { expect(screen.getByTestId("new-subtask-dialog")).toBeTruthy(); }); + it("uses Query task detail for the subtask parent title", () => { + state.tasks.activeTaskId = "t-1"; + mocks.taskById = { id: "t-1", title: "Query parent task" }; + + renderItem(false); + + expect(screen.getByTestId("new-subtask-dialog").dataset.parentTitle).toBe("Query parent task"); + }); + it("hides the subtask affordance when no task is active", () => { state.tasks.activeTaskId = null; renderItem(false); @@ -188,6 +225,20 @@ describe("AppSidebarNewTaskItem row actions", () => { }); describe("AppSidebarNewTaskItem creation success", () => { + it("uses the Query workflow snapshot for regular task-create defaults", () => { + state.workflows.activeId = "wf-query"; + mocks.workflowSnapshotState = { steps: [{ id: "step-query", title: "Ready" }] }; + + renderItem(false); + + expect(mocks.workflowSnapshotWorkflowId).toBe("wf-query"); + expect(mocks.lastRegularDialogProps).toMatchObject({ + workflowId: "wf-query", + defaultStepId: "step-query", + steps: [{ id: "step-query", title: "Ready" }], + }); + }); + it("focuses the created task after regular sidebar task creation succeeds", () => { renderItem(false); screen.getByTestId(REGULAR_DIALOG_TESTID).click(); diff --git a/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx index 2d131b3e46..944a9a8972 100644 --- a/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx @@ -9,6 +9,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; import { useInOffice } from "@/hooks/use-in-office"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { useWorkflowSnapshot } from "@/hooks/use-workflow-snapshot"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { linkToTask } from "@/lib/links"; import type { Task } from "@/lib/types/http"; @@ -56,6 +58,12 @@ function RowActionButton({ icon: Icon, label, testId, onClick }: RowActionButton ); } +function sidebarActionInsetClass(canCreateSubtask: boolean, canOpenQuickChat: boolean) { + if (canCreateSubtask) return TWO_ROW_ACTIONS_INSET_CLASS; + if (canOpenQuickChat) return ONE_ROW_ACTION_INSET_CLASS; + return undefined; +} + /** * "New Task" entry in the sidebar primary nav. Inside Office (an `/office` * route) it opens the richer "New issue" dialog (projects/assignees/stages); @@ -72,16 +80,14 @@ function RowActionButton({ icon: Icon, label, testId, onClick }: RowActionButton export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps) { const router = useRouter(); const workspaceId = useAppStore((s) => s.workspaces.activeId); - const workflowId = useAppStore((s) => s.kanban.workflowId); - const steps = useAppStore((s) => s.kanban.steps); + const workflowId = useAppStore((s) => s.workflows.activeId); + const workflowSnapshot = useWorkflowSnapshot(workflowId); + const steps = workflowSnapshot.snapshotState?.steps ?? []; const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const activeTask = useTaskById(activeTaskId); const setActiveTask = useAppStore((s) => s.setActiveTask); const setActiveSession = useAppStore((s) => s.setActiveSession); - const activeTaskTitle = useAppStore((s) => { - const id = s.tasks.activeTaskId; - if (!id) return ""; - return s.kanban.tasks.find((t) => t.id === id)?.title ?? ""; - }); + const activeTaskTitle = activeTask?.title ?? ""; const inOffice = useInOffice(); const handleOpenQuickChat = useQuickChatLauncher(workspaceId); const [open, setOpen] = useState(false); @@ -93,14 +99,9 @@ export function AppSidebarNewTaskItem({ collapsed }: AppSidebarNewTaskItemProps) // and the expanded rail to host the trailing button. const canOpenQuickChat = !collapsed && !!workspaceId; const canCreateSubtask = !collapsed && !!workspaceId && !!activeTaskId; - let actionInsetClass: string | undefined; // Keep the label clear of the absolute action cluster: // pr-10 covers one w-6 button + right-1.5 inset; pr-16 covers two buttons + gap-1. - if (canCreateSubtask) { - actionInsetClass = TWO_ROW_ACTIONS_INSET_CLASS; - } else if (canOpenQuickChat) { - actionInsetClass = ONE_ROW_ACTION_INSET_CLASS; - } + const actionInsetClass = sidebarActionInsetClass(canCreateSubtask, canOpenQuickChat); const handleRegularTaskCreated = useCallback( ( task: Task, diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx index 49356b8dbd..241a76a67c 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx @@ -1,6 +1,9 @@ import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; const mocks = vi.hoisted(() => ({ openQuickChat: vi.fn(), @@ -8,7 +11,6 @@ const mocks = vi.hoisted(() => ({ const state = { workspaces: { activeId: "ws-1" as string | null }, - office: { inboxCount: 0 }, }; let inOffice = false; let pathname = "/"; @@ -39,17 +41,21 @@ vi.mock("./app-sidebar-new-task-item", () => ({ import { AppSidebarPrimaryNav } from "./app-sidebar-primary-nav"; function renderNav(collapsed: boolean) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.inbox("ws-1"), { items: [], total_count: 0 }); + return render( - - - , + + + + + , ); } describe("AppSidebarPrimaryNav", () => { beforeEach(() => { state.workspaces.activeId = "ws-1"; - state.office.inboxCount = 0; inOffice = false; pathname = "/"; mocks.openQuickChat.mockClear(); diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx index 1bed73a043..6e5f9afb00 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -1,9 +1,11 @@ "use client"; import { IconHome, IconInbox, IconMessageCircle } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { useInOffice } from "@/hooks/use-in-office"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; +import { officeInboxQueryOptions } from "@/lib/query/query-options/office"; import { AppSidebarNavItem } from "./app-sidebar-nav-item"; import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; @@ -13,8 +15,10 @@ type AppSidebarPrimaryNavProps = { export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const inboxCount = useAppStore((s) => s.office.inboxCount); const inOffice = useInOffice(); + const inboxQuery = useQuery(officeInboxQueryOptions(inOffice ? (workspaceId ?? "") : "")); + const inboxItems = inboxQuery.data?.items ?? []; + const inboxCount = inboxQuery.data?.total_count ?? inboxItems.length; const handleOpenQuickChat = useQuickChatLauncher(workspaceId); return ( diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx index 870dc430c8..e0b24b7d87 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx @@ -1,5 +1,9 @@ +import type { ComponentProps } from "react"; import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; const navigationMock = vi.hoisted(() => ({ push: vi.fn() })); @@ -79,6 +83,19 @@ vi.mock("@/components/state-provider", () => ({ import { AppSidebarWorkspacePicker } from "./app-sidebar-workspace-picker"; +type AppSidebarWorkspacePickerProps = ComponentProps; + +function renderPicker(props: AppSidebarWorkspacePickerProps = {}) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.features(), { office: storeState.features.office }); + queryClient.setQueryData(qk.workspaces.all(), storeState.workspaces.items); + return render( + + + , + ); +} + describe("AppSidebarWorkspacePicker — Add workspace routing", () => { beforeEach(() => { navigationMock.push = vi.fn(); @@ -93,7 +110,7 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => { it("routes to the office setup wizard when the office feature is enabled", () => { storeState.features.office = true; - render(); + renderPicker(); fireEvent.click(screen.getByText("New office workspace")); @@ -102,7 +119,7 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => { it("routes to the settings workspaces page for a new kanban workspace", () => { storeState.features.office = true; - render(); + renderPicker(); fireEvent.click(screen.getByText("New kanban workspace")); @@ -111,7 +128,7 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => { it("keeps the legacy add-workspace route when the office feature is disabled", () => { storeState.features.office = false; - render(); + renderPicker(); fireEvent.click(screen.getByText("Add workspace")); @@ -121,7 +138,7 @@ describe("AppSidebarWorkspacePicker — Add workspace routing", () => { it("calls onActionComplete after navigating to add a workspace", () => { const onActionComplete = vi.fn(); storeState.features.office = false; - render(); + renderPicker({ onActionComplete }); fireEvent.click(screen.getByText("Add workspace")); @@ -136,7 +153,7 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { it("does nothing when selecting the already active workspace", () => { storeState.features.office = false; - render(); + renderPicker(); fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM)); @@ -147,7 +164,7 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { it("writes the active office workspace cookie and updates the store on office select", () => { storeState.features.office = false; - render(); + renderPicker(); fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM)); @@ -159,7 +176,7 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { it("calls onActionComplete when selecting a different workspace", () => { const onActionComplete = vi.fn(); storeState.features.office = false; - render(); + renderPicker({ onActionComplete }); fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM)); @@ -170,7 +187,7 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { storeState.features.office = true; storeState.workspaces.activeId = "w2"; cookieWrites = ["office-active-workspace=w2; path=/"]; - render(); + renderPicker(); fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM)); @@ -182,7 +199,7 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { it("navigates to the office workspace when the office feature is enabled", () => { storeState.features.office = true; - render(); + renderPicker(); fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM)); @@ -193,7 +210,7 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { it("navigates to the kanban board when an office user selects a kanban workspace", () => { storeState.features.office = true; storeState.workspaces.activeId = "w2"; - render(); + renderPicker(); fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM)); @@ -210,7 +227,7 @@ describe("AppSidebarWorkspacePicker — active workspace display and routing", ( it("labels workspace types in the menu without using trigger space", () => { storeState.workspaces.activeId = "w1"; - render(); + renderPicker(); expect(screen.getByTestId("sidebar-workspace-trigger").textContent).not.toContain("Kanban"); expect(screen.getByTestId(KANBAN_WORKSPACE_ITEM).textContent).toContain("Kanban"); @@ -218,7 +235,7 @@ describe("AppSidebarWorkspacePicker — active workspace display and routing", ( }); it("renders the trigger as an obvious selector", () => { - render(); + renderPicker(); const trigger = screen.getByTestId("sidebar-workspace-trigger"); expect(trigger.getAttribute("aria-label")).toBe("Switch workspace"); @@ -228,7 +245,7 @@ describe("AppSidebarWorkspacePicker — active workspace display and routing", ( it("routes to the active kanban workspace home when office routing is enabled", () => { storeState.features.office = true; - render(); + renderPicker(); fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM)); @@ -240,7 +257,7 @@ describe("AppSidebarWorkspacePicker — active workspace display and routing", ( it("does not route when selecting the active office workspace", () => { storeState.features.office = true; storeState.workspaces.activeId = "w2"; - render(); + renderPicker(); fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM)); @@ -252,7 +269,7 @@ describe("AppSidebarWorkspacePicker — active workspace display and routing", ( it("calls onActionComplete when re-selecting the active workspace", () => { const onActionComplete = vi.fn(); storeState.features.office = false; - render(); + renderPicker({ onActionComplete }); fireEvent.click(screen.getByTestId(KANBAN_WORKSPACE_ITEM)); diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx index 06c8599d11..a0a926eb4c 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -18,6 +18,7 @@ import { } from "@kandev/ui/dropdown-menu"; import { useAppStore } from "@/components/state-provider"; import { useFeature } from "@/hooks/domains/features/use-feature"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { cn } from "@/lib/utils"; import { rememberLastOfficeWorkspace, @@ -213,11 +214,11 @@ export function AppSidebarWorkspacePicker({ }: WorkspacePickerProps = {}) { const router = useRouter(); const officeEnabled = useFeature("office"); - const workspaces = useAppStore((s) => s.workspaces); + const { items: workspaceItems, activeId: activeWorkspaceId } = useWorkspaces(); const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); const [open, setOpen] = useState(false); - const activeWorkspace = workspaces.items.find((w) => w.id === workspaces.activeId); + const activeWorkspace = workspaceItems.find((w) => w.id === activeWorkspaceId); const activeId = activeWorkspace?.id ?? null; const activeName = activeWorkspace?.name ?? "Workspace"; @@ -271,7 +272,7 @@ export function AppSidebarWorkspacePicker({ ({ push: vi.fn(), })); -const defaultWorkspaceId = workspaceId("workspace-1"); -const staleWorkspaceId = workspaceId("old-workspace"); -const defaultAgentId = "claude"; -const defaultAgentDisplayName = "Claude"; -const defaultAgentModel = "claude-sonnet-4-5"; -const timestamp = "2026-01-01T00:00:00Z"; -type Deferred = { - resolve: (value: T) => void; - promise: Promise; -}; -const createDeferred = () => { - let resolve: (value: T) => void = () => {}; - const promise = new Promise((res) => { - resolve = res; - }); - return { promise, resolve } as Deferred; -}; - const state = { appSidebar: { sectionExpanded: { agents: true, } as Record, }, - office: { - agentProfiles: [] as AgentProfile[], - projects: [] as Array, - inboxItems: [] as Array, - inboxCount: 0, - }, workspaces: { - activeId: defaultWorkspaceId as string | null, + activeId: "workspace-1", }, - setOfficeAgentProfiles: vi.fn(), - setProjects: vi.fn(), - setInboxItems: vi.fn(), - setInboxCount: vi.fn(), toggleAppSidebarSection: vi.fn(), setAppSidebarCollapsed: vi.fn(), sessions: { byId: {}, }, - taskSessions: { - items: {}, - }, }; -const noAgentsText = "No agents yet"; -const createAgentProfile = ({ - id, - workspace, - name, -}: { - id: string; - workspace: string; - name: string; -}): AgentProfile => - ({ - id: agentProfileId(id), - workspaceId: workspaceId(workspace), - name, - role: "worker", - status: "idle", - budgetMonthlyCents: 0, - maxConcurrentSessions: 1, - agentId: defaultAgentId, - agentDisplayName: defaultAgentDisplayName, - model: defaultAgentModel, - allowIndexing: false, - autoApprove: false, - cliFlags: [], - cliPassthrough: false, - createdAt: timestamp, - updatedAt: timestamp, - }) as AgentProfile; - vi.mock("@/lib/routing/client-router", () => ({ usePathname: () => "/office", useRouter: () => routerMock, @@ -94,23 +33,8 @@ vi.mock("@/hooks/use-in-office", () => ({ useInOffice: () => true, })); -vi.mock("@/hooks/use-office-refetch", () => ({ - useOfficeRefetch: vi.fn(), -})); - -vi.mock("@/lib/api/domains/office-api", () => ({ - listAgentProfiles: vi.fn(() => Promise.resolve({ agents: [] })), -})); - vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), - useAppStoreApi: () => ({ - getState: () => ({ - workspaces: { - activeId: state.workspaces.activeId, - }, - }), - }), })); vi.mock("@kandev/ui/collapsible", () => ({ @@ -126,23 +50,26 @@ vi.mock("@kandev/ui/tooltip", () => ({ import { AgentsSection } from "./agents-section"; -const resetOfficeState = () => { - state.office.agentProfiles = []; - state.office.projects = []; - state.office.inboxItems = []; - state.office.inboxCount = 0; - state.workspaces.activeId = defaultWorkspaceId; -}; - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); - resetOfficeState(); -}); +function renderAgentsSection() { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.agents("workspace-1"), { agents: [] }); + queryClient.setQueryData(qk.office.inbox("workspace-1"), { items: [], total_count: 0 }); + + return render( + + + , + ); +} + +describe("AgentsSection", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); -describe("AgentsSection header", () => { it("renders Agent Topology as the header action before Add agent", () => { - render(); + renderAgentsSection(); const agentsHeader = screen.getByRole("button", { name: "Agents" }).closest(".group\\/section"); expect(agentsHeader).toBeTruthy(); @@ -150,7 +77,9 @@ describe("AgentsSection header", () => { const topology = within(agentsHeader as HTMLElement).getByRole("link", { name: "Agent topology", }); - const addAgent = within(agentsHeader as HTMLElement).getByRole("button", { name: "Add agent" }); + const addAgent = within(agentsHeader as HTMLElement).getByRole("button", { + name: "Add agent", + }); expect(topology.getAttribute("href")).toBe("/office/workspace/org"); expect(topology.compareDocumentPosition(addAgent) & Node.DOCUMENT_POSITION_FOLLOWING).toBe( @@ -158,121 +87,3 @@ describe("AgentsSection header", () => { ); }); }); - -describe("AgentsSection stale state cleanup", () => { - it("does not render stale agent links when no office workspace is active", () => { - state.workspaces.activeId = null; - state.office.agentProfiles = [ - createAgentProfile({ - id: "stale-agent", - workspace: staleWorkspaceId, - name: "Stale Agent", - }), - ]; - - render(); - - expect(screen.queryByRole("link", { name: /stale agent/i })).toBeNull(); - expect(screen.getByText(noAgentsText)).toBeTruthy(); - }); - - it("clears all office data when workspace becomes inactive", () => { - state.workspaces.activeId = defaultWorkspaceId; - state.office.agentProfiles = [ - createAgentProfile({ - id: "active-agent", - workspace: defaultWorkspaceId, - name: "Active Agent", - }), - ]; - state.office.projects = [{ id: "project-1" }]; - state.office.inboxItems = [{ id: "item-1" }]; - state.office.inboxCount = 2; - - const { rerender } = render(); - state.workspaces.activeId = null; - rerender(); - - expect(state.setOfficeAgentProfiles).toHaveBeenCalledWith([]); - expect(state.setProjects).toHaveBeenCalledWith([]); - expect(state.setInboxItems).toHaveBeenCalledWith([]); - expect(state.setInboxCount).toHaveBeenCalledWith(0); - }); - - it("does not overwrite stale agent state when workspace changes mid-fetch", async () => { - let resolveAgents: (value: { agents: AgentProfile[] }) => void = () => {}; - const pending = new Promise<{ agents: AgentProfile[] }>((resolve) => { - resolveAgents = resolve; - }); - - vi.mocked(listAgentProfiles).mockReturnValue(pending); - - state.workspaces.activeId = staleWorkspaceId; - render(); - - state.workspaces.activeId = null; - const staleAgents = { - agents: [ - createAgentProfile({ - id: "race-agent", - workspace: staleWorkspaceId, - name: "Race Agent", - }), - ], - }; - - await act(async () => { - resolveAgents(staleAgents); - await pending; - }); - - expect(state.setOfficeAgentProfiles).not.toHaveBeenCalled(); - }); -}); - -describe("AgentsSection request sequencing", () => { - it("applies only the latest response after rapid workspace switches", async () => { - const request1 = createDeferred<{ agents: AgentProfile[] }>(); - const request2 = createDeferred<{ agents: AgentProfile[] }>(); - const request3 = createDeferred<{ agents: AgentProfile[] }>(); - const agentFromFirstRequest = createAgentProfile({ - id: "agent-a", - workspace: defaultWorkspaceId, - name: "First Agent", - }); - const agentFromSecondRequest = createAgentProfile({ - id: "agent-b", - workspace: staleWorkspaceId, - name: "Second Agent", - }); - const agentFromThirdRequest = createAgentProfile({ - id: "agent-c", - workspace: defaultWorkspaceId, - name: "Third Agent", - }); - - vi.mocked(listAgentProfiles) - .mockReturnValueOnce(request1.promise) - .mockReturnValueOnce(request2.promise) - .mockReturnValueOnce(request3.promise); - - state.workspaces.activeId = defaultWorkspaceId; - const { rerender } = render(); - state.workspaces.activeId = staleWorkspaceId; - rerender(); - state.workspaces.activeId = defaultWorkspaceId; - rerender(); - - await act(async () => { - request1.resolve({ agents: [agentFromFirstRequest] }); - request2.resolve({ agents: [agentFromSecondRequest] }); - request3.resolve({ agents: [agentFromThirdRequest] }); - await request1.promise; - await request2.promise; - await request3.promise; - }); - - expect(state.setOfficeAgentProfiles).toHaveBeenCalledTimes(1); - expect(state.setOfficeAgentProfiles).toHaveBeenCalledWith([agentFromThirdRequest]); - }); -}); diff --git a/apps/web/components/app-sidebar/sections/agents-section.tsx b/apps/web/components/app-sidebar/sections/agents-section.tsx index 292a0d1631..3a84413af3 100644 --- a/apps/web/components/app-sidebar/sections/agents-section.tsx +++ b/apps/web/components/app-sidebar/sections/agents-section.tsx @@ -1,17 +1,17 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; import Link from "@/components/routing/app-link"; import { usePathname, useRouter } from "@/lib/routing/client-router"; import { IconPlus, IconRobot, IconSitemap } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAppStore } from "@/components/state-provider"; +import { useOfficeAgentsData } from "@/hooks/domains/office/use-office-data"; import { useInOffice } from "@/hooks/use-in-office"; -import { useOfficeRefetch } from "@/hooks/use-office-refetch"; -import { listAgentProfiles } from "@/lib/api/domains/office-api"; +import { officeInboxQueryOptions } from "@/lib/query/query-options/office"; import { cn } from "@/lib/utils"; -import type { AgentProfile } from "@/lib/state/slices/office/types"; +import type { AgentProfile, InboxItem } from "@/lib/state/slices/office/types"; import { selectActiveSessionsForAgent } from "@/lib/state/slices/session/selectors"; import { AgentAvatar } from "@/app/office/components/agent-avatar"; import { AgentStatusDot } from "@/app/office/agents/components/agent-status-dot"; @@ -27,27 +27,18 @@ type AgentsSectionProps = { collapsed: boolean; }; -const hasStaleOfficeData = ({ - agentsLength, - inboxCount, - inboxItemsLength, - projectsLength, -}: { - agentsLength: number; - inboxCount: number; - inboxItemsLength: number; - projectsLength: number; -}) => agentsLength > 0 || inboxItemsLength > 0 || projectsLength > 0 || inboxCount > 0; +export function AgentsSection({ collapsed }: AgentsSectionProps) { + const router = useRouter(); + const inOffice = useInOffice(); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const agentsQuery = useOfficeAgentsData(inOffice ? workspaceId : null); + const inboxQuery = useQuery(officeInboxQueryOptions(inOffice ? (workspaceId ?? "") : "")); + const agents = agentsQuery.data?.agents ?? []; + const inboxItems = inboxQuery.data?.items ?? []; -function isCurrentWorkspaceResponse( - requestWorkspaceId: string | null, - activeWorkspaceId: string | null, -) { - return activeWorkspaceId !== null && requestWorkspaceId === activeWorkspaceId; -} + if (!inOffice) return null; -function AgentsSectionHeaderAction({ router }: { router: { push: (path: string) => void } }) { - return ( + const headerAction = (
@@ -81,70 +72,6 @@ function AgentsSectionHeaderAction({ router }: { router: { push: (path: string)
); -} - -export function AgentsSection({ collapsed }: AgentsSectionProps) { - const router = useRouter(); - const inOffice = useInOffice(); - const store = useAppStoreApi(); - const agents = useAppStore((s) => s.office.agentProfiles); - const workspaceId = useAppStore((s) => s.workspaces.activeId); - const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); - const setProjects = useAppStore((s) => s.setProjects); - const setInboxItems = useAppStore((s) => s.setInboxItems); - const setInboxCount = useAppStore((s) => s.setInboxCount); - const projects = useAppStore((s) => s.office.projects); - const inboxItems = useAppStore((s) => s.office.inboxItems); - const inboxCount = useAppStore((s) => s.office.inboxCount); - const visibleAgents = workspaceId ? agents : []; - const fetchSequenceRef = useRef(0); - - const refetchAgents = useCallback(async () => { - if (!workspaceId || !inOffice) return; - const requestId = ++fetchSequenceRef.current; - const requestedWorkspaceId = workspaceId; - const res = await listAgentProfiles(requestedWorkspaceId).catch(() => ({ agents: [] })); - if (!isCurrentWorkspaceResponse(requestedWorkspaceId, store.getState().workspaces.activeId)) - return; - if (requestId !== fetchSequenceRef.current) return; - setOfficeAgentProfiles(res.agents ?? []); - }, [inOffice, setOfficeAgentProfiles, store, workspaceId]); - - useEffect(() => { - refetchAgents(); - }, [refetchAgents]); - - useEffect(() => { - if (!inOffice || workspaceId) return; - if ( - !hasStaleOfficeData({ - agentsLength: agents.length, - inboxCount, - inboxItemsLength: inboxItems.length, - projectsLength: projects.length, - }) - ) - return; - setOfficeAgentProfiles([]); - setProjects([]); - setInboxItems([]); - setInboxCount(0); - }, [ - agents.length, - inboxCount, - inboxItems.length, - inOffice, - projects.length, - setInboxCount, - setInboxItems, - setOfficeAgentProfiles, - setProjects, - workspaceId, - ]); - - useOfficeRefetch("agents", refetchAgents); - - if (!inOffice) return null; return ( } + headerAction={headerAction} headerActionVisibility="always" defaultExpanded > - {visibleAgents.length === 0 ? ( + {agents.length === 0 ? (

No agents yet

) : ( - visibleAgents.map((agent) => ) + agents.map((agent) => ) )}
); } -function AgentRow({ agent }: { agent: AgentProfile }) { +function AgentRow({ agent, inboxItems }: { agent: AgentProfile; inboxItems: InboxItem[] }) { 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 errorCount = 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 isAutoPaused = (agent.pauseReason ?? "").startsWith("Auto-paused:"); return ( diff --git a/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx b/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx index a8be7217bb..c5480b964b 100644 --- a/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx +++ b/apps/web/components/app-sidebar/sections/office-navigation-section.test.tsx @@ -1,5 +1,8 @@ import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; let pathname = "/office"; @@ -10,13 +13,7 @@ const state = { "office-workspace": true, } as Record, }, - office: { - dashboard: { - task_count: 7, - routine_count: 2, - skill_count: 3, - }, - }, + workspaces: { activeId: "ws-1" as string | null }, toggleAppSidebarSection: vi.fn(), setAppSidebarCollapsed: vi.fn(), }; @@ -46,6 +43,21 @@ function hrefFor(label: string) { return screen.getByRole("link", { name: new RegExp(label, "i") }).getAttribute("href"); } +function renderOfficeNavigation() { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.dashboard("ws-1"), { + task_count: 7, + routine_count: 2, + skill_count: 3, + }); + + return render( + + + , + ); +} + describe("OfficeNavigationSection", () => { beforeEach(() => { pathname = "/office"; @@ -60,7 +72,7 @@ describe("OfficeNavigationSection", () => { }); it("restores links for the dedicated office pages", () => { - render(); + renderOfficeNavigation(); expect(hrefFor("Tasks")).toBe("/office/tasks"); expect(hrefFor("Routines")).toBe("/office/routines"); @@ -75,14 +87,14 @@ describe("OfficeNavigationSection", () => { it("uses expanded defaults when old persisted sidebar state lacks new office keys", () => { state.appSidebar.sectionExpanded = {}; - render(); + renderOfficeNavigation(); expect(screen.getByRole("link", { name: /Tasks/i })).toBeTruthy(); expect(screen.getByRole("link", { name: /Preferences/i })).toBeTruthy(); }); it("renders the skills count with muted badge styling", () => { - render(); + renderOfficeNavigation(); const badge = screen.getByText("3"); expect(badge.classList.contains("bg-muted")).toBe(true); diff --git a/apps/web/components/app-sidebar/sections/office-navigation-section.tsx b/apps/web/components/app-sidebar/sections/office-navigation-section.tsx index cb39294953..4cc41dc066 100644 --- a/apps/web/components/app-sidebar/sections/office-navigation-section.tsx +++ b/apps/web/components/app-sidebar/sections/office-navigation-section.tsx @@ -10,6 +10,7 @@ import { IconSettings, } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; +import { useOfficeDashboardData } from "@/hooks/domains/office/use-office-data"; import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants"; import { AppSidebarNavItem } from "../app-sidebar-nav-item"; import { AppSidebarSection } from "../app-sidebar-section"; @@ -36,7 +37,9 @@ export function OfficeNavigationSection({ collapsed, section = "all", }: OfficeNavigationSectionProps) { - const dashboard = useAppStore((s) => s.office.dashboard); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const dashboardQuery = useOfficeDashboardData(workspaceId); + const dashboard = dashboardQuery.data ?? null; const taskCount = dashboard?.task_count ?? 0; const routineCount = dashboard?.routine_count ?? 0; const skillCount = dashboard?.skill_count ?? 0; diff --git a/apps/web/components/app-sidebar/sections/projects-section.test.tsx b/apps/web/components/app-sidebar/sections/projects-section.test.tsx index e549f7bd0e..3912f9ba7a 100644 --- a/apps/web/components/app-sidebar/sections/projects-section.test.tsx +++ b/apps/web/components/app-sidebar/sections/projects-section.test.tsx @@ -1,5 +1,8 @@ import { cleanup, render, screen, within } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; const routerMock = vi.hoisted(() => ({ push: vi.fn(), @@ -11,9 +14,7 @@ const state = { projects: false, } as Record, }, - office: { - projects: [], - }, + workspaces: { activeId: "workspace-1" }, toggleAppSidebarSection: vi.fn(), setAppSidebarCollapsed: vi.fn(), }; @@ -43,6 +44,17 @@ vi.mock("@kandev/ui/tooltip", () => ({ import { ProjectsSection } from "./projects-section"; +function renderProjectsSection() { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.projects("workspace-1"), { projects: [] }); + + return render( + + + , + ); +} + describe("ProjectsSection", () => { afterEach(() => { cleanup(); @@ -50,7 +62,7 @@ describe("ProjectsSection", () => { }); it("keeps the add-project action visible when the section is collapsed", () => { - render(); + renderProjectsSection(); const projectsHeader = screen .getByRole("button", { name: "Projects" }) diff --git a/apps/web/components/app-sidebar/sections/projects-section.tsx b/apps/web/components/app-sidebar/sections/projects-section.tsx index ba6b57f388..8bb86575ea 100644 --- a/apps/web/components/app-sidebar/sections/projects-section.tsx +++ b/apps/web/components/app-sidebar/sections/projects-section.tsx @@ -6,6 +6,7 @@ import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; +import { useOfficeProjectsData } from "@/hooks/domains/office/use-office-data"; import { useInOffice } from "@/hooks/use-in-office"; import { cn } from "@/lib/utils"; import { APP_SIDEBAR_SECTION_IDS } from "../app-sidebar-constants"; @@ -18,7 +19,9 @@ type ProjectsSectionProps = { export function ProjectsSection({ collapsed }: ProjectsSectionProps) { const router = useRouter(); const inOffice = useInOffice(); - const projects = useAppStore((s) => s.office.projects); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const projectsQuery = useOfficeProjectsData(inOffice ? workspaceId : null); + const projects = projectsQuery.data?.projects ?? []; const activeProjects = projects.filter((p) => p.status !== "archived"); if (!inOffice) return null; diff --git a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx index a5a3fbb293..5ab5c4aaf3 100644 --- a/apps/web/components/app-sidebar/sections/settings/agents-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/agents-group.tsx @@ -2,8 +2,8 @@ import { IconRobot } from "@tabler/icons-react"; import { AgentLogo } from "@/components/agent-logo"; -import { useAppStore } from "@/components/state-provider"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; const ROOT_HREF = "/settings/agents"; @@ -15,7 +15,7 @@ type AgentsGroupProps = { }; export function AgentsGroup({ pathname, expanded, onToggle }: AgentsGroupProps) { - const agents = useAppStore((s) => s.settingsAgents.items); + const { settingsAgents: agents } = useSettingsData(true); useAvailableAgents(); return ( diff --git a/apps/web/components/app-sidebar/sections/settings/executors-group.tsx b/apps/web/components/app-sidebar/sections/settings/executors-group.tsx index 9e33565843..443f9404d5 100644 --- a/apps/web/components/app-sidebar/sections/settings/executors-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/executors-group.tsx @@ -1,7 +1,7 @@ "use client"; import { IconCpu } from "@tabler/icons-react"; -import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { getExecutorIcon } from "@/lib/executor-icons"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; @@ -14,7 +14,7 @@ type ExecutorsGroupProps = { }; export function ExecutorsGroup({ pathname, expanded, onToggle }: ExecutorsGroupProps) { - const executors = useAppStore((s) => s.executors.items); + const { executors } = useSettingsData(true); const allProfiles = executors.flatMap((executor) => (executor.profiles ?? []).map((profile) => ({ ...profile, executorType: executor.type })), ); diff --git a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx index 29bab57cf6..c2be74cc18 100644 --- a/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx +++ b/apps/web/components/app-sidebar/sections/settings/settings-tree-render.test.tsx @@ -7,28 +7,53 @@ const ARCHIVE_WORKSPACE_ID = "ws-10"; const MAIN_WORKSPACE_NAME = "Main Workspace"; const ARCHIVE_WORKSPACE_NAME = "Archive Workspace"; -const state = { - workspaces: { - activeId: MAIN_WORKSPACE_ID, - items: [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }], - }, - setActiveWorkspace: vi.fn(), - settingsAgents: { - items: [], - }, - executors: { - items: [], - }, -}; +const setActiveWorkspaceMock = vi.hoisted(() => vi.fn()); +const workspaceState = vi.hoisted(() => ({ + activeId: "ws-1" as string | null, + items: [{ id: "ws-1", name: "Main Workspace" }], +})); vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (s: typeof state) => unknown) => selector(state), + useAppStore: ( + selector: (s: { + workspaces: { activeId: string | null }; + setActiveWorkspace: typeof setActiveWorkspaceMock; + }) => unknown, + ) => + selector({ + workspaces: { activeId: workspaceState.activeId }, + setActiveWorkspace: setActiveWorkspaceMock, + }), +})); + +vi.mock("@/hooks/domains/workspace/use-workspaces", () => ({ + useWorkspaces: () => ({ + items: workspaceState.items, + activeId: workspaceState.activeId, + activeWorkspace: + workspaceState.items.find((workspace) => workspace.id === workspaceState.activeId) ?? null, + }), })); vi.mock("@/hooks/domains/settings/use-available-agents", () => ({ useAvailableAgents: () => undefined, })); +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: () => ({ + agentProfiles: [], + availableAgents: [], + availableTools: [], + executors: [], + settingsAgents: [], + settingsData: { + agentsLoaded: true, + capabilitiesLoaded: true, + executorsLoaded: true, + }, + }), +})); + vi.mock("@kandev/ui/collapsible", async () => { const React = await vi.importActual("react"); const CollapsibleContext = React.createContext(false); @@ -47,11 +72,9 @@ import { WorkspacesGroup } from "./workspaces-group"; describe("SettingsTree rendering", () => { beforeEach(() => { - state.workspaces.activeId = MAIN_WORKSPACE_ID; - state.workspaces.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }]; - state.setActiveWorkspace.mockClear(); - state.settingsAgents.items = []; - state.executors.items = []; + workspaceState.activeId = MAIN_WORKSPACE_ID; + workspaceState.items = [{ id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }]; + setActiveWorkspaceMock.mockClear(); }); afterEach(() => cleanup()); @@ -74,7 +97,7 @@ describe("SettingsTree rendering", () => { }); it("opens the active workspace by default when the settings tree opens", () => { - state.workspaces.items = [ + workspaceState.items = [ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }, { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME }, ]; @@ -93,7 +116,7 @@ describe("SettingsTree rendering", () => { }); it("uses an accordion for workspace subsections", () => { - state.workspaces.items = [ + workspaceState.items = [ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }, { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME }, ]; @@ -113,7 +136,7 @@ describe("SettingsTree rendering", () => { }); it("only opens the routed workspace subsection on workspace detail routes", () => { - state.workspaces.items = [ + workspaceState.items = [ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }, { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME }, ]; @@ -140,7 +163,7 @@ describe("SettingsTree rendering", () => { }); it("opens workspace integrations when a workspace integration route is active", () => { - state.workspaces.items = [ + workspaceState.items = [ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }, { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME }, ]; @@ -173,12 +196,12 @@ describe("SettingsTree rendering", () => { describe("WorkspacesGroup active workspace presentation", () => { beforeEach(() => { - state.workspaces.activeId = MAIN_WORKSPACE_ID; - state.workspaces.items = [ + workspaceState.activeId = MAIN_WORKSPACE_ID; + workspaceState.items = [ { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME }, { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }, ]; - state.setActiveWorkspace.mockClear(); + setActiveWorkspaceMock.mockClear(); }); afterEach(() => cleanup()); @@ -199,7 +222,7 @@ describe("WorkspacesGroup active workspace presentation", () => { fireEvent.click(screen.getByRole("button", { name: "Expand Archive Workspace" })); - expect(state.setActiveWorkspace).not.toHaveBeenCalled(); + expect(setActiveWorkspaceMock).not.toHaveBeenCalled(); expect(getWorkspaceRootLinks()[0].textContent).toBe(`${MAIN_WORKSPACE_NAME}Active`); expect(screen.getByRole("link", { name: `${MAIN_WORKSPACE_NAME} Active` })).toBeTruthy(); }); @@ -207,8 +230,8 @@ describe("WorkspacesGroup active workspace presentation", () => { describe("WorkspacesGroup integration route sync", () => { beforeEach(() => { - state.workspaces.activeId = MAIN_WORKSPACE_ID; - state.workspaces.items = [ + workspaceState.activeId = MAIN_WORKSPACE_ID; + workspaceState.items = [ { id: MAIN_WORKSPACE_ID, name: MAIN_WORKSPACE_NAME }, { id: ARCHIVE_WORKSPACE_ID, name: ARCHIVE_WORKSPACE_NAME }, ]; diff --git a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx index 0c67ba06a0..9e3a9d5f50 100644 --- a/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/workspaces-group.tsx @@ -15,7 +15,7 @@ import { IconTicket, } from "@tabler/icons-react"; import type { Icon as TablerIcon } from "@tabler/icons-react"; -import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; const ROOT_HREF = "/settings/workspace"; @@ -64,8 +64,7 @@ function activeWorkspaceFirst(workspaces: T[], activeI } export function WorkspacesGroup({ pathname, expanded, onToggle }: WorkspacesGroupProps) { - const workspaces = useAppStore((s) => s.workspaces.items); - const storeActiveWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const { items: workspaces, activeId: storeActiveWorkspaceId } = useWorkspaces(); const routeWorkspaceId = workspaces.find((workspace) => isWorkspaceRoute(pathname, workspace.id))?.id ?? null; const activeWorkspaceId = activeWorkspaceIdFor(workspaces, storeActiveWorkspaceId); diff --git a/apps/web/components/app-sidebar/sections/tasks-section.test.tsx b/apps/web/components/app-sidebar/sections/tasks-section.test.tsx index b66a147858..7833c6c192 100644 --- a/apps/web/components/app-sidebar/sections/tasks-section.test.tsx +++ b/apps/web/components/app-sidebar/sections/tasks-section.test.tsx @@ -10,7 +10,7 @@ const state = { }, }, workspaces: { activeId: "ws-1" as string | null }, - kanban: { workflowId: "wf-1" as string | null }, + workflows: { activeId: "wf-1" as string | null }, sidebarViews: { views: [{ id: "all", name: "All tasks" }], activeViewId: "all", @@ -26,7 +26,9 @@ vi.mock("@/components/state-provider", () => ({ })); vi.mock("@/components/task/task-session-sidebar", () => ({ - TaskSessionSidebar: () =>
, + TaskSessionSidebar: ({ workflowId }: { workflowId: string | null }) => ( +
+ ), })); vi.mock("@/components/task/sidebar-filter/sidebar-filter-popover", () => ({ @@ -47,7 +49,7 @@ describe("TasksSection", () => { beforeEach(() => { state.appSidebar.sectionExpanded.tasks = true; state.workspaces.activeId = "ws-1"; - state.kanban.workflowId = "wf-1"; + state.workflows.activeId = "wf-1"; state.sidebarViews.views = [{ id: "all", name: "All tasks" }]; state.sidebarViews.activeViewId = "all"; state.sidebarViews.draft = null; @@ -66,4 +68,12 @@ describe("TasksSection", () => { expect(header?.contains(picker)).toBe(true); }); + + it("passes the active workflow from the workflow UI state", () => { + state.workflows.activeId = "wf-query"; + + renderSection(); + + expect(screen.getByTestId("task-sidebar").dataset.workflowId).toBe("wf-query"); + }); }); diff --git a/apps/web/components/app-sidebar/sections/tasks-section.tsx b/apps/web/components/app-sidebar/sections/tasks-section.tsx index a501dc348c..12966d759b 100644 --- a/apps/web/components/app-sidebar/sections/tasks-section.tsx +++ b/apps/web/components/app-sidebar/sections/tasks-section.tsx @@ -13,7 +13,7 @@ type TasksSectionProps = { export function TasksSection({ collapsed }: TasksSectionProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); - const workflowId = useAppStore((s) => s.kanban.workflowId); + const workflowId = useAppStore((s) => s.workflows.activeId); return ( ({ })); vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ - useSettingsData: vi.fn(), + useSettingsData: vi.fn(() => ({ + agentProfiles: [], + availableAgents: [], + availableTools: [], + executors: [], + settingsAgents: [], + settingsData: { + agentsLoaded: true, + capabilitiesLoaded: true, + executorsLoaded: true, + }, + })), })); vi.mock("@/hooks/use-workflows", () => ({ - useWorkflows: vi.fn(), + useWorkflows: vi.fn(() => ({ workflows: mockState.workflows.items })), +})); + +vi.mock("@/hooks/use-workflow-steps", () => ({ + useWorkflowSteps: vi.fn(() => ({ steps: [] })), })); vi.mock("@/hooks/domains/workspace/use-repositories", () => ({ @@ -34,10 +49,6 @@ vi.mock("@/app/actions/workspaces", () => ({ discoverRepositoriesAction: vi.fn().mockResolvedValue({ repositories: [] }), })); -vi.mock("@/lib/api/domains/workflow-api", () => ({ - listWorkflowSteps: vi.fn().mockResolvedValue({ steps: [] }), -})); - import { ConfigSection } from "./config-section"; function renderConfigSection(overrides: Partial> = {}) { diff --git a/apps/web/components/automations/config-section.tsx b/apps/web/components/automations/config-section.tsx index f317b8a956..c078d9086f 100644 --- a/apps/web/components/automations/config-section.tsx +++ b/apps/web/components/automations/config-section.tsx @@ -3,12 +3,11 @@ 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 { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; +import { useWorkflowSteps, type WorkflowStepOption } from "@/hooks/use-workflow-steps"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { discoverRepositoriesAction } from "@/app/actions/workspaces"; -import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; import type { LocalRepository, Repository } from "@/lib/types/http"; import type { ExecutionMode, TriggerType } from "@/lib/types/automation"; import { RequiredFieldLabel } from "./required-field-label"; @@ -120,37 +119,12 @@ function useDiscoveredRepositories(workspaceId: string) { return items; } -type StepOption = { id: string; name: string }; - function getWorkflowStepHelpText(workflowId: string, workflowStepId: string): string | undefined { if (!workflowId) return "Select a workflow before choosing a step."; if (!workflowStepId) return "Select a workflow step to enable saving."; return undefined; } -function useWorkflowSteps(workflowId: string) { - const [steps, setSteps] = useState([]); - - useEffect(() => { - if (!workflowId) return; - let cancelled = false; - listWorkflowSteps(workflowId) - .then((response) => { - if (cancelled) return; - const sorted = [...response.steps].sort((a, b) => a.position - b.position); - setSteps(sorted.map((s) => ({ id: s.id, name: s.name }))); - }) - .catch(() => { - if (!cancelled) setSteps([]); - }); - return () => { - cancelled = true; - }; - }, [workflowId]); - - return steps; -} - export function ConfigSection({ workspaceId, workflowId, @@ -167,15 +141,14 @@ export function ConfigSection({ onRepositoryChange, onExecutionModeChange, }: ConfigSectionProps) { - useSettingsData(true); - useWorkflows(workspaceId, true); + const settingsCatalog = useSettingsData(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 steps = useWorkflowSteps(workflowId); + const agentProfiles = settingsCatalog.agentProfiles; + const executors = settingsCatalog.executors; + const { steps } = useWorkflowSteps(workflowId); const filteredAgentProfiles = useMemo( () => agentProfiles.filter((p) => !p.cli_passthrough), @@ -261,7 +234,7 @@ function WorkflowFields({ workflowId: string; workflowStepId: string; workflows: Array<{ id: string; name: string }>; - steps: StepOption[]; + steps: WorkflowStepOption[]; onWorkflowChange: (id: string) => void; onStepChange: (id: string) => void; }) { diff --git a/apps/web/components/command-panel.test.ts b/apps/web/components/command-panel.test.ts new file mode 100644 index 0000000000..9c866931d3 --- /dev/null +++ b/apps/web/components/command-panel.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { taskId, workflowId, workspaceId, type Task } from "@/lib/types/http"; +import { + getCommandPanelActiveTaskResults, + getCommandPanelSearchTaskResults, + getCommandPanelStepsFromSnapshots, +} from "./command-panel"; + +const STEP_ONE = "step-1"; +const STEP_REVIEW = "step-review"; +const STEP_DOING = "step-doing"; + +function makeTask(id: string, state: Task["state"], stepId: string, title = id): Task { + return { + id: taskId(id), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: stepId, + position: 1, + title, + description: "", + state, + priority: 0, + repositories: [], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; +} + +describe("command panel task results", () => { + it("filters active results to visible non-archived steps and sorts by step position", () => { + const visibleStepIds = new Set([STEP_REVIEW, STEP_DOING]); + const stepPositions = new Map([ + [STEP_REVIEW, 3], + [STEP_DOING, 1], + ]); + + const results = getCommandPanelActiveTaskResults( + [ + makeTask("review", "CREATED", STEP_REVIEW), + makeTask("done", "COMPLETED", STEP_DOING), + makeTask("hidden", "CREATED", "step-hidden"), + makeTask("doing", "CREATED", STEP_DOING), + ], + visibleStepIds, + stepPositions, + ); + + expect(results.map((task) => task.id)).toEqual(["doing", "review"]); + }); + + it("keeps archived task search matches after active matches", () => { + const results = getCommandPanelSearchTaskResults([ + makeTask("failed", "FAILED", STEP_ONE), + makeTask("active", "CREATED", STEP_ONE), + makeTask("cancelled", "CANCELLED", STEP_ONE), + ]); + + expect(results.map((task) => task.id)).toEqual(["active", "failed", "cancelled"]); + }); + + it("derives task result step labels from Query snapshots instead of legacy kanban steps", () => { + const steps = getCommandPanelStepsFromSnapshots({ + "wf-1": { + workflowId: "wf-1", + workflowName: "Workflow", + tasks: [], + steps: [ + { + id: STEP_REVIEW, + title: "Review", + color: "bg-green-500", + position: 0, + show_in_command_panel: true, + }, + ], + }, + }); + + expect(steps).toEqual([ + { + id: STEP_REVIEW, + title: "Review", + color: "bg-green-500", + position: 0, + show_in_command_panel: true, + }, + ]); + }); +}); diff --git a/apps/web/components/command-panel.tsx b/apps/web/components/command-panel.tsx index 61de0f9d1a..f2521dff2f 100644 --- a/apps/web/components/command-panel.tsx +++ b/apps/web/components/command-panel.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useRouter } from "@/lib/routing/client-router"; import { useCommands, useCommandPanelOpen } from "@/lib/commands/command-registry"; import type { CommandPanelMode, CommandItem as CommandItemType } from "@/lib/commands/types"; @@ -8,13 +9,17 @@ 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 { useCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; -import { listTasksByWorkspace } from "@/lib/api"; import { linkToTask } from "@/lib/links"; import type { Task } from "@/lib/types/http"; import { getWebSocketClient } from "@/lib/ws/connection"; import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import { useDockviewStore } from "@/lib/state/dockview-store"; +import { useDebounce } from "@/hooks/use-debounce"; +import { workspaceTasksQueryOptions } from "@/lib/query/query-options"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import { CommandPanelView, MODE_COMMANDS, @@ -57,10 +62,8 @@ function useCommandPanelState() { } function useCommandPanelEffectRefs() { - const debounceRef = useRef | null>(null); - const abortRef = useRef(null); const fileDebounceRef = useRef | null>(null); - return { debounceRef, abortRef, fileDebounceRef }; + return { fileDebounceRef }; } type FileSearchEffectOptions = { @@ -111,7 +114,15 @@ function useFileSearchEffect(opts: FileSearchEffectOptions) { const ARCHIVED_STATES = new Set(["COMPLETED", "CANCELLED", "FAILED"]); -function resolveVisibleStepIds(steps: { id: string; show_in_command_panel?: boolean }[]) { +type CommandPanelStep = { + id: string; + title: string; + color: string; + position: number; + show_in_command_panel?: boolean; +}; + +function resolveVisibleStepIds(steps: CommandPanelStep[]) { if (steps.length === 0) return null; // no steps loaded yet — don't filter return new Set(steps.filter((s) => s.show_in_command_panel !== false).map((s) => s.id)); } @@ -121,12 +132,12 @@ type InlineTaskSearchOptions = { search: string; open: boolean; workspaceId: string | null; - steps: { id: string; position: number; show_in_command_panel?: boolean }[]; + steps: CommandPanelStep[]; setTaskResults: (tasks: Task[]) => void; setIsSearching: (searching: boolean) => void; }; -function useStepMaps(steps: InlineTaskSearchOptions["steps"]) { +function useStepMaps(steps: CommandPanelStep[]) { const visibleStepIds = useMemo(() => resolveVisibleStepIds(steps), [steps]); const stepPositionMap = useMemo(() => { const map = new Map(); @@ -136,109 +147,103 @@ function useStepMaps(steps: InlineTaskSearchOptions["steps"]) { return { visibleStepIds, stepPositionMap }; } +export function getCommandPanelActiveTaskResults( + tasks: Task[], + visibleStepIds: ReadonlySet | null, + stepPositionMap: ReadonlyMap, +) { + return tasks + .filter( + (task) => + (!visibleStepIds || visibleStepIds.has(task.workflow_step_id)) && + !ARCHIVED_STATES.has(task.state), + ) + .sort( + (a, b) => + (stepPositionMap.get(a.workflow_step_id) ?? 99) - + (stepPositionMap.get(b.workflow_step_id) ?? 99), + ); +} + +export function getCommandPanelSearchTaskResults(tasks: Task[]) { + return [...tasks].sort((a, b) => { + const aArchived = ARCHIVED_STATES.has(a.state) ? 1 : 0; + const bArchived = ARCHIVED_STATES.has(b.state) ? 1 : 0; + return aArchived - bArchived; + }); +} + function useInlineTaskSearchEffect(opts: InlineTaskSearchOptions) { const { mode, search, open, workspaceId, steps, setTaskResults, setIsSearching } = opts; - const debounceRef = useRef | null>(null); - const abortRef = useRef(null); const { visibleStepIds, stepPositionMap } = useStepMaps(steps); + const trimmedSearch = search.trim(); + const debouncedSearch = useDebounce(trimmedSearch, 300); + const isBlankSearch = trimmedSearch.length === 0; + const isShortSearch = trimmedSearch.length > 0 && trimmedSearch.length < 2; + const isDebouncingSearch = trimmedSearch.length >= 2 && debouncedSearch !== trimmedSearch; + const queryFilters = isBlankSearch + ? { page: 1, pageSize: 20 } + : { query: debouncedSearch, page: 1, pageSize: 5, includeArchived: true }; + const taskQuery = useQuery({ + ...workspaceTasksQueryOptions(workspaceId ?? "", queryFilters), + enabled: + mode === MODE_COMMANDS && + open && + Boolean(workspaceId) && + !isShortSearch && + !isDebouncingSearch, + staleTime: 0, + }); useEffect(() => { - if (mode !== MODE_COMMANDS) return; - if (debounceRef.current) clearTimeout(debounceRef.current); - abortRef.current?.abort(); - - // No search: load active tasks (excluding backlog + done steps) - if (!search.trim()) { - if (!open || !workspaceId) { - setTaskResults([]); - setIsSearching(false); - return; - } - setIsSearching(true); - const controller = new AbortController(); - abortRef.current = controller; - listTasksByWorkspace( - workspaceId, - { page: 1, pageSize: 20 }, - { init: { signal: controller.signal } }, - ) - .then((res) => { - if (controller.signal.aborted) return; - const tasks = (res.tasks ?? []).filter( - (t) => - (!visibleStepIds || visibleStepIds.has(t.workflow_step_id)) && - !ARCHIVED_STATES.has(t.state), - ); - tasks.sort( - (a, b) => - (stepPositionMap.get(a.workflow_step_id) ?? 99) - - (stepPositionMap.get(b.workflow_step_id) ?? 99), - ); - setTaskResults(tasks); - }) - .catch(() => { - if (!controller.signal.aborted) setTaskResults([]); - }) - .finally(() => { - if (!controller.signal.aborted) setIsSearching(false); - }); - return () => { - controller.abort(); - }; + if (mode !== MODE_COMMANDS) { + return; } - - // Search with < 2 chars: clear results - if (search.trim().length < 2) { + if (!open || !workspaceId || isShortSearch) { setTaskResults([]); setIsSearching(false); return; } - - // Search: query API including archived - setIsSearching(true); - debounceRef.current = setTimeout(async () => { - if (!workspaceId) { - setIsSearching(false); - return; - } - const controller = new AbortController(); - abortRef.current = controller; - try { - const res = await listTasksByWorkspace( - workspaceId, - { query: search.trim(), page: 1, pageSize: 5, includeArchived: true }, - { init: { signal: controller.signal } }, - ); - if (!controller.signal.aborted) { - const tasks = res.tasks ?? []; - tasks.sort((a, b) => { - const aArchived = ARCHIVED_STATES.has(a.state) ? 1 : 0; - const bArchived = ARCHIVED_STATES.has(b.state) ? 1 : 0; - return aArchived - bArchived; - }); - setTaskResults(tasks); - } - } catch { - if (!controller.signal.aborted) setTaskResults([]); - } finally { - if (!controller.signal.aborted) setIsSearching(false); - } - }, 300); - - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - abortRef.current?.abort(); - }; + if (isDebouncingSearch) { + setIsSearching(true); + return; + } + setIsSearching(taskQuery.isFetching); }, [ + isDebouncingSearch, + isShortSearch, mode, - search, open, + setIsSearching, + setTaskResults, + taskQuery.isFetching, workspaceId, - visibleStepIds, - stepPositionMap, + ]); + + useEffect(() => { + if (mode !== MODE_COMMANDS || !open || !workspaceId || !taskQuery.data) return; + const tasks = taskQuery.data.tasks ?? []; + setTaskResults( + isBlankSearch + ? getCommandPanelActiveTaskResults(tasks, visibleStepIds, stepPositionMap) + : getCommandPanelSearchTaskResults(tasks), + ); + }, [ + isBlankSearch, + mode, + open, setTaskResults, - setIsSearching, + stepPositionMap, + taskQuery.data, + visibleStepIds, + workspaceId, ]); + + useEffect(() => { + if (!taskQuery.isError) return; + setTaskResults([]); + setIsSearching(false); + }, [setIsSearching, setTaskResults, taskQuery.isError]); } function useCommandPanelEffects( @@ -246,7 +251,7 @@ function useCommandPanelEffects( state: ReturnType, workspaceId: string | null, activeSessionId: string | null, - steps: { id: string; position: number; show_in_command_panel?: boolean }[], + steps: CommandPanelStep[], ) { const { mode, @@ -326,7 +331,7 @@ function useCommandPanelHandlers( state: ReturnType, setOpen: (open: boolean) => void, commands: CommandItemType[], - kanbanSteps: { id: string; title: string; color: string }[], + steps: CommandPanelStep[], repositories: Array<{ id: string; local_path: string }>, ) { const { mode, search, inputCommand, setMode, setSearch, setInputCommand } = state; @@ -347,9 +352,9 @@ function useCommandPanelHandlers( const stepMap = useMemo(() => { const map = new Map(); - for (const step of kanbanSteps) map.set(step.id, { name: step.title, color: step.color }); + for (const step of steps) map.set(step.id, { name: step.title, color: step.color }); return map; - }, [kanbanSteps]); + }, [steps]); const repoMap = useMemo(() => { const map = new Map(); @@ -425,14 +430,55 @@ function useCommandPanelHandlers( }; } +export function getCommandPanelStepsFromSnapshots( + snapshots: Record, +): CommandPanelStep[] { + return Object.values(snapshots).flatMap((snapshot) => + snapshot.steps.map((step) => ({ + id: step.id, + title: step.title, + color: step.color, + position: step.position, + show_in_command_panel: step.show_in_command_panel, + })), + ); +} + +function getCommandPanelStepsSignature(snapshots: Record): string { + return Object.values(snapshots) + .flatMap((snapshot) => + snapshot.steps.map( + (step) => + `${snapshot.workflowId}:${step.id}:${step.title}:${step.color}:${step.position}:${step.show_in_command_panel}`, + ), + ) + .sort() + .join("|"); +} + +function useCommandPanelSteps(workspaceId: string | null): CommandPanelStep[] { + const { snapshots } = useAllWorkflowSnapshots(workspaceId); + const stableStepsRef = useRef<{ signature: string; steps: CommandPanelStep[] }>({ + signature: "", + steps: [], + }); + const signature = getCommandPanelStepsSignature(snapshots); + if (stableStepsRef.current.signature !== signature) { + stableStepsRef.current = { + signature, + steps: getCommandPanelStepsFromSnapshots(snapshots), + }; + } + return stableStepsRef.current.steps; +} + export function CommandPanel() { const { open, setOpen } = useCommandPanelOpen(); const commands = useCommands(); - const kanbanSteps = useAppStore((state) => state.kanban.steps); 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] ?? []) : []; + const repositories = useCachedRepositories(workspaceId); + const steps = useCommandPanelSteps(workspaceId); const state = useCommandPanelState(); const { @@ -448,7 +494,7 @@ export function CommandPanel() { setSearch, } = state; - useCommandPanelEffects(open, state, workspaceId, activeSessionId, kanbanSteps); + useCommandPanelEffects(open, state, workspaceId, activeSessionId, steps); useFirstResultSelection(open, state); const openRef = useRef(open); @@ -487,7 +533,7 @@ export function CommandPanel() { handleFileSelect, handleKeyDown, goBack, - } = useCommandPanelHandlers(state, setOpen, commands, kanbanSteps, repositories); + } = useCommandPanelHandlers(state, setOpen, commands, steps, repositories); return ( void }) { - const profiles = useAppStore((s) => s.agentProfiles.items ?? []); + const { agentProfiles: profiles } = useSettingsData(true); return (

Select an agent profile

@@ -150,7 +150,8 @@ function ConfigChatEmptyState({ isStarting: boolean; error: string | null; }) { - const profileCount = useAppStore((s) => s.agentProfiles.items?.length ?? 0); + const { agentProfiles } = useSettingsData(true); + const profileCount = agentProfiles.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 063f91a615..0501227612 100644 --- a/apps/web/components/config-chat/use-config-chat.ts +++ b/apps/web/components/config-chat/use-config-chat.ts @@ -1,15 +1,19 @@ "use client"; import { useCallback, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useShallow } from "zustand/react/shallow"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { startConfigChat } from "@/lib/api/domains/workspace-api"; import { updateWorkspaceAction } from "@/app/actions/workspaces"; +import { patchWorkspaceCache } from "@/lib/query/workspace-cache"; import { agentProfileId as toAgentProfileId, sessionId as toSessionId, taskId as toTaskId, } from "@/lib/types/ids"; +import type { Workspace } from "@/lib/types/http"; function useConfigChatStore() { return useAppStore( @@ -24,29 +28,27 @@ function useConfigChatStore() { closeConfigChatSession: s.closeConfigChatSession, setActiveConfigChatSession: s.setActiveConfigChatSession, renameConfigChatSession: s.renameConfigChatSession, + setTaskSession: s.setTaskSession, })), ); } -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))); +function usePatchWorkspaceDefaults() { + const queryClient = useQueryClient(); + return (workspaceId: string, updates: Partial) => { + patchWorkspaceCache(queryClient, workspaceId, updates); }; } export function useConfigChat(workspaceId: string) { const store = useConfigChatStore(); - const storeApi = useAppStoreApi(); const [isStarting, setIsStarting] = useState(false); const [error, setError] = useState(null); const [pendingPrompt, setPendingPrompt] = useState(null); - const updateWorkspaceInStore = useUpdateWorkspaceInStore(); + const patchWorkspaceDefaults = usePatchWorkspaceDefaults(); - const workspace = useAppStore( - (s) => s.workspaces.items.find((w) => w.id === workspaceId) ?? null, - ); + const { items: workspaceItems } = useWorkspaces(); + const workspace = workspaceItems.find((w) => w.id === workspaceId) ?? null; const defaultProfileId = workspace?.default_config_agent_profile_id ?? workspace?.default_agent_profile_id ?? undefined; @@ -75,7 +77,7 @@ export function useConfigChat(workspaceId: string) { // Seed the task session in the main store so QuickChatContent can find it // immediately. The WS event will merge on top when it arrives. - storeApi.getState().setTaskSession({ + store.setTaskSession({ id: toSessionId(response.session_id), task_id: toTaskId(response.task_id), state: "CREATED", @@ -96,8 +98,8 @@ export function useConfigChat(workspaceId: string) { await updateWorkspaceAction(workspaceId, { default_config_agent_profile_id: agentProfileId, }); - updateWorkspaceInStore(workspaceId, { - default_config_agent_profile_id: agentProfileId, + patchWorkspaceDefaults(workspaceId, { + default_config_agent_profile_id: toAgentProfileId(agentProfileId), }); } catch { // Non-critical — don't fail the chat start for this @@ -114,9 +116,8 @@ export function useConfigChat(workspaceId: string) { workspaceId, isStarting, store, - storeApi, workspace?.default_config_agent_profile_id, - updateWorkspaceInStore, + patchWorkspaceDefaults, ], ); diff --git a/apps/web/components/diff/diff-viewer-comment-edit.test.tsx b/apps/web/components/diff/diff-viewer-comment-edit.test.tsx index 1279e28382..0ce85337f4 100644 --- a/apps/web/components/diff/diff-viewer-comment-edit.test.tsx +++ b/apps/web/components/diff/diff-viewer-comment-edit.test.tsx @@ -1,10 +1,12 @@ -import type { ReactNode } from "react"; +import type { ReactElement, ReactNode } from "react"; import type { DiffLineAnnotation } from "@pierre/diffs"; import { cleanup, fireEvent, render, screen, waitFor, act } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DiffViewer } from "./diff-viewer"; import type { FileDiffData, DiffComment } from "@/lib/diff/types"; +import { makeQueryClient } from "@/lib/query/client"; import { useCommentsStore } from "@/lib/state/slices/comments"; import type { AnnotationMetadata } from "./use-diff-annotation-renderer"; @@ -80,6 +82,15 @@ function latestCommentText(): string | undefined { return undefined; } +function renderWithQuery(ui: ReactElement) { + const queryClient = makeQueryClient(); + return render(ui, { + wrapper: ({ children }) => ( + {children} + ), + }); +} + beforeEach(() => { window.sessionStorage.clear(); useCommentsStore.setState({ @@ -97,7 +108,7 @@ describe("DiffViewer comment editing", () => { it("propagates an edited comment's new text to the renderer", async () => { useCommentsStore.getState().addComment(comment(ORIGINAL_TEXT)); - render(); + renderWithQuery(); await waitFor(() => expect(latestCommentText()).toBe(ORIGINAL_TEXT)); @@ -111,7 +122,7 @@ describe("DiffViewer comment editing", () => { it("submits edited text from the inline comment form", async () => { useCommentsStore.getState().addComment(comment(ORIGINAL_TEXT)); - render(); + renderWithQuery(); await waitFor(() => expect(screen.getByText(ORIGINAL_TEXT)).toBeTruthy()); @@ -128,7 +139,7 @@ describe("DiffViewer comment editing", () => { it("routes controlled inline edits to onCommentUpdate", async () => { const onCommentUpdate = vi.fn(); - render( + renderWithQuery( = []; @@ -31,9 +34,18 @@ afterEach(() => { fileDiffProps.length = 0; }); +function renderWithQuery(ui: ReactElement) { + const queryClient = makeQueryClient(); + return render(ui, { + wrapper: ({ children }) => ( + {children} + ), + }); +} + describe("DiffViewer", () => { it("wraps diff lines by default", async () => { - render(); + renderWithQuery(); await waitFor(() => expect(fileDiffProps.length).toBeGreaterThan(0)); expect(fileDiffProps.at(-1)?.options?.overflow).toBe("wrap"); @@ -42,7 +54,7 @@ describe("DiffViewer", () => { it("rerenders when the controlled delete handler changes", async () => { const firstDelete = vi.fn(); const secondDelete = vi.fn(); - const { rerender } = render(); + const { rerender } = renderWithQuery(); await waitFor(() => expect(fileDiffProps.length).toBeGreaterThan(0)); const renderCount = fileDiffProps.length; @@ -55,7 +67,7 @@ describe("DiffViewer", () => { it("rerenders when the controlled update handler changes", async () => { const firstUpdate = vi.fn(); const secondUpdate = vi.fn(); - const { rerender } = render(); + const { rerender } = renderWithQuery(); await waitFor(() => expect(fileDiffProps.length).toBeGreaterThan(0)); const renderCount = fileDiffProps.length; diff --git a/apps/web/components/diff/use-diff-viewer-state.ts b/apps/web/components/diff/use-diff-viewer-state.ts index 50c32b6b76..e53fb49c57 100644 --- a/apps/web/components/diff/use-diff-viewer-state.ts +++ b/apps/web/components/diff/use-diff-viewer-state.ts @@ -19,6 +19,7 @@ import { type WalkthroughTargetFile, } from "@/lib/diff/walkthrough-match"; import type { WalkthroughStep } from "@/lib/types/http"; +import { useTaskWalkthrough } from "@/hooks/domains/session/use-task-walkthrough"; type BuildAnnotationsOpts = { comments: DiffComment[]; @@ -180,10 +181,7 @@ function useWalkthroughSelection( selectedLines: SelectedLineRange | null; } { const activeTaskId = useOptionalAppStore((s) => s.tasks.activeTaskId, null); - const walkthrough = useOptionalAppStore( - (s) => (activeTaskId ? s.walkthroughs.byTaskId[activeTaskId] : null), - null, - ); + const walkthrough = useTaskWalkthrough(activeTaskId).data ?? null; const activeStep = useOptionalAppStore( (s) => (activeTaskId ? (s.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0), 0, diff --git a/apps/web/components/diff/walkthrough-floating-window.tsx b/apps/web/components/diff/walkthrough-floating-window.tsx index c1bf2c3524..3deeab3080 100644 --- a/apps/web/components/diff/walkthrough-floating-window.tsx +++ b/apps/web/components/diff/walkthrough-floating-window.tsx @@ -8,6 +8,7 @@ import { type RefObject, } from "react"; import { useAppStore } from "@/components/state-provider"; +import { useTaskWalkthrough } from "@/hooks/domains/session/use-task-walkthrough"; import { useFileEditors } from "@/hooks/use-file-editors"; import { revealFileAtLine, type OpenFileFn } from "@/lib/diff/walkthrough-reveal"; import { @@ -197,29 +198,15 @@ export function WalkthroughFloatingWindow({ const { position, onPointerDown } = useDraggableWindow(cardRef, isDesktop); const anchor = useWalkthroughEditorAnchor(); const cardRect = useCardRect(cardRef, position, anchor?.key); - // Select primitives (not a fresh object) so the selector stays referentially - // stable — returning a new object here would loop the store subscription. - const stepFile = useAppStore((s) => { - const taskId = s.tasks.activeTaskId; - if (!taskId) return null; - const wt = s.walkthroughs.byTaskId[taskId]; - const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; - return wt?.steps[idx]?.file ?? null; - }); - const stepLine = useAppStore((s) => { - const taskId = s.tasks.activeTaskId; - if (!taskId) return 0; - const wt = s.walkthroughs.byTaskId[taskId]; - const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; - return wt?.steps[idx]?.line ?? 0; - }); - const stepRepo = useAppStore((s) => { - const taskId = s.tasks.activeTaskId; - if (!taskId) return undefined; - const wt = s.walkthroughs.byTaskId[taskId]; - const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; - return wt?.steps[idx]?.repo; - }); + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const walkthrough = useTaskWalkthrough(activeTaskId).data ?? null; + const activeStep = useAppStore((s) => + activeTaskId ? (s.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0, + ); + const step = walkthrough?.steps[activeStep]; + const stepFile = step?.file ?? null; + const stepLine = step?.line ?? 0; + const stepRepo = step?.repo; // Open the step's file (current state) and reveal its line whenever the step changes. useEffect(() => { diff --git a/apps/web/components/diff/walkthrough-step-card.tsx b/apps/web/components/diff/walkthrough-step-card.tsx index 080e296ae1..9d8eb0fd9f 100644 --- a/apps/web/components/diff/walkthrough-step-card.tsx +++ b/apps/web/components/diff/walkthrough-step-card.tsx @@ -12,6 +12,12 @@ import { useRunComment } from "@/hooks/domains/comments/use-run-comment"; import { revealFileAtLine, type OpenFileFn } from "@/lib/diff/walkthrough-reveal"; import { generateUUID } from "@/lib/utils"; import { useToast } from "@/components/toast-provider"; +import { + useActiveTaskWalkthrough, + useTaskWalkthrough, + useWalkthroughSeen, + useWalkthroughStepState, +} from "@/hooks/domains/session/use-task-walkthrough"; import { markdownComponents, normalizeMarkdown, @@ -129,13 +135,11 @@ function StepNavigation({ /** True when the active task has a walkthrough with a valid active step. */ export function useHasActiveWalkthroughStep(): boolean { - return useAppStore((s) => { - const taskId = s.tasks.activeTaskId; - if (!taskId) return false; - const wt = s.walkthroughs.byTaskId[taskId]; - const idx = s.walkthroughs.activeStepByTaskId[taskId] ?? 0; - return !!wt?.steps[idx]; - }); + const { taskId, data: walkthrough } = useActiveTaskWalkthrough(); + const activeStep = useAppStore((s) => + taskId ? (s.walkthroughs.activeStepByTaskId[taskId] ?? 0) : 0, + ); + return Boolean(walkthrough?.steps[activeStep]); } function useWalkthroughStepFeedback(params: { @@ -211,17 +215,12 @@ export function WalkthroughStepInner({ }) { const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); const sessionId = useAppStore((s) => s.tasks.activeSessionId); - const walkthrough = useAppStore((s) => - activeTaskId ? s.walkthroughs.byTaskId[activeTaskId] : null, - ); - const activeStep = useAppStore((s) => - activeTaskId ? (s.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0, - ); - const setActiveStep = useAppStore((s) => s.setWalkthroughActiveStep); - const markSeen = useAppStore((s) => s.markWalkthroughSeen); + const walkthrough = useTaskWalkthrough(activeTaskId).data ?? null; + const stepCount = walkthrough?.steps.length ?? 0; + const { activeStep, setActiveStep } = useWalkthroughStepState(activeTaskId, stepCount); + const { markSeen } = useWalkthroughSeen(activeTaskId, walkthrough); const { openFile: defaultOpenFile } = useFileEditors(); const openFile = onSelectFile ?? defaultOpenFile; - const stepCount = walkthrough?.steps.length ?? 0; const step = walkthrough?.steps[activeStep]; const { addWalkthroughFeedback, runWalkthroughFeedback } = useWalkthroughStepFeedback({ activeStep, @@ -232,7 +231,7 @@ export function WalkthroughStepInner({ walkthrough, }); useEffect(() => { - if (activeTaskId && walkthrough) markSeen(activeTaskId); + if (activeTaskId && walkthrough) markSeen(); }, [activeTaskId, walkthrough, markSeen]); if (!activeTaskId || !walkthrough) return null; if (!step) return null; @@ -253,8 +252,8 @@ export function WalkthroughStepInner({ setActiveStep(activeTaskId, activeStep - 1)} - onNext={() => setActiveStep(activeTaskId, activeStep + 1)} + onPrevious={() => setActiveStep(activeStep - 1)} + onNext={() => setActiveStep(activeStep + 1)} />
s.tasks.activeTaskId); + const walkthrough = useTaskWalkthrough(activeTaskId).data ?? null; const activeStep = useAppStore((s) => activeTaskId ? (s.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0, ); - const updatedAt = useAppStore((s) => - activeTaskId ? s.walkthroughs.byTaskId[activeTaskId]?.updated_at : undefined, - ); + const updatedAt = walkthrough?.updated_at; const [dismissedKey, setDismissedKey] = useState(null); const stepKey = activeTaskId ? `${activeTaskId}:${updatedAt ?? ""}:${activeStep}` : ""; if (!hasStep || dismissedKey === stepKey) return null; diff --git a/apps/web/components/editors/codemirror/use-codemirror-walkthrough-range.ts b/apps/web/components/editors/codemirror/use-codemirror-walkthrough-range.ts index 2b045f6d61..7643e84759 100644 --- a/apps/web/components/editors/codemirror/use-codemirror-walkthrough-range.ts +++ b/apps/web/components/editors/codemirror/use-codemirror-walkthrough-range.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { EditorView } from "@codemirror/view"; import { useAppStore } from "@/components/state-provider"; +import { useTaskWalkthrough } from "@/hooks/domains/session/use-task-walkthrough"; import { clearWalkthroughEditorAnchor, isWalkthroughAnchorTargetVisible, @@ -63,9 +64,8 @@ function useActiveWalkthroughStep() { const stepIndex = useAppStore((s) => taskId ? (s.walkthroughs.activeStepByTaskId[taskId] ?? 0) : 0, ); - const step = useAppStore((s) => - taskId ? (s.walkthroughs.byTaskId[taskId]?.steps[stepIndex] ?? null) : null, - ); + const walkthrough = useTaskWalkthrough(taskId).data ?? null; + const step = walkthrough?.steps[stepIndex] ?? null; return { taskId, stepIndex, step: isOpen ? step : null }; } diff --git a/apps/web/components/editors/monaco/use-monaco-walkthrough-range.ts b/apps/web/components/editors/monaco/use-monaco-walkthrough-range.ts index c69ead5632..4cac89cef2 100644 --- a/apps/web/components/editors/monaco/use-monaco-walkthrough-range.ts +++ b/apps/web/components/editors/monaco/use-monaco-walkthrough-range.ts @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState, type RefObject } from "react"; import type { editor as monacoEditor } from "monaco-editor"; import { useAppStore } from "@/components/state-provider"; +import { useTaskWalkthrough } from "@/hooks/domains/session/use-task-walkthrough"; import { clearWalkthroughEditorAnchor, isWalkthroughAnchorTargetVisible, @@ -91,9 +92,8 @@ function useActiveWalkthroughStep() { const stepIndex = useAppStore((s) => taskId ? (s.walkthroughs.activeStepByTaskId[taskId] ?? 0) : 0, ); - const step = useAppStore((s) => - taskId ? (s.walkthroughs.byTaskId[taskId]?.steps[stepIndex] ?? null) : null, - ); + const walkthrough = useTaskWalkthrough(taskId).data ?? null; + const step = walkthrough?.steps[stepIndex] ?? null; return { taskId, stepIndex, step: isOpen ? step : null }; } diff --git a/apps/web/components/github/github-settings.tsx b/apps/web/components/github/github-settings.tsx index c21af7a95d..44ef5fbb83 100644 --- a/apps/web/components/github/github-settings.tsx +++ b/apps/web/components/github/github-settings.tsx @@ -22,7 +22,7 @@ import { useIssueWatches } from "@/hooks/domains/github/use-issue-watches"; import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; import { ResetWatchDialog, useWatchResetController } from "@/components/watches/reset-watch-dialog"; import { cleanupMergedReviewTasks, cleanupClosedIssueTasks } from "@/lib/api/domains/github-api"; -import type { ReviewWatch, IssueWatch } from "@/lib/types/github"; +import type { GitHubStatus, ReviewWatch, IssueWatch } from "@/lib/types/github"; // CleanupNowButton runs a manual global sweep over the dedup tables. Useful // for users who upgraded with a pile of legacy merged-PR / closed-issue @@ -267,7 +267,11 @@ function useIssueWatchActions(workspaceId?: string | null) { }; } -export function GitHubConnectionSection() { +export function GitHubConnectionSection({ + initialStatus, +}: { + initialStatus?: GitHubStatus | null; +}) { return ( <>
@@ -287,7 +291,7 @@ export function GitHubConnectionSection() { - + @@ -311,14 +315,18 @@ function PerWorkspaceSection({ workspaceId }: { workspaceId: string }) { } type GitHubIntegrationPageProps = { + initialStatus?: GitHubStatus | null; workspaceId?: string; }; -export function GitHubIntegrationPage({ workspaceId }: GitHubIntegrationPageProps = {}) { +export function GitHubIntegrationPage({ + initialStatus, + workspaceId, +}: GitHubIntegrationPageProps = {}) { return (
- + {(ws) => } diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx index 90abc4a313..6f38f3143f 100644 --- a/apps/web/components/github/github-status.tsx +++ b/apps/web/components/github/github-status.tsx @@ -20,11 +20,11 @@ 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 { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { useToast } from "@/components/toast-provider"; import { configureGitHubToken, clearGitHubToken } from "@/lib/api/domains/github-api"; -import type { AuthDiagnostics } from "@/lib/types/github"; +import type { AuthDiagnostics, GitHubStatus } from "@/lib/types/github"; import { GitHubRateLimitDisplay } from "./github-rate-limit"; import { HostShellDialog } from "@/components/settings/host-shell-dialog"; @@ -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 { availableTools } = useSettingsData(true); + const ghInstalled = availableTools.some((tool) => tool.name === "gh" && tool.available); const [tokenOpen, setTokenOpen] = useState(false); const [diagOpen, setDiagOpen] = useState(false); @@ -303,8 +302,8 @@ function CLIUnavailableHint() { ); } -export function GitHubStatusCard() { - const { status, loaded, loading, refresh } = useGitHubStatus(); +export function GitHubStatusCard({ initialStatus }: { initialStatus?: GitHubStatus | null }) { + const { status, loaded, loading, refresh } = useGitHubStatus(initialStatus); const { toast } = useToast(); const [clearing, setClearing] = useState(false); const [ghAuthOpen, setGhAuthOpen] = useState(false); diff --git a/apps/web/components/github/issue-watch-dialog.tsx b/apps/web/components/github/issue-watch-dialog.tsx index 0e8d54c018..e14a38aff4 100644 --- a/apps/web/components/github/issue-watch-dialog.tsx +++ b/apps/web/components/github/issue-watch-dialog.tsx @@ -19,6 +19,7 @@ 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 { 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"; @@ -125,13 +126,12 @@ function formStateFromWatch(watch: IssueWatch): FormState { } function useWatchFormData(workspaceId: string) { - useSettingsData(true); - useWorkflows(workspaceId, true); + const settingsCatalog = useSettingsData(true); + const { workflows: allWorkflows } = useWorkflows(workspaceId, true); - const allWorkflows = useAppStore((state) => state.workflows.items); 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 = settingsCatalog.agentProfiles; + const executors = settingsCatalog.executors; const allExecutorProfiles = useMemo( () => executors @@ -559,7 +559,7 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { items: workspaces } = useWorkspaces(); return ( s.workspaces.items); + const { items: 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 c1aab5ee36..c5a145f0ef 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,21 +1,54 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; +vi.mock("@kandev/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children: ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, +})); + vi.mock("@/lib/routing/client-router", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), })); +vi.mock("@/lib/api/domains/kanban-api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchTask: vi.fn(() => new Promise(() => {})), + }; +}); + import { StateProvider } from "@/components/state-provider"; import { PRRowTaskIndicator } from "./pr-row-task-indicator"; +import { qk } from "@/lib/query/keys"; import type { TaskPR } from "@/lib/types/github"; +import { + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, + type Task, + type WorkflowSnapshot, +} from "@/lib/types/http"; + +const CREATED_AT = "2026-06-24T00:00:00Z"; +const TASK_ID = toTaskId("task-1"); +const WORKSPACE_ID = toWorkspaceId("workspace-1"); +const WORKFLOW_ID = toWorkflowId("workflow-1"); -function renderWithStore(ui: ReactNode) { +function renderWithStore(ui: ReactNode, seed?: (client: QueryClient) => void) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + seed?.(queryClient); return render( - - {ui} - , + + + {ui} + + , ); } @@ -52,6 +85,48 @@ function makeTaskPR(overrides: Partial = {}): TaskPR { }; } +function queryTask(): Task { + return { + id: TASK_ID, + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: "step-query", + position: 0, + title: "Query task", + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function workflowSnapshot(): WorkflowSnapshot { + return { + workflow: { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Workflow", + sort_order: 0, + hidden: false, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + steps: [ + { + id: "step-query", + workflow_id: WORKFLOW_ID, + name: "Query Review", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks: [queryTask()], + }; +} + afterEach(() => cleanup()); describe("PRRowTaskIndicator", () => { @@ -88,4 +163,16 @@ describe("PRRowTaskIndicator", () => { expect(btn.textContent).toContain("…"); expect(btn.textContent!.length).toBeLessThan(longTitle.length + 5); }); + + it("uses workflow snapshot Query cache for the single-task step tooltip", () => { + renderWithStore( + , + (client) => { + client.setQueryData(qk.tasks.detail(TASK_ID), queryTask()); + client.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), workflowSnapshot()); + }, + ); + + expect(screen.getByText("Step: Query Review")).toBeTruthy(); + }); }); 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 ec18d8322b..f0a5615afb 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 @@ -1,6 +1,7 @@ "use client"; -import { useCallback } from "react"; +import { useCallback, useSyncExternalStore } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useRouter } from "@/lib/routing/client-router"; import { IconChecklist } from "@tabler/icons-react"; import { @@ -15,7 +16,7 @@ import { useAppStore } from "@/components/state-provider"; 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 { workflowSnapshotQueryData } from "@/lib/query/workflow-snapshot-cache"; import type { TaskPR } from "@/lib/types/github"; type PRRowTaskIndicatorProps = { @@ -23,18 +24,24 @@ 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 queryClient = useQueryClient(); + return useSyncExternalStore( + (onStoreChange) => queryClient.getQueryCache().subscribe(onStoreChange), + () => findWorkflowStepTitle(queryClient, workflowStepId), + () => null, + ); +} + +function findWorkflowStepTitle( + queryClient: QueryClient, + workflowStepId: string | undefined, +): string | null { + if (!workflowStepId) return null; + for (const snapshot of workflowSnapshotQueryData(queryClient)) { + const step = snapshot.steps.find((s) => s.id === workflowStepId); + if (step) return step.name; + } + return null; } function truncateTitle(title: string): string { diff --git a/apps/web/components/github/pr-ci-popover.automation.test.tsx b/apps/web/components/github/pr-ci-popover.automation.test.tsx index 3c501df1c1..27aa8dc922 100644 --- a/apps/web/components/github/pr-ci-popover.automation.test.tsx +++ b/apps/web/components/github/pr-ci-popover.automation.test.tsx @@ -13,7 +13,12 @@ const hookMocks = vi.hoisted(() => ({ })); vi.mock("@/hooks/domains/github/use-github-status", () => ({ - useGitHubStatus: vi.fn(), + useGitHubStatus: () => ({ + status: null, + loaded: true, + loading: false, + refresh: vi.fn(), + }), })); vi.mock("@/hooks/domains/github/use-pr-ci-popover", () => ({ diff --git a/apps/web/components/github/pr-ci-popover.tsx b/apps/web/components/github/pr-ci-popover.tsx index dd98909192..5f94555416 100644 --- a/apps/web/components/github/pr-ci-popover.tsx +++ b/apps/web/components/github/pr-ci-popover.tsx @@ -544,10 +544,8 @@ export function PRCIPopover({ enabled: boolean; onOpenDetailPanel?: () => void; }) { - const ghStatus = useAppStore((s) => s.githubStatus.status); + 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 83fd610876..35adff99bd 100644 --- a/apps/web/components/github/pr-detail-panel.tsx +++ b/apps/web/components/github/pr-detail-panel.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { setPanelTitle } from "@/lib/layout/panel-portal-manager"; import { IconRefresh, @@ -24,6 +25,7 @@ import { useCommentsStore, isPRFeedbackComment } from "@/lib/state/slices/commen import type { PRFeedbackComment } from "@/lib/state/slices/comments"; import { useToast } from "@/components/toast-provider"; import { submitPRReview } from "@/lib/api/domains/github-api"; +import { qk } from "@/lib/query/keys"; import type { TaskPR, PRFeedback } from "@/lib/types/github"; import { formatTimeAgo, @@ -111,12 +113,12 @@ function useAddPRFeedbackAsContext(sessionId: string, prNumber: number) { return { addAsContext }; } -// Sync live feedback data back to the store so topbar/other consumers stay up to date. +// Sync live feedback data back to Query so topbar/other consumers stay up to date. // Use primitive deps to avoid re-render loops from object reference changes. -// Guard: never regress the store to a less-terminal state (e.g. merged → open) +// Guard: never regress Query 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 queryClient = useQueryClient(); const prState = taskPR.state; const prMergedAt = taskPR.merged_at ?? null; const prClosedAt = taskPR.closed_at ?? null; @@ -146,7 +148,7 @@ function useSyncLivePRState(taskPR: TaskPR, feedback: PRFeedback | null) { livePR.deletions !== prDeletions || effectiveMergeableState !== prMergeableState ) { - setTaskPR(prTaskId, { + const next = { ...taskPR, state: effectiveState as TaskPR["state"], additions: livePR.additions, @@ -154,7 +156,10 @@ function useSyncLivePRState(taskPR: TaskPR, feedback: PRFeedback | null) { merged_at: effectiveMergedAt, closed_at: effectiveClosedAt, mergeable_state: effectiveMergeableState, - }); + }; + queryClient.setQueryData(qk.integrations.github.taskPr(prTaskId), (prev) => + upsertTaskPR(prev ?? [], next), + ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ @@ -166,10 +171,18 @@ function useSyncLivePRState(taskPR: TaskPR, feedback: PRFeedback | null) { prDeletions, prMergeableState, prTaskId, - setTaskPR, + queryClient, ]); } +function upsertTaskPR(items: TaskPR[], next: TaskPR): TaskPR[] { + const index = items.findIndex((item) => item.id === next.id); + if (index === -1) return [...items, next]; + const copy = [...items]; + copy[index] = next; + return copy; +} + type PRPanelMetrics = { reviewCount: number; pendingReviewCount: number; diff --git a/apps/web/components/github/pr-status-chip.auto-fix-rounds.test.tsx b/apps/web/components/github/pr-status-chip.auto-fix-rounds.test.tsx index abadb1c4cf..385a1d205f 100644 --- a/apps/web/components/github/pr-status-chip.auto-fix-rounds.test.tsx +++ b/apps/web/components/github/pr-status-chip.auto-fix-rounds.test.tsx @@ -1,10 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; import { PRStatusChip } from "./pr-status-chip"; +import { qk } from "@/lib/query/keys"; import type { AppState } from "@/lib/state/store"; import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github"; @@ -51,13 +53,37 @@ vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: vi.fn(() => null), })); -function renderWithStore(initialState: Partial, ui: ReactNode) { +type GitHubQueryTestState = Partial & { + taskPRs?: { byTaskId: Record }; + taskCIAutomation?: { + byTaskId?: Record; + loading?: Record; + saving?: Record; + errors?: Record; + }; +}; + +function renderWithStore(initialState: GitHubQueryTestState, ui: ReactNode) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + for (const [taskId, prs] of Object.entries(initialState.taskPRs?.byTaskId ?? {})) { + queryClient.setQueryData(qk.integrations.github.taskPr(taskId), prs); + } + for (const [taskId, options] of Object.entries(initialState.taskCIAutomation?.byTaskId ?? {})) { + queryClient.setQueryData(qk.integrations.github.taskCiOptions(taskId), options); + } + const appState = { ...initialState }; + delete appState.taskPRs; + delete appState.taskCIAutomation; return render( - - - {ui} - - , + + + + {ui} + + + , ); } @@ -132,7 +158,7 @@ function stateWithAutoFix( roundCount: number, exhausted = false, maxRounds = 10, -): Partial { +): GitHubQueryTestState { return { taskPRs: { byTaskId: { "task-1": [makePR()] } }, taskCIAutomation: { diff --git a/apps/web/components/github/pr-status-chip.test.tsx b/apps/web/components/github/pr-status-chip.test.tsx index e01d03cf06..12cd1912e3 100644 --- a/apps/web/components/github/pr-status-chip.test.tsx +++ b/apps/web/components/github/pr-status-chip.test.tsx @@ -1,11 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; import { PRStatusChip, aggregateChipStatus } from "./pr-status-chip"; import { PR_CI_DESKTOP_POPOVER_SCROLL_CLASS } from "./pr-ci-popover"; +import { qk } from "@/lib/query/keys"; import type { AppState } from "@/lib/state/store"; import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github"; @@ -57,13 +59,35 @@ vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: vi.fn(() => null), })); -function renderWithStore(initialState: Partial | undefined, ui: ReactNode) { +type GitHubQueryTestState = Partial & { + taskPRs?: { byTaskId: Record }; + taskCIAutomation?: { + byTaskId?: Record; + loading?: Record; + saving?: Record; + errors?: Record; + }; +}; + +function renderWithStore(initialState: GitHubQueryTestState | undefined, ui: ReactNode) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + for (const [taskId, prs] of Object.entries(initialState?.taskPRs?.byTaskId ?? {})) { + queryClient.setQueryData(qk.integrations.github.taskPr(taskId), prs); + } + for (const [taskId, options] of Object.entries(initialState?.taskCIAutomation?.byTaskId ?? {})) { + queryClient.setQueryData(qk.integrations.github.taskCiOptions(taskId), options); + } + const appState = { ...(initialState ?? {}) }; + delete appState.taskPRs; + delete appState.taskCIAutomation; return render( - - - {ui} - - , + + + + {ui} + + + , ); } @@ -128,7 +152,7 @@ const ATTR_PR_NUMBER = "data-pr-number"; const ATTR_STATUS = "data-status"; const ATTR_READY_TO_MERGE = "data-pr-ready-to-merge"; const DRAWER_SELECTOR = "[data-testid='pr-status-chip-drawer']"; -const seededState: Partial = { +const seededState: GitHubQueryTestState = { taskPRs: { byTaskId: { "task-1": [makePR()] } }, taskCIAutomation: { byTaskId: { "task-1": makeCIOptions() }, @@ -138,7 +162,7 @@ const seededState: Partial = { }, }; -function multiState(prs: TaskPR[]): Partial { +function multiState(prs: TaskPR[]): GitHubQueryTestState { return { taskPRs: { byTaskId: { "task-1": prs } } }; } diff --git a/apps/web/components/github/pr-status-chip.tsx b/apps/web/components/github/pr-status-chip.tsx index 48f6cab8c5..875b9ac190 100644 --- a/apps/web/components/github/pr-status-chip.tsx +++ b/apps/web/components/github/pr-status-chip.tsx @@ -111,11 +111,10 @@ const CHIP_BUTTON_CLASS = "cursor-pointer inline-flex items-center gap-1 rounded-md px-1 py-0.5 text-xs"; /** - * Radix HoverCard treats the trigger as outside the content's bounding box, so - * a click on the chip would auto-close the popover. This guard filters out - * trigger clicks so clicking the chip is a no-op while the popover stays open - * via hover. Returns the trigger ref plus a memoised handler that reads the ref - * lazily (inside the callback, never during render). + * Radix treats the trigger as outside the content's bounding box, so clicking + * the chip while the popover is already open would otherwise auto-close it. + * This guard filters out trigger clicks and keeps the trigger ref lookup lazy + * (inside the callback, never during render). */ function useChipTriggerGuard() { const ref = useRef(null); @@ -331,7 +330,15 @@ function PRStatusChipHoverCard({ pr, automation }: { pr: TaskPR; automation: Aut onBlur={onTriggerLeave} > - 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 01a976a42d..6b2fb82a3a 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,31 @@ import { afterEach, describe, expect, it } from "vitest"; import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, render } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { StateProvider } from "@/components/state-provider"; import { PRTaskIcon } from "./pr-task-icon"; +import { qk } from "@/lib/query/keys"; import type { AppState } from "@/lib/state/store"; import type { TaskPR } from "@/lib/types/github"; -function renderWithStore(initialState: Partial | undefined, ui: ReactNode) { +type GitHubQueryTestState = Partial & { + taskPRs?: { byTaskId: Record }; +}; + +function renderWithStore(initialState: GitHubQueryTestState | undefined, ui: ReactNode) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + for (const [taskId, prs] of Object.entries(initialState?.taskPRs?.byTaskId ?? {})) { + queryClient.setQueryData(qk.integrations.github.taskPr(taskId), prs); + } + const appState = { ...(initialState ?? {}) }; + delete appState.taskPRs; return render( - - {ui} - , + + + {ui} + + , ); } @@ -57,8 +71,7 @@ describe("PRTaskIcon corrupted store entry", () => { // 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( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - { taskPRs: { byTaskId: { "task-1": {} as any } } } as Partial, + { taskPRs: { byTaskId: { "task-1": {} } } }, , ); expect(container.firstChild).toBeNull(); diff --git a/apps/web/components/github/pr-task-icon.tsx b/apps/web/components/github/pr-task-icon.tsx index 9e208106bf..08d8b234cc 100644 --- a/apps/web/components/github/pr-task-icon.tsx +++ b/apps/web/components/github/pr-task-icon.tsx @@ -2,8 +2,8 @@ import { IconGitPullRequest } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { cn } from "@/lib/utils"; -import { useAppStore } from "@/components/state-provider"; import type { TaskPR } from "@/lib/types/github"; const MUTED_FOREGROUND = "text-muted-foreground"; @@ -192,7 +192,7 @@ export function prStatusRank(pr: TaskPR): number { * when every PR is terminal so the popover always has something to show. */ export function pickDefaultPR(prs: TaskPR[]): TaskPR | null { - if (prs.length === 0) return null; + if (!Array.isArray(prs) || prs.length === 0) return null; let best = prs[0]; let bestRank = prStatusRank(prs[0]); for (let i = 1; i < prs.length; i++) { @@ -206,12 +206,12 @@ export function pickDefaultPR(prs: TaskPR[]): TaskPR | null { } export function PRTaskIcon({ taskId }: { taskId: string }) { - const prs = useAppStore((state) => state.taskPRs.byTaskId[taskId] ?? null); + const { prs } = useTaskPR(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 // instead of falling through into MultiPRIcon, where for-of throws. - if (!Array.isArray(prs) || prs.length === 0) return null; + if (prs.length === 0) return null; if (prs.length === 1) return ; return ; } diff --git a/apps/web/components/github/review-watch-dialog.tsx b/apps/web/components/github/review-watch-dialog.tsx index 8f81fcb876..108913d5de 100644 --- a/apps/web/components/github/review-watch-dialog.tsx +++ b/apps/web/components/github/review-watch-dialog.tsx @@ -19,6 +19,7 @@ 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 { 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 +182,7 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { items: workspaces } = useWorkspaces(); return ( state.workflows.items); 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 = settingsCatalog.agentProfiles; + const executors = settingsCatalog.executors; 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 0193a99bf4..26da3a7121 100644 --- a/apps/web/components/github/review-watch-table.tsx +++ b/apps/web/components/github/review-watch-table.tsx @@ -12,7 +12,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 = { @@ -141,7 +141,7 @@ export function ReviewWatchTable({ onReset, onToggleEnabled, }: ReviewWatchTableProps) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { items: workspaces } = useWorkspaces(); const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; if (watches.length === 0) { diff --git a/apps/web/components/gitlab/gitlab-settings.tsx b/apps/web/components/gitlab/gitlab-settings.tsx index 51db73aab4..4bc492c7c4 100644 --- a/apps/web/components/gitlab/gitlab-settings.tsx +++ b/apps/web/components/gitlab/gitlab-settings.tsx @@ -27,9 +27,9 @@ import { clearGitLabToken, configureGitLabHost, configureGitLabToken, - fetchGitLabStatus, } from "@/lib/api/domains/gitlab-api"; import type { GitLabStatus } from "@/lib/types/gitlab"; +import { useGitLabStatus } from "@/hooks/domains/gitlab/use-gitlab-status"; const DEFAULT_HOST = "https://gitlab.com"; @@ -241,24 +241,11 @@ export function GitLabIntegrationPage({ workspaceId }: GitLabIntegrationPageProp } function GitLabConnectionSection() { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(true); + const { status, loading, refresh } = useGitLabStatus(); const reload = useCallback(async () => { - setLoading(true); - try { - const next = await fetchGitLabStatus({ cache: "no-store" }); - setStatus(next); - } catch { - setStatus(null); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void reload(); - }, [reload]); + await refresh(); + }, [refresh]); return ( ({ + bootstrapImproveKandev: vi.fn(), + fetchSystemHealth: vi.fn(), + listRepositories: vi.fn(), + listWorkflowSteps: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: mocks.toast }), +})); + +vi.mock("@/components/improve-kandev-dialog-create", () => ({ + CreateModeView: ({ bootstrap }: { bootstrap: { kind: string; steps?: unknown[] } }) => ( +
+ ), +})); + +vi.mock("@/lib/api/domains/health-api", () => ({ + fetchSystemHealth: (...args: unknown[]) => mocks.fetchSystemHealth(...args), +})); + +vi.mock("@/lib/api/domains/improve-kandev-api", () => ({ + bootstrapImproveKandev: (...args: unknown[]) => mocks.bootstrapImproveKandev(...args), +})); + +vi.mock("@/lib/api/domains/workflow-api", () => ({ + listWorkflowSteps: (...args: unknown[]) => mocks.listWorkflowSteps(...args), +})); + +vi.mock("@/lib/api/domains/workspace-api", () => ({ + listBranches: vi.fn(), + listQuickChatSessions: vi.fn(), + listRepositories: (...args: unknown[]) => mocks.listRepositories(...args), + listRepositoryBranches: vi.fn(), + listRepositoryScripts: vi.fn(), + listWorkspaces: vi.fn(), +})); + +function renderDialog(queryClient: QueryClient) { + return render( + + + {}} workspaceId="ws-1" /> + + , + ); +} + +describe("ImproveKandevDialog", () => { + beforeEach(() => { + mocks.bootstrapImproveKandev.mockResolvedValue({ + repository_id: "repo-1", + workflow_id: "wf-1", + branch: "improve/example", + bundle_dir: "/tmp/bundle", + bundle_files: { + metadata: "metadata.json", + backend_log: "backend.log", + frontend_log: "frontend.log", + }, + github_login: "octo", + has_write_access: false, + fork_status: "ready", + }); + mocks.fetchSystemHealth.mockResolvedValue({ issues: [] }); + mocks.listRepositories.mockRejectedValue(new Error("raw repository fetch should not run")); + mocks.listWorkflowSteps.mockRejectedValue(new Error("raw step fetch should not run")); + mocks.toast.mockReset(); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("hydrates bootstrap steps and repositories through Query cache", async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const steps = [ + { + id: "step-1", + workflow_id: "wf-1", + name: "Improve", + position: 0, + color: "bg-blue-500", + is_start_step: true, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }, + ]; + const repositories = [{ id: "repo-link-1", repository_id: "repo-1", name: "kandev" }]; + mocks.listWorkflowSteps.mockResolvedValue({ steps }); + mocks.listRepositories.mockResolvedValue({ repositories }); + + renderDialog(queryClient); + + const proceed = await screen.findByTestId("improve-kandev-proceed"); + await waitFor(() => expect((proceed as HTMLButtonElement).disabled).toBe(false)); + fireEvent.click(proceed); + + await waitFor(() => + expect(screen.getByTestId("create-mode").getAttribute("data-bootstrap-kind")).toBe("ready"), + ); + expect(screen.getByTestId("create-mode").getAttribute("data-step-count")).toBe("1"); + expect(queryClient.getQueryData(qk.workflows.steps("wf-1"))).toEqual(steps); + expect(queryClient.getQueryData(qk.workspaces.repositories("ws-1"))).toEqual(repositories); + expect(mocks.toast).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/improve-kandev-dialog.tsx b/apps/web/components/improve-kandev-dialog.tsx index 7ab0997796..f41bd6622b 100644 --- a/apps/web/components/improve-kandev-dialog.tsx +++ b/apps/web/components/improve-kandev-dialog.tsx @@ -2,16 +2,18 @@ import Link from "@/components/routing/app-link"; import { useCallback, useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; import { Button } from "@kandev/ui/button"; import { IconAlertTriangle, IconStethoscope, IconCheck } from "@tabler/icons-react"; import { useToast } from "@/components/toast-provider"; -import { useAppStore } from "@/components/state-provider"; 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"; import { fetchSystemHealth } from "@/lib/api/domains/health-api"; +import { + workflowStepsQueryOptions, + workspaceRepositoriesQueryOptions, +} from "@/lib/query/query-options"; import type { Task } from "@/lib/types/http"; import { buildImproveKandevDescription } from "./improve-kandev-dialog-helpers"; import { CreateModeView, type BootstrapState } from "./improve-kandev-dialog-create"; @@ -142,7 +144,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; @@ -165,15 +167,16 @@ function useBootstrapKandev( return; } // Refresh the workspace repository list so the newly-created kandev - // repo is in the store; otherwise the locked repo dropdown can't - // resolve a label for the bootstrapped repository_id. - const [stepsRes, reposRes] = await Promise.all([ - listWorkflowSteps(data.workflow_id), - listRepositories(workspaceId, undefined, { cache: "no-store" }), + // repo is available to Query readers that resolve the locked repo label. + const [steps] = await Promise.all([ + queryClient.fetchQuery({ ...workflowStepsQueryOptions(data.workflow_id), staleTime: 0 }), + queryClient.fetchQuery({ + ...workspaceRepositoriesQueryOptions(workspaceId), + staleTime: 0, + }), ]); if (cancelled) return; - setRepositories(workspaceId, reposRes.repositories); - setBootstrap({ kind: "ready", data, steps: stepsRes.steps }); + setBootstrap({ kind: "ready", data, steps }); } catch (err) { if (cancelled) return; const message = err instanceof Error ? err.message : "Bootstrap failed"; @@ -188,7 +191,7 @@ function useBootstrapKandev( return () => { cancelled = true; }; - }, [mode, workspaceId, setBootstrap, setRepositories, toast]); + }, [mode, workspaceId, queryClient, setBootstrap, toast]); } function IntroBody({ diff --git a/apps/web/components/integrations/integrations-menu.test.ts b/apps/web/components/integrations/integrations-menu.test.ts index 3583cc6226..b89b2864d7 100644 --- a/apps/web/components/integrations/integrations-menu.test.ts +++ b/apps/web/components/integrations/integrations-menu.test.ts @@ -1,4 +1,5 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { createElement } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -7,13 +8,18 @@ import { IntegrationsMenu, IntegrationsTopbarLinks, } from "./integrations-menu"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { GitHubStatus } from "@/lib/types/github"; +import type { Workspace } from "@/lib/types/http"; import { TooltipProvider } from "@kandev/ui/tooltip"; const useGitHubStatusMock = vi.hoisted(() => vi.fn()); const useGitLabAvailableMock = vi.hoisted(() => vi.fn()); const useJiraAvailableMock = vi.hoisted(() => vi.fn()); const useLinearAvailableMock = vi.hoisted(() => vi.fn()); +const setActiveWorkspaceMock = vi.hoisted(() => vi.fn()); +const setActiveWorkflowMock = vi.hoisted(() => vi.fn()); const activeWorkspaceRef = vi.hoisted(() => ({ id: null as string | null, items: [] as Array<{ id: string }>, @@ -38,11 +44,15 @@ vi.mock("@/hooks/domains/linear/use-linear-availability", () => ({ vi.mock("@/components/state-provider", () => ({ useAppStore: ( selector: (state: { - workspaces: { activeId: string | null; items: Array<{ id: string }> }; + workspaces: { activeId: string | null }; + setActiveWorkspace: typeof setActiveWorkspaceMock; + setActiveWorkflow: typeof setActiveWorkflowMock; }) => unknown, ) => selector({ - workspaces: { activeId: activeWorkspaceRef.id, items: activeWorkspaceRef.items }, + workspaces: { activeId: activeWorkspaceRef.id }, + setActiveWorkspace: setActiveWorkspaceMock, + setActiveWorkflow: setActiveWorkflowMock, }), })); @@ -77,6 +87,26 @@ function mockAvailability({ useLinearAvailableMock.mockReturnValue(linearAvailable); } +function workspace(id: string): Workspace { + return { + id, + name: id, + owner_id: "user-1", + created_at: "2026-01-01T00:00:00.000Z", + updated_at: "2026-01-01T00:00:00.000Z", + } as Workspace; +} + +function renderWithQuery(component: Parameters[0]) { + const queryClient = makeQueryClient(); + queryClient.setQueryDefaults(qk.workspaces.all(), { staleTime: Infinity }); + queryClient.setQueryData( + qk.workspaces.all(), + activeWorkspaceRef.items.map((item) => workspace(item.id)), + ); + return render(createElement(QueryClientProvider, { client: queryClient }, component)); +} + afterEach(() => { cleanup(); vi.clearAllMocks(); @@ -145,7 +175,7 @@ describe("IntegrationsMenu", () => { it("opens configured integration links on hover", async () => { mockAvailability({ githubReady: true, jiraAvailable: true, linearAvailable: false }); - render(createElement(IntegrationsMenu, {})); + renderWithQuery(createElement(IntegrationsMenu, {})); const trigger = screen.getByRole("button", { name: "Integrations" }); expect(screen.queryByText("GitHub")).toBeNull(); @@ -160,7 +190,7 @@ describe("IntegrationsMenu", () => { it("does not render when no integrations are configured", () => { mockAvailability({ githubReady: false, jiraAvailable: false, linearAvailable: false }); - render(createElement(IntegrationsMenu, {})); + renderWithQuery(createElement(IntegrationsMenu, {})); expect(screen.queryByRole("button", { name: "Integrations" })).toBeNull(); }); @@ -170,7 +200,7 @@ describe("IntegrationsMenu", () => { activeWorkspaceRef.items = [{ id: "ws-active" }]; mockAvailability({ githubReady: true, jiraAvailable: true, linearAvailable: true }); - render(createElement(IntegrationsMenu, {})); + renderWithQuery(createElement(IntegrationsMenu, {})); // Jira and Linear are per-workspace: they must be scoped to the active // workspace so the sidebar reflects the workspace the user is viewing. @@ -186,7 +216,7 @@ describe("IntegrationsMenu", () => { activeWorkspaceRef.items = [{ id: "ws-remaining" }]; mockAvailability({ githubReady: false, jiraAvailable: true, linearAvailable: true }); - render(createElement(IntegrationsMenu, {})); + renderWithQuery(createElement(IntegrationsMenu, {})); expect(useJiraAvailableMock).toHaveBeenCalledWith(null); expect(useLinearAvailableMock).toHaveBeenCalledWith(null); @@ -195,7 +225,7 @@ describe("IntegrationsMenu", () => { function renderWithTooltip(component: Parameters[0]) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TooltipProvider requires children in its type but createElement passes them as 3rd arg - return render(createElement(TooltipProvider, {} as any, component)); + return renderWithQuery(createElement(TooltipProvider, {} as any, component)); } describe("IntegrationsTopbarLinks", () => { diff --git a/apps/web/components/integrations/integrations-menu.tsx b/apps/web/components/integrations/integrations-menu.tsx index b1e5221534..03d415ad78 100644 --- a/apps/web/components/integrations/integrations-menu.tsx +++ b/apps/web/components/integrations/integrations-menu.tsx @@ -22,7 +22,7 @@ import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability"; import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; import { useGitLabAvailable } from "@/hooks/domains/gitlab/use-task-mr"; -import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import type { GitHubStatus } from "@/lib/types/github"; type MobileIntegrationsSectionProps = { @@ -90,12 +90,10 @@ export function useConfiguredIntegrationLinks(): IntegrationLink[] { // fall back to a legacy default-workspace resolver that can point at the // wrong workspace, hiding a configured integration from the sidebar. GitHub // and GitLab are install-wide and don't need the workspace id. - const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); - const activeWorkspaceExists = useAppStore((s) => - s.workspaces.items.some((item) => item.id === s.workspaces.activeId), - ); + const { activeId: activeWorkspaceId, items: workspaces } = useWorkspaces(); + const activeWorkspaceExists = workspaces.some((item) => item.id === activeWorkspaceId); // Guard against a stale active id: if the active workspace was removed but - // activeId was not reconciled (e.g. setWorkspaces keeps a non-null id), + // activeId has not reconciled yet, // scoping to the deleted id would return no config and hide the links even // when another workspace is configured. Fall back to null so the backend's // default-workspace resolution applies instead. diff --git a/apps/web/components/integrations/workspace-scoped-section.test.tsx b/apps/web/components/integrations/workspace-scoped-section.test.tsx index fecb19cba5..53ddd0d49d 100644 --- a/apps/web/components/integrations/workspace-scoped-section.test.tsx +++ b/apps/web/components/integrations/workspace-scoped-section.test.tsx @@ -15,6 +15,13 @@ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), })); +vi.mock("@/hooks/domains/workspace/use-workspaces", () => ({ + useWorkspaces: () => ({ + items: state.workspaces.items, + activeId: state.workspaces.activeId, + }), +})); + import { WorkspaceScopedSection } from "./workspace-scoped-section"; describe("WorkspaceScopedSection", () => { diff --git a/apps/web/components/integrations/workspace-scoped-section.tsx b/apps/web/components/integrations/workspace-scoped-section.tsx index 152fa3c776..d12e905da5 100644 --- a/apps/web/components/integrations/workspace-scoped-section.tsx +++ b/apps/web/components/integrations/workspace-scoped-section.tsx @@ -3,6 +3,7 @@ import { 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"; type WorkspaceScopedSectionProps = { emptyMessage?: string; @@ -11,13 +12,14 @@ type WorkspaceScopedSectionProps = { }; // WorkspaceScopedSection renders per-workspace integration settings for the -// routed workspace when present, otherwise the active workspace in the store. +// routed workspace when present, otherwise the workspace currently selected +// in the top-right settings switcher. export function WorkspaceScopedSection({ emptyMessage, workspaceId, children, }: WorkspaceScopedSectionProps) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { items: workspaces } = useWorkspaces(); const activeId = useAppStore((s) => s.workspaces.activeId); const selected = workspaceId ?? activeId ?? workspaces[0]?.id ?? null; diff --git a/apps/web/components/jira/jira-issue-watch-dialog.tsx b/apps/web/components/jira/jira-issue-watch-dialog.tsx index d918d758e8..62b51d3edc 100644 --- a/apps/web/components/jira/jira-issue-watch-dialog.tsx +++ b/apps/web/components/jira/jira-issue-watch-dialog.tsx @@ -19,6 +19,7 @@ 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 { 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"; @@ -125,12 +126,11 @@ function formStateFromWatch(w: JiraIssueWatch): FormState { } function useFormData(workspaceId: string) { - useSettingsData(true); - useWorkflows(workspaceId, true); - const allWorkflows = useAppStore((s) => s.workflows.items); + const settingsCatalog = useSettingsData(true); + 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 = settingsCatalog.agentProfiles; + const executors = settingsCatalog.executors; const allExecutorProfiles = useMemo( () => executors @@ -297,7 +297,7 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { items: workspaces } = useWorkspaces(); return ( s.workspaces.items); + const { items: workspaces } = useWorkspaces(); const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; if (watches.length === 0) { diff --git a/apps/web/components/jira/jira-settings.tsx b/apps/web/components/jira/jira-settings.tsx index a8da3d377f..fe6fbf87ea 100644 --- a/apps/web/components/jira/jira-settings.tsx +++ b/apps/web/components/jira/jira-settings.tsx @@ -1,6 +1,14 @@ "use client"; -import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { IconTicket, IconCode } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; @@ -20,13 +28,9 @@ import { type IntegrationAuthHealth, } from "@/components/integrations/auth-status-banner"; import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; -import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; -import { - getJiraConfig, - setJiraConfig, - deleteJiraConfig, - testJiraConnection, -} from "@/lib/api/domains/jira-api"; +import { setJiraConfig, deleteJiraConfig, testJiraConnection } from "@/lib/api/domains/jira-api"; +import { qk } from "@/lib/query/keys"; +import { jiraConfigQueryOptions } from "@/lib/query/query-options/jira"; import type { JiraAuthMethod, JiraConfig, @@ -455,50 +459,35 @@ function ActionBar({ ); } -function useJiraConfigRefresh(workspaceId: string, setConfig: (cfg: JiraConfig | null) => void) { - // Background refresh so the auth-health banner picks up new probe results - // from the backend poller without requiring a page reload. We re-fetch the - // config rather than the loud full `load()` to avoid flashing the form. - useEffect(() => { - const id = setInterval(() => { - getJiraConfig({ workspaceId }) - .then((cfg) => setConfig(cfg)) - .catch(() => { - /* transient failures are fine — next tick retries */ - }); - }, INTEGRATION_STATUS_REFRESH_MS); - return () => clearInterval(id); - }, [workspaceId, setConfig]); -} - function useJiraSettings(workspaceId: string) { const { toast } = useToast(); + const queryClient = useQueryClient(); + const configQuery = useQuery(jiraConfigQueryOptions(workspaceId)); const [config, setConfig] = useState(null); const [form, setForm] = useState(emptyForm); - const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); + const formHydratedRef = useRef(false); const health = configToHealth(config); - const load = useCallback(async () => { - setLoading(true); - try { - const cfg = await getJiraConfig({ workspaceId }); - setConfig(cfg); + useEffect(() => { + if (!configQuery.isSuccess) return; + const cfg = configQuery.data ?? null; + setConfig(cfg); + if (!formHydratedRef.current) { setForm(configToForm(cfg)); - } catch (err) { - toast({ description: `Failed to load Jira config: ${String(err)}`, variant: "error" }); - } finally { - setLoading(false); + formHydratedRef.current = true; } - }, [workspaceId, toast]); + }, [configQuery.data, configQuery.isSuccess]); useEffect(() => { - void load(); - }, [load]); - - useJiraConfigRefresh(workspaceId, setConfig); + if (!configQuery.isError) return; + toast({ + description: `Failed to load Jira config: ${String(configQuery.error)}`, + variant: "error", + }); + }, [configQuery.error, configQuery.isError, toast]); const update = useCallback( (key: K, value: FormState[K]) => @@ -533,6 +522,7 @@ function useJiraSettings(workspaceId: string) { }, { workspaceId }, ); + queryClient.setQueryData(qk.integrations.jira.config(workspaceId), saved); setConfig(saved); setForm(configToForm(saved)); // Clear any inline test result from the previous credentials so the @@ -544,12 +534,13 @@ function useJiraSettings(workspaceId: string) { } finally { setSaving(false); } - }, [workspaceId, form, toast]); + }, [workspaceId, form, queryClient, toast]); const handleDelete = useCallback(async () => { if (!confirm("Remove Jira configuration?")) return; try { await deleteJiraConfig({ workspaceId }); + queryClient.setQueryData(qk.integrations.jira.config(workspaceId), null); setConfig(null); setForm(emptyForm); setTestResult(null); @@ -557,13 +548,13 @@ function useJiraSettings(workspaceId: string) { } catch (err) { toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); } - }, [workspaceId, toast]); + }, [workspaceId, queryClient, toast]); return { config, form, setForm, - loading, + loading: configQuery.isFetching && !configQuery.isSuccess, saving, testing, testResult, diff --git a/apps/web/components/kanban-board-grid.test.tsx b/apps/web/components/kanban-board-grid.test.tsx new file mode 100644 index 0000000000..c7bbae1761 --- /dev/null +++ b/apps/web/components/kanban-board-grid.test.tsx @@ -0,0 +1,61 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { KanbanBoardGrid } from "./kanban-board-grid"; +import type { WorkflowStep } from "./kanban-column"; + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => false, +})); + +const noop = vi.fn(); + +function renderGrid() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const steps: WorkflowStep[] = [ + { + id: "step-1", + title: "Query Ready", + color: "bg-blue-500", + }, + ]; + + return render( + + + + + , + ); +} + +afterEach(() => { + cleanup(); + noop.mockClear(); +}); + +describe("KanbanBoardGrid", () => { + it("does not show loading when Query-derived steps are present", () => { + renderGrid(); + + expect(screen.queryByText("Loading...")).toBeNull(); + expect(screen.getByTestId("kanban-column-step-1")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index 1af8723399..1d76183be6 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -271,13 +271,9 @@ function DesktopLayout({ function useShowLoading(isLoading: boolean | undefined, stepsLength: number) { const workflowsActiveId = useAppStore((state) => state.workflows.activeId); - const kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId); - return ( - isLoading === true || - (workflowsActiveId && !kanbanWorkflowId) || - (isLoading === undefined && stepsLength === 0 && !workflowsActiveId) - ); + if (isLoading !== undefined) return isLoading; + return stepsLength === 0 && !workflowsActiveId; } function useKanbanBoardSensors() { diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 68002dfcd0..3c54999b78 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -6,8 +6,8 @@ import { Task } from "./kanban-card"; import { TaskCreateDialog } from "./task-create-dialog"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; 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"; +import type { WorkflowItem, WorkflowsState } from "@/lib/state/slices"; +import { type MoveTaskError } from "@/lib/kanban/move-task-error"; import { SwimlaneContainer } from "./kanban/swimlane-container"; import { KanbanHeader } from "./kanban/kanban-header"; import { MobileFab } from "./kanban/mobile-fab"; @@ -42,15 +42,13 @@ function useWorkflowSelection({ workflowsState, commitSettings, setActiveWorkflow, - setWorkflows, }: { store: ReturnType; userSettings: { workflowId?: string | null }; workspaceState: { activeId: string | null }; - workflowsState: WorkflowsState; + workflowsState: WorkflowsState & { items: WorkflowItem[] }; commitSettings: unknown; setActiveWorkflow: (id: string | null) => void; - setWorkflows: (workflows: WorkflowsState["items"]) => void; }) { const searchParams = useSearchParams(); const routeWorkflowId = searchParams.get("workflowId"); @@ -62,15 +60,14 @@ function useWorkflowSelection({ useEffect(() => { const workspaceId = workspaceState.activeId; if (!workspaceId) { - if (workflowsState.items.length || workflowsState.activeId) { - setWorkflows([]); + if (workflowsState.activeId) { setActiveWorkflow(null); } return; } const settings = userSettingsRef.current; const workspaceWorkflows = workflowsState.items.filter( - (workflow: WorkflowsState["items"][number]) => workflow.workspaceId === workspaceId, + (workflow) => workflow.workspaceId === workspaceId, ); const desiredWorkflowId = resolveDesiredWorkflowId({ @@ -79,17 +76,11 @@ function useWorkflowSelection({ workspaceWorkflows, }); setActiveWorkflow(desiredWorkflowId); - if (!desiredWorkflowId) { - store.getState().hydrate({ - kanban: { workflowId: null, steps: [], tasks: [] }, - }); - } }, [ workflowsState.activeId, workflowsState.items, commitSettings, setActiveWorkflow, - setWorkflows, store, routeWorkflowId, workspaceState.activeId, @@ -121,19 +112,15 @@ function useMoveErrorState(router: ReturnType) { function useKanbanBoardStore() { const store = useAppStoreApi(); const kanbanViewMode = useAppStore((state) => state.userSettings.kanbanViewMode); - const kanban = useAppStore((state) => state.kanban); const workspaceState = useAppStore((state) => state.workspaces); const workflowsState = useAppStore((state) => state.workflows); const setActiveWorkflow = useAppStore((state) => state.setActiveWorkflow); - const setWorkflows = useAppStore((state) => state.setWorkflows); return { store, kanbanViewMode, - kanban, workspaceState, workflowsState, setActiveWorkflow, - setWorkflows, }; } @@ -165,12 +152,19 @@ function useKanbanBoardHooks( deletingTaskId, archivingTaskId, } = useKanbanActions({ workspaceState, workflowsState }); - const { enablePreviewOnClick, userSettings, commitSettings, activeSteps, isMounted } = - useKanbanData({ - onWorkspaceChange: handleWorkspaceChange, - onWorkflowChange: handleWorkflowChange, - searchQuery, - }); + const { + enablePreviewOnClick, + boardState, + userSettings, + commitSettings, + activeSteps, + isMounted, + workflowsState: queryWorkflowsState, + } = useKanbanData({ + onWorkspaceChange: handleWorkspaceChange, + onWorkflowChange: handleWorkflowChange, + searchQuery, + }); return { isDialogOpen, editingTask, @@ -185,8 +179,10 @@ function useKanbanBoardHooks( deletingTaskId, archivingTaskId, enablePreviewOnClick, + boardState, userSettings, commitSettings, + workflowsState: queryWorkflowsState, activeSteps, isMounted, }; @@ -238,17 +234,10 @@ function useKanbanBoardSetup( const router = useRouter(); const { isMobile } = useResponsiveBreakpoint(); const [searchQuery, setSearchQuery] = useState(""); - const { - store, - kanbanViewMode, - kanban, - workspaceState, - workflowsState, - setActiveWorkflow, - setWorkflows, - } = useKanbanBoardStore(); + const { store, kanbanViewMode, workspaceState, workflowsState, setActiveWorkflow } = + useKanbanBoardStore(); - useAllWorkflowSnapshots(workspaceState.activeId); + const allWorkflowSnapshots = useAllWorkflowSnapshots(workspaceState.activeId); useWorkspacePRs(workspaceState.activeId); useWorkspaceMRs(workspaceState.activeId); @@ -269,12 +258,14 @@ function useKanbanBoardSetup( [onBeforeEdit, handleEdit], ); - const multiSelect = useTaskMultiSelect(kanban.workflowId); + const multiSelect = useTaskMultiSelect( + hooks.boardState.workflowId, + allWorkflowSnapshots.snapshots, + ); const { isMultiSelectMode, toggleSelect } = multiSelect; - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); const { isMixedWorkflowSelection, multiSelectSteps } = useMultiSelectDerived( multiSelect.selectedIds, - snapshots, + allWorkflowSnapshots.snapshots, hooks.activeSteps, ); @@ -301,18 +292,15 @@ function useKanbanBoardSetup( store, userSettings: hooks.userSettings, workspaceState, - workflowsState, + workflowsState: hooks.workflowsState, commitSettings: hooks.commitSettings, setActiveWorkflow, - setWorkflows, }); return { isMobile, kanbanViewMode, - kanban, workspaceState, - workflowsState, searchQuery, setSearchQuery, ...hooks, @@ -325,6 +313,7 @@ function useKanbanBoardSetup( multiSelect, isMixedWorkflowSelection, multiSelectSteps, + allWorkflowSnapshots, }; } @@ -369,7 +358,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB isDialogOpen={s.isDialogOpen} handleDialogOpenChange={s.handleDialogOpenChange} workspaceId={s.workspaceState.activeId} - workflowId={s.kanban.workflowId} + workflowId={s.boardState.workflowId} defaultStepId={s.activeSteps[0]?.id ?? null} stepOptions={stepOptions} editingTask={s.editingTask} @@ -379,6 +368,8 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB handleGoToTask={s.handleGoToTask} /> ({ + PRTaskIcon: () => null, +})); + +const CREATED_AT = "2026-06-24T00:00:00Z"; +const PARENT_TASK_ID = toTaskId("parent-1"); +const WORKSPACE_ID = toWorkspaceId("workspace-1"); +const WORKFLOW_ID = toWorkflowId("workflow-1"); + +function queryClientWithParent() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(qk.tasks.detail(PARENT_TASK_ID), parentTask()); + return client; +} + +function parentTask(): HttpTask { + return { + id: PARENT_TASK_ID, + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: "step-1", + position: 0, + title: "Query parent task", + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function subtask(): Task { + return { + id: "task-1", + workflowStepId: "step-1", + title: "Subtask", + description: "", + position: 0, + state: "TODO", + parentTaskId: "parent-1", + }; +} + +function renderBody() { + return render( + + + + + , + ); +} + +afterEach(() => cleanup()); + +describe("KanbanCardBody", () => { + it("uses Query task detail for the subtask parent badge when legacy kanban is empty", () => { + renderBody(); + + expect(screen.getByText("Query parent task")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx index 4395c0c914..aa895e973c 100644 --- a/apps/web/components/kanban-card-content.tsx +++ b/apps/web/components/kanban-card-content.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { CSS, type Transform } from "@dnd-kit/utilities"; import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core"; import { @@ -20,10 +20,9 @@ import { KanbanCardDropdownMenuItems, type KanbanCardMenuEntry, } from "@/components/kanban-card-menu-items"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification"; -import { createDebugLogger, isDebug } from "@/lib/debug/log"; import { getTaskStateIcon, shouldShowTaskRunningSpinner, @@ -33,8 +32,6 @@ import { cn } from "@/lib/utils"; import { needsAction } from "@/lib/utils/needs-action"; import type { RepositoryChip, Task } from "@/components/kanban-card"; -const kanbanStatusDebug = createDebugLogger("kanban:task-status"); - type KanbanCardActionProps = { task: Task; showMaximizeButton?: boolean; @@ -167,10 +164,8 @@ 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 parentTask = useTaskById(task.parentTaskId); + const parentTitle = parentTask?.title ?? null; const showRow = (task.sessionCount && task.sessionCount > 1) || @@ -220,9 +215,6 @@ function KanbanCardActions({ isArchiving, }: KanbanCardActionProps) { const [menuOpen, setMenuOpen] = useState(false); - const [storePrimarySessionState, setStorePrimarySessionState] = useState(null); - const storeApi = useAppStoreApi(); - const debugEnabled = isDebug(); const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving); const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId, { primarySessionState: task.primarySessionState, @@ -230,14 +222,6 @@ function KanbanCardActions({ }); const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest); const showRunningSpinner = shouldShowTaskRunningSpinner(task.state, task.primarySessionState); - const storeWouldShowRunningSpinner = - storePrimarySessionState === null - ? null - : shouldShowTaskRunningSpinner(task.state, storePrimarySessionState); - const hasSpinnerMismatch = - showRunningSpinner && - storeWouldShowRunningSpinner === false && - task.primarySessionState !== storePrimarySessionState; const statusIcon = showRunningSpinner ? ( ) : ( @@ -246,45 +230,6 @@ function KanbanCardActions({ const hasKnownSession = Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0); - useEffect(() => { - if (!debugEnabled || !task.primarySessionId) { - setStorePrimarySessionState(null); - return; - } - - const primarySessionId = task.primarySessionId; - const readPrimarySessionState = () => - storeApi.getState().taskSessions.items[primarySessionId]?.state ?? null; - const syncPrimarySessionState = () => { - const nextState = readPrimarySessionState(); - setStorePrimarySessionState((current) => (current === nextState ? current : nextState)); - }; - - syncPrimarySessionState(); - return storeApi.subscribe(syncPrimarySessionState); - }, [debugEnabled, storeApi, task.primarySessionId]); - - useEffect(() => { - if (!hasSpinnerMismatch || !debugEnabled) return; - kanbanStatusDebug("spinner mismatch", { - task_id: task.id, - taskState: task.state ?? "-", - primarySessionId: task.primarySessionId ?? "-", - taskPrimarySessionState: task.primarySessionState ?? "-", - storePrimarySessionState: storePrimarySessionState ?? "-", - showSpinner: showRunningSpinner, - }); - }, [ - debugEnabled, - hasSpinnerMismatch, - showRunningSpinner, - storePrimarySessionState, - task.id, - task.primarySessionId, - task.primarySessionState, - task.state, - ]); - return (
{(showRunningSpinner || showQuestionIcon) && statusIcon} diff --git a/apps/web/components/kanban-card-menu-items.test.tsx b/apps/web/components/kanban-card-menu-items.test.tsx index 61084d9408..bc5bd233b0 100644 --- a/apps/web/components/kanban-card-menu-items.test.tsx +++ b/apps/web/components/kanban-card-menu-items.test.tsx @@ -1,12 +1,103 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu"; +import { qk } from "@/lib/query/keys"; +import { + taskId, + workflowId, + workspaceId, + type Task, + type Workflow, + type WorkflowSnapshot, +} from "@/lib/types/http"; import { buildKanbanCardMenuEntries, KanbanCardDropdownMenuItems, + useKanbanCardMoveTargets, type KanbanCardMenuEntry, } from "./kanban-card-menu-items"; +const WORKSPACE_ID = workspaceId("workspace-1"); +const CURRENT_WORKFLOW_ID = workflowId("workflow-current"); +const TARGET_WORKFLOW_ID = workflowId("workflow-target"); +const TASK_ID = taskId("task-1"); +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +function queryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function workflow(id: string, name: string): Workflow { + return { + id: workflowId(id), + workspace_id: WORKSPACE_ID, + name, + sort_order: 0, + hidden: false, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function task(id: string, workflow: Workflow, stepId: string): Task { + return { + id: taskId(id), + workspace_id: WORKSPACE_ID, + workflow_id: workflow.id, + workflow_step_id: stepId, + position: 1, + title: "Cached task", + description: "", + state: "CREATED", + priority: 0, + repositories: [], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function snapshot( + workflowItem: Workflow, + steps: Array<{ id: string; name: string; position: number; autoStart?: boolean }>, + tasks: Task[], +): WorkflowSnapshot { + return { + workflow: workflowItem, + steps: steps.map((step) => ({ + id: step.id, + workflow_id: workflowItem.id, + name: step.name, + position: step.position, + color: "bg-sky-500", + events: step.autoStart ? { on_enter: [{ type: "start_agent" }] } : undefined, + allow_manual_move: true, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + })), + tasks, + } as WorkflowSnapshot; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + // Regression: React synthetic events bubble through the fiber tree from a Radix portal; without stopPropagation the parent Card's onClick fires instead of the confirm dialog. describe("KanbanCardDropdownMenuItems — click propagation", () => { function renderWithParent(entries: KanbanCardMenuEntry[], parentOnClick: () => void) { @@ -130,3 +221,47 @@ describe("buildKanbanCardMenuEntries — external issue links", () => { expect(itemLabels(linkMenu)).toEqual(["GitHub Pull Request", "GitHub Issue", "Jira Ticket"]); }); }); + +describe("useKanbanCardMoveTargets", () => { + it("builds workflow and step move targets from Query snapshots without a legacy kanban store", () => { + const client = queryClient(); + const currentWorkflow = workflow(CURRENT_WORKFLOW_ID, "Current"); + const targetWorkflow = workflow(TARGET_WORKFLOW_ID, "Target"); + client.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [ + currentWorkflow, + targetWorkflow, + ]); + client.setQueryData( + qk.workflows.snapshot(CURRENT_WORKFLOW_ID), + snapshot( + currentWorkflow, + [ + { id: "current-1", name: "Todo", position: 0 }, + { id: "current-2", name: "Review", position: 1, autoStart: true }, + ], + [task(TASK_ID, currentWorkflow, "current-1")], + ), + ); + client.setQueryData( + qk.workflows.snapshot(TARGET_WORKFLOW_ID), + snapshot(targetWorkflow, [{ id: "target-1", name: "Target Step", position: 0 }], []), + ); + + const { result } = renderHook(() => useKanbanCardMoveTargets(TASK_ID), { + wrapper: wrapperFor(client), + }); + + expect(result.current.currentWorkflowId).toBe(CURRENT_WORKFLOW_ID); + expect(result.current.workflowItems.map((item) => item.id)).toEqual([ + CURRENT_WORKFLOW_ID, + TARGET_WORKFLOW_ID, + ]); + expect(result.current.stepsByWorkflowId[CURRENT_WORKFLOW_ID]).toMatchObject([ + { id: "current-1", title: "Todo" }, + { id: "current-2", title: "Review", events: { on_enter: [{ type: "start_agent" }] } }, + ]); + expect(result.current.stepsByWorkflowId[TARGET_WORKFLOW_ID]).toMatchObject([ + { id: "target-1", title: "Target Step" }, + ]); + }); +}); diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index 4afa98a50b..cd9fd8a3fa 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -1,6 +1,7 @@ "use client"; -import { useMemo, type ReactNode } from "react"; +import { useCallback, useMemo, useRef, useSyncExternalStore, type ReactNode } from "react"; +import { useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query"; import { IconArchive, IconArrowRight, @@ -29,7 +30,7 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from "@kandev/ui/dropdown-menu"; -import { useAppStore } from "@/components/state-provider"; +import { useAllCachedWorkflows } from "@/hooks/use-workflow-cache"; import type { WorkflowStep } from "@/components/kanban-card"; import { stepHasAutoStart, @@ -37,6 +38,7 @@ import { type TaskMoveWorkflow, } from "@/components/task/task-move-context-menu"; import { cn } from "@/lib/utils"; +import type { WorkflowSnapshot } from "@/lib/types/http"; type ItemEntry = { kind: "item"; @@ -72,6 +74,13 @@ export type KanbanCardMoveTargets = { stepsByWorkflowId: Record; }; +type WorkflowSnapshotCache = { + signature: string; + snapshots: WorkflowSnapshot[]; +}; + +const EMPTY_SNAPSHOTS: WorkflowSnapshot[] = []; + type BuildKanbanCardMenuEntriesArgs = { currentWorkflowId?: string | null; currentStepId?: string | null; @@ -406,16 +415,73 @@ export function buildKanbanCardMenuEntries({ return entries; } +function isWorkflowSnapshotQueryKey(key: QueryKey): boolean { + return Array.isArray(key) && key[0] === "workflows" && key[2] === "snapshot"; +} + +function isWorkflowSnapshot(value: unknown): value is WorkflowSnapshot { + return ( + typeof value === "object" && + value !== null && + "workflow" in value && + "steps" in value && + "tasks" in value && + Array.isArray((value as { tasks?: unknown }).tasks) + ); +} + +function readWorkflowSnapshots(client: QueryClient): WorkflowSnapshotCache { + const queries = client + .getQueryCache() + .findAll() + .filter((query) => isWorkflowSnapshotQueryKey(query.queryKey)) + .sort((a, b) => a.queryHash.localeCompare(b.queryHash)); + const snapshots = queries + .map((query) => query.state.data) + .filter((data): data is WorkflowSnapshot => isWorkflowSnapshot(data)); + + return { + signature: queries + .map( + (query) => `${query.queryHash}:${query.state.dataUpdatedAt}:${query.state.dataUpdateCount}`, + ) + .join("|"), + snapshots, + }; +} + +function useCachedWorkflowSnapshots(): WorkflowSnapshot[] { + const queryClient = useQueryClient(); + const snapshotRef = useRef({ + signature: "", + snapshots: EMPTY_SNAPSHOTS, + }); + const getSnapshot = useCallback(() => { + const snapshot = readWorkflowSnapshots(queryClient); + if (snapshot.signature === snapshotRef.current.signature) { + return snapshotRef.current.snapshots; + } + snapshotRef.current = snapshot; + return snapshot.snapshots; + }, [queryClient]); + + return useSyncExternalStore( + (onStoreChange) => queryClient.getQueryCache().subscribe(onStoreChange), + getSnapshot, + () => EMPTY_SNAPSHOTS, + ); +} + export function useKanbanCardMoveTargets( taskId: string, steps?: WorkflowStep[], ): KanbanCardMoveTargets { - const workflows = useAppStore((state) => state.workflows.items); - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); + const workflows = useAllCachedWorkflows(); + const snapshots = useCachedWorkflowSnapshots(); const currentWorkflowId = useMemo(() => { - for (const [workflowId, snapshot] of Object.entries(snapshots)) { - if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId; + for (const snapshot of snapshots) { + if (snapshot.tasks.some((task) => task.id === taskId)) return snapshot.workflow.id; } return null; }, [snapshots, taskId]); @@ -429,13 +495,13 @@ export function useKanbanCardMoveTargets( const stepsByWorkflowId = useMemo>(() => { const result: Record = {}; - for (const [workflowId, snapshot] of Object.entries(snapshots)) { - result[workflowId] = snapshot.steps + for (const snapshot of snapshots) { + result[snapshot.workflow.id] = snapshot.steps .slice() .sort((a, b) => a.position - b.position) .map((step) => ({ id: step.id, - title: step.title, + title: step.name, color: step.color, events: step.events, })); diff --git a/apps/web/components/kanban-card-preview.tsx b/apps/web/components/kanban-card-preview.tsx index 2da50e4a84..de7f051069 100644 --- a/apps/web/components/kanban-card-preview.tsx +++ b/apps/web/components/kanban-card-preview.tsx @@ -8,8 +8,7 @@ import { type RepositoryChip, type Task, } from "@/components/kanban-card"; -import { useAppStore } from "@/components/state-provider"; -import type { Repository } from "@/lib/types/http"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; function KanbanCardPreviewLayout({ task, @@ -31,14 +30,10 @@ function KanbanCardPreviewLayout({ } export function KanbanCardPreview({ task }: { task: Task }) { - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const repositories = useAllCachedRepositories(); const repositoryChips = useMemo( - () => - resolveTaskRepositoryChips( - task, - Object.values(repositoriesByWorkspace).flat() as Repository[], - ), - [repositoriesByWorkspace, task], + () => resolveTaskRepositoryChips(task, repositories), + [repositories, task], ); return ; diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 692d383968..2cae2dd241 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -1,7 +1,8 @@ "use client"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { useDraggable } from "@dnd-kit/core"; +import { useQueryClient } from "@tanstack/react-query"; import { KanbanCardContextMenu } from "@/components/kanban-card-context-menu"; import { KanbanCardShell } from "@/components/kanban-card-content"; import { @@ -18,8 +19,10 @@ import { import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability"; import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog"; import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog"; +import { useCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; -import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select"; +import { sortIdsByCreatedDesc } from "@/lib/kanban/task-order"; +import { workflowSnapshotQueryData } from "@/lib/query/workflow-snapshot-cache"; import { repositorySlug } from "@/lib/repository-slug"; import { formatUserHomePath } from "@/lib/utils"; import { @@ -101,6 +104,34 @@ interface KanbanCardProps { isMultiSelectMode?: boolean; } +function useKanbanCardBulkMoveHelpers(currentWorkflowId: string | null) { + const queryClient = useQueryClient(); + const sortByDisplayOrder = useCallback( + (ids: string[]) => { + const taskById = new Map(); + for (const snapshot of workflowSnapshotQueryData(queryClient)) { + for (const snapshotTask of snapshot.tasks) { + taskById.set(snapshotTask.id, { createdAt: snapshotTask.created_at }); + } + } + return sortIdsByCreatedDesc(ids, taskById); + }, + [queryClient], + ); + const getWorkflowIdForTask = useCallback( + (taskId: string) => { + for (const snapshot of workflowSnapshotQueryData(queryClient)) { + if (snapshot.tasks.some((snapshotTask) => snapshotTask.id === taskId)) { + return snapshot.workflow.id; + } + } + return currentWorkflowId; + }, + [currentWorkflowId, queryClient], + ); + return { sortByDisplayOrder, getWorkflowIdForTask }; +} + function useKanbanCardMoveMenuActions({ task, steps, @@ -110,7 +141,9 @@ function useKanbanCardMoveMenuActions({ }: Pick) { const moveTargets = useKanbanCardMoveTargets(task.id, steps); const moveTasks = useTaskWorkflowMove(); - const { sortByDisplayOrder, getWorkflowIdForTask } = useTaskMultiSelectStore(); + const { sortByDisplayOrder, getWorkflowIdForTask } = useKanbanCardBulkMoveHelpers( + moveTargets.currentWorkflowId, + ); const runMoveTasks = (taskIds: string[], workflowId: string, stepId: string) => { void moveTasks(taskIds, workflowId, stepId).catch(() => { @@ -346,9 +379,7 @@ export function dispatchKanbanCardClick( function useActiveWorkspaceRepositories() { const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); - return useAppStore((state) => - activeWorkspaceId ? (state.repositories.itemsByWorkspaceId[activeWorkspaceId] ?? []) : [], - ); + return useCachedRepositories(activeWorkspaceId); } function KanbanCardFrame({ diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index 6099c8b51c..0b7b9cd7a1 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -7,8 +7,8 @@ import { Badge } from "@kandev/ui/badge"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; import type { KanbanExternalLinkAvailability } from "./kanban-external-link-availability"; -import type { Repository } from "@/lib/types/http"; import { formatWipCount, isOverWipLimit } from "@/lib/kanban/wip-limit"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; export interface WorkflowStep { id: string; @@ -69,12 +69,7 @@ export function KanbanColumn({ }); const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); - // 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], - ); + const repositories = useAllCachedRepositories(); // Ordered ids of the cards rendered in this column — the source of truth for // shift-click range selection (matches exactly what the user sees). diff --git a/apps/web/components/kanban-display-dropdown.tsx b/apps/web/components/kanban-display-dropdown.tsx index c2a49e6f60..96927db52a 100644 --- a/apps/web/components/kanban-display-dropdown.tsx +++ b/apps/web/components/kanban-display-dropdown.tsx @@ -13,7 +13,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { IconAdjustmentsHorizontal } from "@tabler/icons-react"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import type { Repository } from "@/lib/types/http"; -import type { WorkflowsState } from "@/lib/state/slices"; +import type { WorkflowItem } from "@/lib/state/slices"; import type { ComponentProps } from "react"; type KanbanDisplayDropdownProps = { @@ -35,7 +35,7 @@ function WorkflowSection({ onWorkflowChange, }: { activeWorkflowId: string | null; - workflows: WorkflowsState["items"]; + workflows: WorkflowItem[]; onWorkflowChange: (id: string | null) => void; }) { return ( @@ -50,7 +50,7 @@ function WorkflowSection({ All Workflows - {workflows.map((workflow: WorkflowsState["items"][number]) => ( + {workflows.map((workflow) => ( {workflow.name} diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 5d75d715c0..8fa85c0d29 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -18,10 +18,11 @@ import { useTaskSession } from "@/hooks/use-task-session"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAppStore } from "@/components/state-provider"; import { Task } from "./kanban-card"; -import type { KanbanState } from "@/lib/state/slices"; +import type { WorkflowSnapshotData } from "@/lib/state/slices"; import { PREVIEW_PANEL } from "@/lib/settings/constants"; import { linkToTask } from "@/lib/links"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { useEnsureTaskSession, type UseEnsureTaskSessionResult, @@ -154,18 +155,11 @@ function useSessionSelectionReset( function useSelectedTask( selectedTaskId: string | null | undefined, - kanbanTasks: KanbanState["tasks"], - snapshots: Record, + snapshots: Record, ) { return useMemo(() => { if (!selectedTaskId) return null; - // The active workflow's tasks live in `kanban.tasks`, but cards from other - // workflows can also appear in the board (multi-workflow swimlane view via - // `kanbanMulti.snapshots`). Fall back to those so cross-workflow previews - // are not auto-closed by the "task no longer exists" guard below. - const task = - kanbanTasks.find((t: KanbanState["tasks"][number]) => t.id === selectedTaskId) ?? - findTaskInSnapshots(selectedTaskId, snapshots); + const task = findTaskInSnapshots(selectedTaskId, snapshots); if (!task) return null; return { id: task.id, @@ -177,7 +171,7 @@ function useSelectedTask( repositoryId: task.repositoryId, primarySessionId: task.primarySessionId, }; - }, [selectedTaskId, kanbanTasks, snapshots]); + }, [selectedTaskId, snapshots]); } function useCloseMissingSelectedTask(params: { @@ -246,17 +240,15 @@ 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 kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId); - const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false); - const kanbanMultiSnapshots = useAppStore((state) => state.kanbanMulti.snapshots); + const workspaceId = useAppStore((state) => state.workspaces.activeId); + const activeWorkflowId = useAppStore((state) => state.workflows.activeId); const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); const setKanbanPreviewedTaskId = useAppStore((state) => state.setKanbanPreviewedTaskId); + const allWorkflowSnapshots = useAllWorkflowSnapshots(workspaceId); const hasLoadedTaskSources = hasLoadedKanbanTaskSources({ - activeWorkflowId: kanbanWorkflowId, - multiSnapshotCount: Object.keys(kanbanMultiSnapshots).length, + activeWorkflowId, + multiSnapshotCount: Object.keys(allWorkflowSnapshots.snapshots).length, }); const { selectedTaskId, isOpen, previewWidthPx, open, close, updatePreviewWidth } = @@ -290,14 +282,14 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit // Track resize state const isResizingRef = useRef(false); - const selectedTask = useSelectedTask(selectedTaskId, kanbanTasks, kanbanMultiSnapshots); + const selectedTask = useSelectedTask(selectedTaskId, allWorkflowSnapshots.snapshots); useCloseMissingSelectedTask({ isOpen, selectedTaskId, selectedTask, initialTaskId, - kanbanIsLoading, + kanbanIsLoading: allWorkflowSnapshots.isLoading, hasLoadedTaskSources, close, }); diff --git a/apps/web/components/kanban/graph2-task-pipeline.tsx b/apps/web/components/kanban/graph2-task-pipeline.tsx index fe5dc7e37a..95b3d161a4 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 { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; 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 = useAllCachedRepositories(); 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((repo) => repo.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 a0fd78235c..33aac6caa5 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -21,7 +21,7 @@ import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { linkToTasks } from "@/lib/links"; import { cn } from "@/lib/utils"; import type { Repository } from "@/lib/types/http"; -import type { WorkflowsState } from "@/lib/state/slices"; +import type { WorkflowItem } from "@/lib/state/slices"; type MobileMenuSheetProps = { open: boolean; @@ -56,7 +56,7 @@ function getMobileViewValue(currentPage: string, kanbanViewMode: string | null): type MobileDisplayOptionsProps = { activeWorkflowId: string | null; - workflows: WorkflowsState["items"]; + workflows: WorkflowItem[]; onWorkflowChange: (id: string | null) => void; repositoryValue: string; repositories: Repository[]; @@ -87,7 +87,7 @@ function MobileDisplaySelects({ - {workflows.map((workflow: WorkflowsState["items"][number]) => ( + {workflows.map((workflow) => ( {workflow.name} diff --git a/apps/web/components/kanban/swimlane-container.tsx b/apps/web/components/kanban/swimlane-container.tsx index e22dd53def..c459f706be 100644 --- a/apps/web/components/kanban/swimlane-container.tsx +++ b/apps/web/components/kanban/swimlane-container.tsx @@ -16,8 +16,10 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { useAppStore } from "@/components/state-provider"; +import { useQueryClient } from "@tanstack/react-query"; import { useSwimlaneCollapse } from "@/hooks/domains/kanban/use-swimlane-collapse"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; +import { reorderCachedWorkflows, useAllCachedWorkflows } from "@/hooks/use-workflow-cache"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { filterTasksByRepositories, mapSelectedRepositoryIds } from "@/lib/kanban/filters"; import { reorderWorkflows } from "@/lib/api"; @@ -28,11 +30,12 @@ import { type ViewContentProps, } 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 { MoveTaskError } from "@/lib/kanban/move-task-error"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; export type SwimlaneContainerProps = { + snapshots: Record; + snapshotsLoading: boolean; viewMode: string; workflowFilter: string | null; onPreviewTask: (task: Task) => void; @@ -183,12 +186,11 @@ function WorkflowItemContent({ } function useWorkflowReorder( - orderedWorkflows: { id: string; name: string }[], + orderedWorkflows: { id: string; name: string; workspaceId?: string }[], workflowFilter: string | null, ) { - const reorderWorkflowItems = useAppStore((state) => state.reorderWorkflowItems); - const workflows = useAppStore((state) => state.workflows.items); - const workspaceId = workflows[0]?.workspaceId; + const queryClient = useQueryClient(); + const workspaceId = orderedWorkflows[0]?.workspaceId; const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); const canSort = !workflowFilter && orderedWorkflows.length > 1; @@ -200,34 +202,30 @@ 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)); + const workflowIds = reordered.map((wf) => wf.id); if (workspaceId) { - reorderWorkflows( - workspaceId, - reordered.map((wf) => wf.id), - ).catch(() => {}); + reorderCachedWorkflows(queryClient, workspaceId, workflowIds); + reorderWorkflows(workspaceId, workflowIds).catch(() => { + void queryClient.invalidateQueries({ queryKey: ["workflows", workspaceId] }); + }); } }, - [orderedWorkflows, reorderWorkflowItems, workspaceId], + [orderedWorkflows, queryClient, workspaceId], ); return { sensors, canSort, handleDragEnd }; } function useSwimlaneData( + snapshots: Record, + snapshotsLoading: boolean, workflowFilter: string | null | undefined, 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 repositories = useMemo( - () => Object.values(repositoriesByWorkspace).flat() as Repository[], - [repositoriesByWorkspace], - ); + const workflows = useAllCachedWorkflows(); + const isLoading = snapshotsLoading; + const repositories = useAllCachedRepositories(); const repoFilter = useMemo( () => mapSelectedRepositoryIds(repositories, selectedRepositoryIds), [repositories, selectedRepositoryIds], @@ -250,6 +248,8 @@ function useSwimlaneData( } export function SwimlaneContainer({ + snapshots, + snapshotsLoading, viewMode, workflowFilter, onPreviewTask, @@ -271,7 +271,9 @@ export function SwimlaneContainer({ }: SwimlaneContainerProps) { const { isMobile } = useResponsiveBreakpoint(); const { isCollapsed, toggleCollapse } = useSwimlaneCollapse(); - const { snapshots, isLoading, orderedWorkflows, getFilteredTasks } = useSwimlaneData( + const { isLoading, orderedWorkflows, getFilteredTasks } = useSwimlaneData( + snapshots, + snapshotsLoading, workflowFilter, selectedRepositoryIds, searchQuery ?? "", diff --git a/apps/web/components/kanban/swimlane-graph-content.tsx b/apps/web/components/kanban/swimlane-graph-content.tsx deleted file mode 100644 index bfeb381d6b..0000000000 --- a/apps/web/components/kanban/swimlane-graph-content.tsx +++ /dev/null @@ -1,389 +0,0 @@ -"use client"; - -import { useCallback, useMemo, useState } from "react"; -import { - DndContext, - DragEndEvent, - DragOverlay, - DragStartEvent, - PointerSensor, - useSensor, - useSensors, - useDroppable, - useDraggable, - type Modifier, -} from "@dnd-kit/core"; -import { cn } from "@kandev/ui/lib/utils"; -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 type { Task } from "@/components/kanban-card"; -import type { WorkflowStep } from "@/components/kanban-column"; -import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; -import type { KanbanState } from "@/lib/state/slices/kanban/types"; -import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification"; -import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; - -export type SwimlaneGraphContentProps = { - workflowId: string; - steps: WorkflowStep[]; - tasks: Task[]; - onPreviewTask: (task: Task) => void; - onOpenTask?: (task: Task) => void; - onEditTask?: (task: Task) => void; - onDeleteTask?: (task: Task) => void; - onMoveError?: (error: MoveTaskError) => void; - deletingTaskId?: string | null; -}; - -function DroppableStepZone({ - stepId, - isDragging, - children, -}: { - stepId: string; - isDragging: boolean; - children: React.ReactNode; -}) { - const { setNodeRef, isOver } = useDroppable({ id: stepId }); - return ( -
- {children} -
- ); -} - -function DraggableTaskChip({ - task, - onPreviewTask, -}: { - task: Task; - onPreviewTask: (task: Task) => void; -}) { - const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ - id: task.id, - }); - const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id); - const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId, { - primarySessionState: task.primarySessionState, - primarySessionPendingAction: task.primarySessionPendingAction, - }); - const statusIcon = getTaskStateIcon(task.state, "h-3 w-3", hasPendingClarificationRequest); - - return ( - - ); -} - -function TaskChipPreview({ task }: { task: Task }) { - const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId, { - primarySessionState: task.primarySessionState, - primarySessionPendingAction: task.primarySessionPendingAction, - }); - const statusIcon = getTaskStateIcon(task.state, "h-3 w-3", hasPendingClarificationRequest); - return ( -
-
{statusIcon}
- {task.title} -
- ); -} - -type SwimlaneGraphDndOptions = { - tasks: Task[]; - steps: WorkflowStep[]; - workflowId: string; - onMoveError?: (error: MoveTaskError) => void; -}; - -async function moveTaskAcrossSwimlaneSteps({ - task, - taskId, - targetColumnId, - workflowId, - store, - moveTaskById, - onMoveError, -}: { - task: Task; - taskId: string; - targetColumnId: string; - workflowId: string; - store: ReturnType; - moveTaskById: ReturnType["moveTaskById"]; - onMoveError?: (error: MoveTaskError) => void; -}) { - const state = store.getState(); - const snapshot = state.kanbanMulti.snapshots[workflowId]; - if (!snapshot) return; - - const targetTasks = snapshot.tasks - .filter( - (t: KanbanState["tasks"][number]) => t.workflowStepId === targetColumnId && t.id !== taskId, - ) - .sort( - (a: KanbanState["tasks"][number], b: KanbanState["tasks"][number]) => a.position - b.position, - ); - const nextPosition = targetTasks.length; - const originalTasks = snapshot.tasks; - - state.setWorkflowSnapshot(workflowId, { - ...snapshot, - tasks: snapshot.tasks.map((t: KanbanState["tasks"][number]) => - t.id === taskId ? { ...t, workflowStepId: targetColumnId, position: nextPosition } : t, - ), - }); - - try { - await moveTaskById(taskId, { - workflow_id: workflowId, - workflow_step_id: targetColumnId, - position: nextPosition, - }); - // 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]; - if (currentSnapshot) { - store - .getState() - .setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); - } - const message = error instanceof Error ? error.message : "Failed to move task"; - onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); - } -} - -function useSwimlaneGraphDnd({ tasks, steps, workflowId, onMoveError }: SwimlaneGraphDndOptions) { - const store = useAppStoreApi(); - const { moveTaskById } = useTaskActions(); - const [activeTaskId, setActiveTaskId] = useState(null); - - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); - - const clampVertical: Modifier[] = useMemo( - () => [({ transform }) => ({ ...transform, y: Math.max(-20, Math.min(20, transform.y)) })], - [], - ); - - const tasksByStep = useMemo(() => { - const map: Record = {}; - for (const col of steps) { - map[col.id] = tasks - .filter((t) => t.workflowStepId === col.id) - .sort(compareTasksByCreatedDesc); - } - return map; - }, [steps, tasks]); - - const adjacentSteps = useMemo(() => { - const map: Record> = {}; - for (let i = 0; i < steps.length; i++) { - const adj = new Set(); - if (i > 0) adj.add(steps[i - 1].id); - if (i < steps.length - 1) adj.add(steps[i + 1].id); - map[steps[i].id] = adj; - } - return map; - }, [steps]); - - const handleDragStart = useCallback((event: DragStartEvent) => { - setActiveTaskId(event.active.id as string); - }, []); - - const handleDragEnd = useCallback( - async (event: DragEndEvent) => { - const { active, over } = event; - setActiveTaskId(null); - if (!over) return; - - const taskId = active.id as string; - const targetColumnId = over.id as string; - const task = tasks.find((t) => t.id === taskId); - if (!task || task.workflowStepId === targetColumnId) return; - - const allowed = adjacentSteps[task.workflowStepId]; - if (!allowed || !allowed.has(targetColumnId)) return; - await moveTaskAcrossSwimlaneSteps({ - task, - taskId, - targetColumnId, - workflowId, - store, - moveTaskById, - onMoveError, - }); - }, - [tasks, workflowId, store, moveTaskById, adjacentSteps, onMoveError], - ); - - const handleDragCancel = useCallback(() => { - setActiveTaskId(null); - }, []); - const activeTask = useMemo( - () => tasks.find((t) => t.id === activeTaskId) ?? null, - [tasks, activeTaskId], - ); - - return { - sensors, - clampVertical, - tasksByStep, - activeTaskId, - handleDragStart, - handleDragEnd, - handleDragCancel, - activeTask, - }; -} - -export function SwimlaneGraphContent({ - workflowId, - steps, - tasks, - onPreviewTask, - onMoveError, -}: SwimlaneGraphContentProps) { - const { - sensors, - clampVertical, - tasksByStep, - activeTaskId, - handleDragStart, - handleDragEnd, - handleDragCancel, - activeTask, - } = useSwimlaneGraphDnd({ tasks, steps, workflowId, onMoveError }); - - if (tasks.length === 0) { - return ( -
-
No tasks
-
- ); - } - - return ( - - - - {activeTask ? : null} - - - ); -} - -function GraphStepGrid({ - steps, - tasksByStep, - isDragging, - onPreviewTask, -}: { - steps: WorkflowStep[]; - tasksByStep: Record; - isDragging: boolean; - onPreviewTask: (task: Task) => void; -}) { - return ( -
-
- {steps.map((step, index) => { - const stepTasks = tasksByStep[step.id] ?? []; - const hasActiveTasks = stepTasks.length > 0; - - return ( -
- -
-
-
- {step.title} - - {stepTasks.length} - -
-
- - {stepTasks.length > 0 && ( -
- {stepTasks.map((task) => ( - - ))} -
- )} -
-
- - {index < steps.length - 1 && ( -
-
-
-
- )} -
- ); - })} -
-
- ); -} diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 8dc40210c8..1e62236bf8 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -15,15 +15,14 @@ import { KanbanColumn } from "@/components/kanban-column"; import { type Task } from "@/components/kanban-card"; 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 { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAppStore } from "@/components/state-provider"; +import { useSwimlaneMove } from "@/hooks/domains/kanban/use-swimlane-move"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { MobileColumnTabs } from "./mobile-column-tabs"; import { SwipeableColumns } from "./swipeable-columns"; import { MobileDropTargets } from "./mobile-drop-targets"; import { getKanbanColumnGridTemplate } from "./kanban-grid-template"; -import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import type { MoveTaskError } from "@/lib/kanban/move-task-error"; import { compareTasksByCreatedDesc } from "@/lib/kanban/task-order"; import { type KanbanExternalLinkAvailability, @@ -56,8 +55,7 @@ type SwimlaneKanbanDndOptions = { }; function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanbanDndOptions) { - const store = useAppStoreApi(); - const { moveTaskById } = useTaskActions(); + const { moveTask } = useSwimlaneMove(workflowId, { onMoveError }); const [activeTaskId, setActiveTaskId] = useState(null); const sensors = useSensors( @@ -81,42 +79,9 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban const targetStepId = over.id as string; const task = tasks.find((t) => t.id === taskId); if (!task || task.workflowStepId === targetStepId) return; - - const state = store.getState(); - const snapshot = state.kanbanMulti.snapshots[workflowId]; - if (!snapshot) return; - - const targetTasks = snapshot.tasks.filter( - (t: KanbanState["tasks"][number]) => t.workflowStepId === targetStepId && t.id !== taskId, - ); - const nextPosition = targetTasks.length; - const originalTasks = snapshot.tasks; - - state.setWorkflowSnapshot(workflowId, { - ...snapshot, - tasks: snapshot.tasks.map((t: KanbanState["tasks"][number]) => - t.id === taskId ? { ...t, workflowStepId: targetStepId, position: nextPosition } : t, - ), - }); - - try { - await moveTaskById(taskId, { - workflow_id: workflowId, - workflow_step_id: targetStepId, - position: nextPosition, - }); - } catch (error) { - const currentSnapshot = store.getState().kanbanMulti.snapshots[workflowId]; - if (currentSnapshot) { - store - .getState() - .setWorkflowSnapshot(workflowId, { ...currentSnapshot, tasks: originalTasks }); - } - const message = error instanceof Error ? error.message : "Failed to move task"; - onMoveError?.({ message, taskId, sessionId: task.primarySessionId ?? null }); - } + await moveTask(task, targetStepId); }, - [tasks, workflowId, store, moveTaskById, onMoveError], + [tasks, moveTask], ); const handleDragCancel = useCallback(() => { @@ -126,9 +91,9 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban const moveTaskToStep = useCallback( async (task: Task, targetStepId: string) => { if (task.workflowStepId === targetStepId) return; - await handleDragEnd({ active: { id: task.id }, over: { id: targetStepId } } as DragEndEvent); + await moveTask(task, targetStepId); }, - [handleDragEnd], + [moveTask], ); const activeTask = useMemo( diff --git a/apps/web/components/kanban/task-multi-select-toolbar.test.tsx b/apps/web/components/kanban/task-multi-select-toolbar.test.tsx new file mode 100644 index 0000000000..0090913edf --- /dev/null +++ b/apps/web/components/kanban/task-multi-select-toolbar.test.tsx @@ -0,0 +1,50 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; +import { TaskMultiSelectToolbar } from "./task-multi-select-toolbar"; + +function snapshots(): Record { + return { + "workflow-1": { + workflowId: "workflow-1", + workflowName: "Build", + steps: [], + tasks: [ + { + id: "task-1", + workflowStepId: "step-1", + title: "Task 1", + position: 0, + primaryExecutorType: "docker", + }, + ], + }, + }; +} + +describe("TaskMultiSelectToolbar", () => { + it("renders selected-task actions from Query-owned snapshots without a Zustand store", () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + + , + ); + + expect(screen.getByTestId("multi-select-toolbar")).toBeTruthy(); + expect(screen.getByText("1 selected")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/kanban/task-multi-select-toolbar.tsx b/apps/web/components/kanban/task-multi-select-toolbar.tsx index 6857e27674..b56327a47f 100644 --- a/apps/web/components/kanban/task-multi-select-toolbar.tsx +++ b/apps/web/components/kanban/task-multi-select-toolbar.tsx @@ -5,8 +5,8 @@ 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 { findTaskInSnapshots } from "@/lib/kanban/find-task"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import { DropdownMenu, DropdownMenuContent, @@ -18,6 +18,7 @@ import type { WorkflowStep } from "@/components/kanban-column"; interface TaskMultiSelectToolbarProps { selectedIds: Set; + snapshots: Record; steps: WorkflowStep[]; isProcessing: boolean; canMove?: boolean; @@ -27,15 +28,13 @@ interface TaskMultiSelectToolbarProps { onBulkMove: (targetStepId: string) => Promise; } -function useBulkExecutorTypes(taskIds: string[]): Array { - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); - const fallbackTasks = useAppStore((state) => state.kanban.tasks); +function useBulkExecutorTypes( + taskIds: string[], + snapshots: Record, +): Array { return useMemo( - () => - taskIds.map( - (id) => findTaskInSnapshots(id, snapshots, fallbackTasks)?.primaryExecutorType ?? null, - ), - [taskIds, snapshots, fallbackTasks], + () => taskIds.map((id) => findTaskInSnapshots(id, snapshots)?.primaryExecutorType ?? null), + [taskIds, snapshots], ); } @@ -127,6 +126,7 @@ function BulkDeleteDialog({ export function TaskMultiSelectToolbar({ selectedIds, + snapshots, steps, isProcessing, canMove = true, @@ -136,7 +136,7 @@ export function TaskMultiSelectToolbar({ onBulkMove, }: TaskMultiSelectToolbarProps) { const taskIds = useMemo(() => [...selectedIds], [selectedIds]); - const executorTypes = useBulkExecutorTypes(taskIds); + const executorTypes = useBulkExecutorTypes(taskIds, snapshots); if (selectedIds.size === 0) return null; diff --git a/apps/web/components/linear/linear-issue-watch-dialog.tsx b/apps/web/components/linear/linear-issue-watch-dialog.tsx index 051905fa7d..1081c73780 100644 --- a/apps/web/components/linear/linear-issue-watch-dialog.tsx +++ b/apps/web/components/linear/linear-issue-watch-dialog.tsx @@ -18,6 +18,7 @@ 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 { 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,12 +67,11 @@ type Props = { }; function useFormData(workspaceId: string) { - useSettingsData(true); - useWorkflows(workspaceId, true); - const allWorkflows = useAppStore((s) => s.workflows.items); + const settingsCatalog = useSettingsData(true); + 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 = settingsCatalog.agentProfiles; + const executors = settingsCatalog.executors; const allExecutorProfiles = useMemo( () => executors @@ -385,7 +385,7 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { items: workspaces } = useWorkspaces(); return ( s.workspaces.items); + const { items: workspaces } = useWorkspaces(); const workspaceName = (id: string) => workspaces.find((w) => w.id === id)?.name ?? id; if (watches.length === 0) { diff --git a/apps/web/components/linear/linear-settings.tsx b/apps/web/components/linear/linear-settings.tsx index 4b93f6efd9..f8249ded2d 100644 --- a/apps/web/components/linear/linear-settings.tsx +++ b/apps/web/components/linear/linear-settings.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { IconHexagon } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; @@ -18,14 +19,14 @@ import { type IntegrationAuthHealth, } from "@/components/integrations/auth-status-banner"; import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; -import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; import { - getLinearConfig, setLinearConfig, deleteLinearConfig, testLinearConnection, listLinearTeams, } from "@/lib/api/domains/linear-api"; +import { qk } from "@/lib/query/keys"; +import { linearConfigQueryOptions } from "@/lib/query/query-options/linear"; import type { LinearConfig, LinearTeam, TestLinearConnectionResult } from "@/lib/types/linear"; import { LinearIssueWatchersSection } from "./linear-issue-watchers-section"; @@ -219,6 +220,7 @@ function useSettingsActions({ setTestResult, }: SettingsActionsArgs) { const { toast } = useToast(); + const queryClient = useQueryClient(); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); @@ -252,6 +254,7 @@ function useSettingsActions({ }, { workspaceId }, ); + queryClient.setQueryData(qk.integrations.linear.config(workspaceId), saved); setConfig(saved); setForm(configToForm(saved)); setTestResult(null); @@ -261,12 +264,13 @@ function useSettingsActions({ } finally { setSaving(false); } - }, [workspaceId, form, toast, setConfig, setForm, setTestResult]); + }, [workspaceId, form, queryClient, toast, setConfig, setForm, setTestResult]); const handleDelete = useCallback(async () => { if (!confirm("Remove Linear configuration?")) return; try { await deleteLinearConfig({ workspaceId }); + queryClient.setQueryData(qk.integrations.linear.config(workspaceId), null); setConfig(null); setForm(emptyForm); setTestResult(null); @@ -274,7 +278,7 @@ function useSettingsActions({ } catch (err) { toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); } - }, [workspaceId, toast, setConfig, setForm, setTestResult]); + }, [workspaceId, queryClient, toast, setConfig, setForm, setTestResult]); return { saving, testing, handleTest, handleSave, handleDelete }; } @@ -312,41 +316,31 @@ function useTeamsLoader( function useLinearSettings(workspaceId: string) { const { toast } = useToast(); + const configQuery = useQuery(linearConfigQueryOptions(workspaceId)); const [config, setConfig] = useState(null); const [form, setForm] = useState(emptyForm); - const [loading, setLoading] = useState(true); const [testResult, setTestResult] = useState(null); + const formHydratedRef = useRef(false); const health = configToHealth(config); const { teams, loadingTeams } = useTeamsLoader(workspaceId, config?.hasSecret, config?.lastOk); - const load = useCallback(async () => { - setLoading(true); - try { - const cfg = await getLinearConfig({ workspaceId }); - setConfig(cfg); + useEffect(() => { + if (!configQuery.isSuccess) return; + const cfg = configQuery.data ?? null; + setConfig(cfg); + if (!formHydratedRef.current) { setForm(configToForm(cfg)); - } catch (err) { - toast({ description: `Failed to load Linear config: ${String(err)}`, variant: "error" }); - } finally { - setLoading(false); + formHydratedRef.current = true; } - }, [workspaceId, toast]); - - useEffect(() => { - void load(); - }, [load]); + }, [configQuery.data, configQuery.isSuccess]); - // Background refresh so the auth-health banner picks up new probe results. useEffect(() => { - const id = setInterval(() => { - getLinearConfig({ workspaceId }) - .then((cfg) => setConfig(cfg)) - .catch(() => { - /* transient failures are fine — next tick retries */ - }); - }, INTEGRATION_STATUS_REFRESH_MS); - return () => clearInterval(id); - }, [workspaceId]); + if (!configQuery.isError) return; + toast({ + description: `Failed to load Linear config: ${String(configQuery.error)}`, + variant: "error", + }); + }, [configQuery.error, configQuery.isError, toast]); const update = useCallback( (key: K, value: FormState[K]) => @@ -365,7 +359,7 @@ function useLinearSettings(workspaceId: string) { return { config, form, - loading, + loading: configQuery.isFetching && !configQuery.isSuccess, saving, testing, testResult, diff --git a/apps/web/components/model-config-selector.tsx b/apps/web/components/model-config-selector.tsx index f3bb4d8cc0..4317fe4271 100644 --- a/apps/web/components/model-config-selector.tsx +++ b/apps/web/components/model-config-selector.tsx @@ -358,7 +358,9 @@ export const ModelConfigSelector = memo(function ModelConfigSelector({ const onModelSelect = (value: string) => { if (!value) return; onModelChange(value); - if (!hasExtraConfigOptions) { + if (hasExtraConfigOptions) { + window.setTimeout(() => setOpen(true), 0); + } else { setOpen(false); } }; diff --git a/apps/web/components/quick-chat/quick-chat-dialog.tsx b/apps/web/components/quick-chat/quick-chat-dialog.tsx index d7cd1b0071..c9b995a0f9 100644 --- a/apps/web/components/quick-chat/quick-chat-dialog.tsx +++ b/apps/web/components/quick-chat/quick-chat-dialog.tsx @@ -8,6 +8,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { IconX, IconRocket } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { startQuickChat } from "@/lib/api/domains/workspace-api"; import type { Repository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices/settings/types"; @@ -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 = useCachedRepositories(workspaceId); + const { agentProfiles } = useSettingsData(true); const handleStart = useCallback(async () => { if (isStarting) return; diff --git a/apps/web/components/quick-chat/quick-chat-modal.tsx b/apps/web/components/quick-chat/quick-chat-modal.tsx index 22cecbe515..425bf78692 100644 --- a/apps/web/components/quick-chat/quick-chat-modal.tsx +++ b/apps/web/components/quick-chat/quick-chat-modal.tsx @@ -6,6 +6,7 @@ import { Dialog, DialogContent, DialogTitle } from "@kandev/ui/dialog"; import { Button } from "@kandev/ui/button"; import { IconLoader2, IconMessageCircle, IconPlus } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { PassthroughTerminal } from "@/components/task/passthrough-terminal"; import { QuickChatContent } from "./quick-chat-content"; import { QuickChatDeleteDialog } from "./quick-chat-delete-dialog"; @@ -66,17 +67,19 @@ function QuickChatTabs({ } function useIsQuickChatPassthrough(sessionId: string) { - return useAppStore( + const sessionInfo = useAppStore( useShallow((s) => { const session = s.taskSessions.items[sessionId]; - if (typeof session?.is_passthrough === "boolean") return session.is_passthrough; const profileId = session?.agent_profile_id ?? s.quickChat.sessions.find((qs) => qs.sessionId === sessionId)?.agentProfileId; - if (!profileId) return false; - return s.agentProfiles.items.find((p) => p.id === profileId)?.cli_passthrough === true; + return { explicitPassthrough: session?.is_passthrough, profileId }; }), ); + const { agentProfiles } = useSettingsData(true); + if (typeof sessionInfo.explicitPassthrough === "boolean") return sessionInfo.explicitPassthrough; + if (!sessionInfo.profileId) return false; + return agentProfiles.find((p) => p.id === sessionInfo.profileId)?.cli_passthrough === true; } function QuickChatSessionView({ sessionId }: { sessionId: string }) { @@ -98,7 +101,7 @@ function AgentPickerView({ onSelectAgent: (agentId: string) => void; pendingAgentId: string | null; }) { - const agentProfiles = useAppStore((s) => s.agentProfiles.items) ?? []; + const { agentProfiles } = useSettingsData(true); const isLoading = pendingAgentId !== null; return ( diff --git a/apps/web/components/quick-chat/use-quick-chat-modal.ts b/apps/web/components/quick-chat/use-quick-chat-modal.ts index a2da0e70f5..f074e0ea04 100644 --- a/apps/web/components/quick-chat/use-quick-chat-modal.ts +++ b/apps/web/components/quick-chat/use-quick-chat-modal.ts @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { startQuickChat } from "@/lib/api/domains/workspace-api"; async function deleteQuickChatTask(taskId: string) { @@ -12,7 +13,7 @@ async function deleteQuickChatTask(taskId: string) { } function useQuickChatStore() { - return useAppStore( + const store = useAppStore( useShallow((s) => ({ isOpen: s.quickChat.isOpen, sessions: s.quickChat.sessions, @@ -22,10 +23,11 @@ function useQuickChatStore() { setActiveQuickChatSession: s.setActiveQuickChatSession, renameQuickChatSession: s.renameQuickChatSession, openQuickChat: s.openQuickChat, - agentProfiles: s.agentProfiles.items ?? [], taskSessions: s.taskSessions.items || {}, })), ); + const { agentProfiles } = useSettingsData(true); + return useMemo(() => ({ ...store, agentProfiles }), [agentProfiles, store]); } type QuickChatStore = ReturnType; diff --git a/apps/web/components/review/review-dialog.tsx b/apps/web/components/review/review-dialog.tsx index e5ca74dc59..58f17a8480 100644 --- a/apps/web/components/review/review-dialog.tsx +++ b/apps/web/components/review/review-dialog.tsx @@ -12,9 +12,11 @@ import { useGitOperations } from "@/hooks/use-git-operations"; import { walkthroughStepMatchesFile } from "@/lib/diff/walkthrough-match"; import { useReviewSidebarResize } from "@/hooks/use-review-sidebar-resize"; import { useAppStore } from "@/components/state-provider"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { useToast } from "@/components/toast-provider"; import { DEFAULT_DIFF_WORD_WRAP } from "@/components/diff/diff-defaults"; import { useRequestChangesWalkthrough } from "@/hooks/domains/session/use-request-changes-walkthrough"; +import { useTaskWalkthrough } from "@/hooks/domains/session/use-task-walkthrough"; import { ReviewTopBar } from "./review-top-bar"; import { ReviewFileTree } from "./review-file-tree"; import { ReviewDiffList } from "./review-diff-list"; @@ -316,14 +318,12 @@ function useReviewDialogHandlers(opts: ReviewDialogHandlerOptions) { } function useRepositoryNameToId() { - const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const repositories = useAllCachedRepositories(); return useMemo(() => { const m = new Map(); - for (const list of Object.values(reposByWorkspace)) { - for (const r of list) m.set(r.name, r.id); - } + for (const repository of repositories) m.set(repository.name, repository.id); return m; - }, [reposByWorkspace]); + }, [repositories]); } function useFilteredReviewFiles(allFiles: ReviewFile[], filter: string) { @@ -451,13 +451,12 @@ function useWalkthroughFileSelection( setFilter: (value: string) => void, handleSelectFile: (key: string) => void, ) { - const step = useAppStore((state) => { - const taskId = state.tasks.activeTaskId; - if (!taskId) return null; - const wt = state.walkthroughs.byTaskId[taskId]; - const idx = state.walkthroughs.activeStepByTaskId[taskId] ?? 0; - return wt?.steps[idx] ?? null; - }); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const walkthrough = useTaskWalkthrough(activeTaskId).data ?? null; + const activeStep = useAppStore((state) => + activeTaskId ? (state.walkthroughs.activeStepByTaskId[activeTaskId] ?? 0) : 0, + ); + const step = walkthrough?.steps[activeStep] ?? null; useEffect(() => { if (!open || !step) return; const file = allFiles.find((f) => walkthroughStepMatchesFile(f, step, allFiles)); diff --git a/apps/web/components/review/review-diff-list-grouping.test.tsx b/apps/web/components/review/review-diff-list-grouping.test.tsx index 5e8da6efae..ab9d701285 100644 --- a/apps/web/components/review/review-diff-list-grouping.test.tsx +++ b/apps/web/components/review/review-diff-list-grouping.test.tsx @@ -27,6 +27,9 @@ vi.mock("@/hooks/use-global-view-mode", () => ({ vi.mock("@/hooks/domains/comments/use-run-comment", () => ({ useRunComment: () => ({ runComment: vi.fn() }), })); +vi.mock("@/hooks/domains/session/use-base-branch-by-repo", () => ({ + useBaseBranchByRepo: () => ({}), +})); vi.mock("@/components/state-provider", () => ({ useAppStore: () => null, })); diff --git a/apps/web/components/review/walkthrough-overlay.test.tsx b/apps/web/components/review/walkthrough-overlay.test.tsx index 99bb97c699..bcc55d642b 100644 --- a/apps/web/components/review/walkthrough-overlay.test.tsx +++ b/apps/web/components/review/walkthrough-overlay.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ConnectionStatus } from "@/lib/types/connection"; import type { TaskWalkthrough } from "@/lib/types/http"; @@ -43,20 +44,28 @@ function StoreProbe() { function renderOverlay() { setConnectionStatus = null; + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); render( - - - - , + + + + + + , ); } diff --git a/apps/web/components/review/walkthrough-overlay.tsx b/apps/web/components/review/walkthrough-overlay.tsx index 23226f9970..d8a39efaf8 100644 --- a/apps/web/components/review/walkthrough-overlay.tsx +++ b/apps/web/components/review/walkthrough-overlay.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useRef, useState, type MouseEvent } from "react"; +import { useEffect, useState, type MouseEvent } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { IconRoute, IconX } from "@tabler/icons-react"; import { AlertDialog, @@ -14,11 +15,16 @@ import { } from "@kandev/ui/alert-dialog"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; -import { deleteTaskWalkthrough, getTaskWalkthrough } from "@/lib/api/domains/walkthrough-api"; +import { deleteTaskWalkthrough } from "@/lib/api/domains/walkthrough-api"; import { WalkthroughFloatingWindow } from "@/components/diff/walkthrough-floating-window"; +import { + useTaskWalkthrough, + useWalkthroughSeen, + useWalkthroughStepState, +} from "@/hooks/domains/session/use-task-walkthrough"; +import { qk } from "@/lib/query/keys"; import { clearOpenWalkthroughTaskId, setOpenWalkthroughTaskId } from "@/lib/walkthrough-open-state"; import { cn } from "@kandev/ui/lib/utils"; -import type { TaskWalkthrough } from "@/lib/types/http"; type WalkthroughOverlayProps = { /** The task whose walkthrough launcher should be shown. */ @@ -137,70 +143,27 @@ function DiscardWalkthroughDialog({ ); } -function useWalkthroughBackfill(params: { - connectionStatus: string; - setWalkthrough: (taskId: string, walkthrough: TaskWalkthrough | null) => void; - taskId: string | null; - walkthrough: TaskWalkthrough | null | undefined; -}) { - const { connectionStatus, setWalkthrough, taskId, walkthrough } = params; - const fetchedRef = useRef>(new Set()); - const inFlightRef = useRef>(new Set()); - useEffect(() => { - if ( - !taskId || - walkthrough || - connectionStatus !== "connected" || - fetchedRef.current.has(taskId) || - inFlightRef.current.has(taskId) - ) { - return; - } - let cancelled = false; - inFlightRef.current.add(taskId); - getTaskWalkthrough(taskId) - .then((wt) => { - if (cancelled) return; - if (wt) { - fetchedRef.current.add(taskId); - setWalkthrough(taskId, wt); - } - }) - .catch(() => {}) - .finally(() => { - inFlightRef.current.delete(taskId); - }); - return () => { - cancelled = true; - }; - }, [connectionStatus, setWalkthrough, taskId, walkthrough]); -} - /** * Task-level launcher for an agent-authored walkthrough. It (1) backfills the - * walkthrough into the store on mount — a live `task.walkthrough.created` event + * walkthrough into Query on mount — a live `task.walkthrough.created` event * can fire before the page's WS subscription exists — and (2) toggles the * floating step card, which opens each step's file (current state) and reveals * the anchored line. Works for changed and unchanged files alike (no review * surface required). */ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayProps) { - const walkthrough = useAppStore((s) => (taskId ? s.walkthroughs.byTaskId[taskId] : null)); const connectionStatus = useAppStore((s) => s.connection.status); - const activeStep = useAppStore((s) => - taskId ? (s.walkthroughs.activeStepByTaskId[taskId] ?? 0) : 0, - ); - const lastSeenUpdatedAt = useAppStore((s) => - taskId ? s.walkthroughs.lastSeenUpdatedAtByTaskId[taskId] : undefined, - ); - const setWalkthrough = useAppStore((s) => s.setWalkthrough); + const queryClient = useQueryClient(); + const walkthroughQuery = useTaskWalkthrough(taskId, connectionStatus === "connected"); + const walkthrough = walkthroughQuery.data ?? null; + const stepCount = walkthrough?.steps.length ?? 0; + const { activeStep } = useWalkthroughStepState(taskId, stepCount); + const { hasUnseen } = useWalkthroughSeen(taskId, walkthrough); const [open, setOpen] = useState(false); const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false); const [discarding, setDiscarding] = useState(false); const { toast } = useToast(); - useWalkthroughBackfill({ connectionStatus, setWalkthrough, taskId, walkthrough }); - useEffect(() => { if (!taskId || !open) return; setOpenWalkthroughTaskId(taskId); @@ -208,18 +171,13 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP }, [open, taskId]); if (!taskId || !walkthrough) return null; - const hasUnseen = walkthrough.updated_at !== lastSeenUpdatedAt; // Refresh to the latest persisted walkthrough when opening the card — covers // the case where the agent re-emitted a walkthrough and the live WS update // was missed (e.g. the tab was idle), so it shows without a page reload. const openTour = () => { setOpenWalkthroughTaskId(taskId); - getTaskWalkthrough(taskId) - .then((wt) => { - if (wt) setWalkthrough(taskId, wt); - }) - .catch(() => {}); + void walkthroughQuery.refetch(); setOpen(true); }; const closeTour = () => { @@ -234,7 +192,7 @@ export function WalkthroughOverlay({ taskId, onSelectFile }: WalkthroughOverlayP await deleteTaskWalkthrough(taskId); clearOpenWalkthroughTaskId(taskId); setOpen(false); - setWalkthrough(taskId, null); + queryClient.setQueryData(qk.taskWalkthrough.detail(taskId), null); setConfirmDiscardOpen(false); toast({ title: "Walkthrough discarded", variant: "success" }); } catch (error) { diff --git a/apps/web/components/sentry/sentry-issue-watch-dialog.test.tsx b/apps/web/components/sentry/sentry-issue-watch-dialog.test.tsx index 24910ae7c1..22379c6414 100644 --- a/apps/web/components/sentry/sentry-issue-watch-dialog.test.tsx +++ b/apps/web/components/sentry/sentry-issue-watch-dialog.test.tsx @@ -1,4 +1,6 @@ +import type { ReactElement } from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { listSentryInstances, @@ -6,6 +8,7 @@ import { listSentryProjects, } from "@/lib/api/domains/sentry-api"; import type { SentryConfig, SentryIssueWatch } from "@/lib/types/sentry"; +import { makeQueryClient } from "@/lib/query/client"; import { SentryIssueWatchDialog } from "./sentry-issue-watch-dialog"; const { WORKSPACE_ID } = vi.hoisted(() => ({ WORKSPACE_ID: "ws-1" })); @@ -19,8 +22,10 @@ vi.mock("@/components/state-provider", () => ({ executors: { items: [] }, }), })); -vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ useSettingsData: vi.fn() })); -vi.mock("@/hooks/use-workflows", () => ({ useWorkflows: vi.fn() })); +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: () => ({ agentProfiles: [], executors: [] }), +})); +vi.mock("@/hooks/use-workflows", () => ({ useWorkflows: () => ({ workflows: [] }) })); vi.mock("@/hooks/use-workflow-steps", () => ({ useWorkflowSteps: () => ({ steps: [], loading: false }), stepPlaceholder: () => "Select workflow first", @@ -40,6 +45,53 @@ vi.mock("@/lib/api/domains/sentry-api", () => ({ listSentryOrganizations: vi.fn(), listSentryProjects: vi.fn(), })); +vi.mock("@kandev/ui/select", async () => { + const React = await import("react"); + type SelectContextValue = { + disabled?: boolean; + onValueChange?: (value: string) => void; + value?: string; + }; + const SelectContext = React.createContext({}); + return { + Select: ({ + children, + disabled, + onValueChange, + value, + }: { + children: React.ReactNode; + disabled?: boolean; + onValueChange?: (value: string) => void; + value?: string; + }) => ( + +
{children}
+
+ ), + SelectTrigger: ({ children }: { children: React.ReactNode }) => { + const { disabled } = React.useContext(SelectContext); + return ( + + ); + }, + SelectValue: ({ placeholder }: { placeholder?: string }) => { + const { value } = React.useContext(SelectContext); + return {value || placeholder}; + }, + SelectContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SelectItem: ({ children, value }: { children: React.ReactNode; value: string }) => { + const { onValueChange } = React.useContext(SelectContext); + return ( + + ); + }, + }; +}); const PRIMARY_INSTANCE_ID = "instance-a"; @@ -85,10 +137,16 @@ function selectTrigger(label: string): HTMLButtonElement { } async function choose(label: string, option: string): Promise { - fireEvent.click(selectTrigger(label)); + await waitFor(() => expect(selectTrigger(label).disabled).toBe(false)); + fireEvent.pointerDown(selectTrigger(label), { button: 0, ctrlKey: false }); fireEvent.click(await screen.findByRole("option", { name: option })); } +function renderWithQuery(ui: ReactElement) { + const queryClient = makeQueryClient(); + return render({ui}); +} + afterEach(() => { cleanup(); vi.clearAllMocks(); @@ -118,7 +176,7 @@ beforeEach(() => { describe("SentryIssueWatchDialog", () => { it("removes the previous instance's org and project choices before the new lookup resolves", async () => { - render( + renderWithQuery( { ); await waitFor(() => { - expect(listSentryInstances).toHaveBeenCalledWith(WORKSPACE_ID); + expect(listSentryInstances).toHaveBeenCalledWith(WORKSPACE_ID, expect.any(Object)); }); await choose("Sentry instance", "Production"); @@ -150,7 +208,7 @@ describe("SentryIssueWatchDialog", () => { }); it("permits mutable updates to a legacy unbound watch while its instance remains immutable", async () => { - render( + renderWithQuery( s.workflows.items); + const settingsCatalog = useSettingsData(true); + 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 = settingsCatalog.agentProfiles; + const executors = settingsCatalog.executors; const allExecutorProfiles = useMemo( () => executors @@ -330,7 +328,7 @@ function WorkspacePicker({ onChange: (v: string) => void; disabled?: boolean; }) { - const workspaces = useAppStore((s) => s.workspaces.items); + const { items: workspaces } = useWorkspaces(); return ( ([]); - useEffect(() => { - if (!open || !workspaceId) { - setInstances([]); - return; - } - let cancelled = false; - listSentryInstances(workspaceId) - .then((list) => { - if (!cancelled) setInstances(list); - }) - .catch(() => { - if (!cancelled) setInstances([]); - }); - return () => { - cancelled = true; - }; - }, [open, workspaceId]); + const instancesQuery = useQuery({ + ...sentryInstancesQueryOptions(workspaceId), + enabled: open && Boolean(workspaceId), + }); + const instances = instancesQuery.data ?? []; useEffect(() => { if (hasWatch || instances.length !== 1) return; setForm((p) => (p.sentryInstanceId ? p : { ...p, sentryInstanceId: instances[0].id })); diff --git a/apps/web/components/sentry/sentry-settings.test.tsx b/apps/web/components/sentry/sentry-settings.test.tsx index 75e5f152c6..280946bce8 100644 --- a/apps/web/components/sentry/sentry-settings.test.tsx +++ b/apps/web/components/sentry/sentry-settings.test.tsx @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { listSentryInstances } from "@/lib/api/domains/sentry-api"; +import { qk } from "@/lib/query/keys"; import type { SentryConfig } from "@/lib/types/sentry"; const mocks = vi.hoisted(() => ({ @@ -23,7 +26,15 @@ vi.mock("@/hooks/domains/sentry/use-sentry-enabled", () => ({ })); vi.mock("@/hooks/domains/integrations/use-integration-availability", () => ({ - INTEGRATION_STATUS_REFRESH_MS: 1, + INTEGRATION_STATUS_REFRESH_MS: 10_000, +})); + +vi.mock("@/hooks/domains/workspace/use-workspaces", () => ({ + useWorkspaces: () => ({ + items: mocks.workspaces, + activeId: mocks.activeWorkspaceId, + activeWorkspace: mocks.workspaces.find((w) => w.id === mocks.activeWorkspaceId) ?? null, + }), })); vi.mock("@kandev/ui/switch", () => ({ @@ -66,9 +77,11 @@ vi.mock("@/lib/api/domains/sentry-api", () => ({ import { SentryConnectionSection, SentryIntegrationPage } from "./sentry-settings"; +const WORKSPACE_ID = "workspace-1"; + const instance: SentryConfig = { id: "instance-1", - workspaceId: "workspace-1", + workspaceId: WORKSPACE_ID, name: "Production", authMethod: "auth_token", url: "https://sentry.example.com", @@ -78,59 +91,71 @@ const instance: SentryConfig = { updatedAt: "2026-01-01T00:00:00Z", }; +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function renderWithProviders(ui: ReactElement, queryClient = createQueryClient()) { + return { + queryClient, + ...render( + + {ui} + , + ), + }; +} + +function renderWithHydratedInstances(ui: ReactElement, instances: SentryConfig[]) { + const queryClient = createQueryClient(); + queryClient.setQueryDefaults(qk.integrations.sentry.instances(WORKSPACE_ID), { + staleTime: Infinity, + }); + queryClient.setQueryData(qk.integrations.sentry.instances(WORKSPACE_ID), instances); + vi.mocked(listSentryInstances).mockResolvedValue(instances); + return renderWithProviders(ui, queryClient); +} + beforeEach(() => { - vi.useFakeTimers(); mocks.activeWorkspaceId = "ws-active"; }); afterEach(() => { cleanup(); - vi.useRealTimers(); vi.clearAllMocks(); }); describe("SentryConnectionSection", () => { it("restores the add path when a polling refresh removes the instance being edited", async () => { - vi.mocked(listSentryInstances).mockResolvedValueOnce([instance]).mockResolvedValueOnce([]); - - render( - - - , + const { queryClient } = renderWithHydratedInstances( + , + [instance], ); - await act(async () => { - await Promise.resolve(); - }); expect(screen.getByTestId("sentry-instance-edit-button")).toBeTruthy(); fireEvent.click(screen.getByTestId("sentry-instance-edit-button")); expect(screen.getByTestId("sentry-edit-form")).toBeTruthy(); - await act(async () => { - await vi.advanceTimersByTimeAsync(1); + act(() => { + queryClient.setQueryData(qk.integrations.sentry.instances(WORKSPACE_ID), []); }); - expect(screen.getByRole("button", { name: "Add instance" })).toBeTruthy(); + await waitFor(() => expect(screen.getByRole("button", { name: "Add instance" })).toBeTruthy()); }); it("keeps initial load failures visible but silences recurring poll failures", async () => { vi.mocked(listSentryInstances).mockRejectedValue(new Error("offline")); - render( - - - , - ); + renderWithProviders(); - await act(async () => { - await Promise.resolve(); - }); - expect(mocks.toast).toHaveBeenCalledTimes(1); - - await act(async () => { - await vi.advanceTimersByTimeAsync(1); - }); + await waitFor(() => expect(mocks.toast).toHaveBeenCalledTimes(1)); + expect(listSentryInstances).toHaveBeenCalledTimes(1); expect(mocks.toast).toHaveBeenCalledTimes(1); }); }); @@ -139,11 +164,7 @@ describe("SentryIntegrationPage workspace scope", () => { it("passes the route workspace to watchers before the global active workspace", () => { vi.mocked(listSentryInstances).mockResolvedValue([]); - render( - - - , - ); + renderWithProviders(); expect(screen.getByTestId("sentry-watchers-workspace").textContent).toBe("ws-route"); }); @@ -151,11 +172,7 @@ describe("SentryIntegrationPage workspace scope", () => { it("uses the global active workspace when no route workspace is supplied", () => { vi.mocked(listSentryInstances).mockResolvedValue([]); - render( - - - , - ); + renderWithProviders(); expect(screen.getByTestId("sentry-watchers-workspace").textContent).toBe("ws-active"); }); diff --git a/apps/web/components/sentry/sentry-settings.tsx b/apps/web/components/sentry/sentry-settings.tsx index 4463041398..745a6280c6 100644 --- a/apps/web/components/sentry/sentry-settings.tsx +++ b/apps/web/components/sentry/sentry-settings.tsx @@ -1,21 +1,19 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { IconBrandSentry, IconPlus } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; import { Label } from "@kandev/ui/label"; import { Switch } from "@kandev/ui/switch"; -import { useToast } from "@/components/toast-provider"; +import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; import { SettingsSection } from "@/components/settings/settings-section"; +import { useToast } from "@/components/toast-provider"; import { useSentryEnabled } from "@/hooks/domains/sentry/use-sentry-enabled"; -import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; -import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; -import { - deleteSentryInstance, - listSentryInstances, - sentryInUseWatchCount, -} from "@/lib/api/domains/sentry-api"; +import { deleteSentryInstance, sentryInUseWatchCount } from "@/lib/api/domains/sentry-api"; +import { qk } from "@/lib/query/keys"; +import { sentryInstancesQueryOptions } from "@/lib/query/query-options/sentry"; import type { SentryConfig } from "@/lib/types/sentry"; import { SentryInstanceCard } from "./sentry-instance-card"; import { SentryInstanceForm } from "./sentry-instance-form"; @@ -25,47 +23,39 @@ import { SentryIssueWatchersSection } from "./sentry-issue-watchers-section"; // is open at a time. type EditMode = { kind: "none" } | { kind: "add" } | { kind: "edit"; id: string }; -// useInstanceList loads and polls a workspace's Sentry instances. Fetches are -// request-versioned so a slow load for a previous workspace (or an overlapping -// poll) can't clobber newer results. +function upsertInstance(list: SentryConfig[] | undefined, saved: SentryConfig): SentryConfig[] { + const current = list ?? []; + const index = current.findIndex((instance) => instance.id === saved.id); + if (index === -1) return [...current, saved]; + const next = [...current]; + next[index] = saved; + return next; +} + +// useInstanceList loads and polls a workspace's Sentry instances through Query +// so every Sentry surface shares the same server-state cache. function useInstanceList(workspaceId: string) { const { toast } = useToast(); - const [instances, setInstances] = useState([]); - const [loading, setLoading] = useState(true); - const requestId = useRef(0); - - const reload = useCallback( - async (reportError = true) => { - const current = ++requestId.current; - try { - const list = await listSentryInstances(workspaceId); - if (current !== requestId.current) return; - setInstances(list); - } catch (err) { - if (current !== requestId.current) return; - if (reportError) { - toast({ - description: `Failed to load Sentry instances: ${String(err)}`, - variant: "error", - }); - } - } finally { - if (current === requestId.current) setLoading(false); - } - }, - [workspaceId, toast], - ); + const reportedErrorRef = useRef(false); + const query = useQuery(sentryInstancesQueryOptions(workspaceId)); useEffect(() => { - setLoading(true); - setInstances([]); - void reload(); - // Poll so per-instance health banners stay fresh without a manual refresh. - const id = setInterval(() => void reload(false), INTEGRATION_STATUS_REFRESH_MS); - return () => clearInterval(id); - }, [reload]); - - return { instances, loading, reload }; + if (!query.isError) { + reportedErrorRef.current = false; + return; + } + if (reportedErrorRef.current) return; + reportedErrorRef.current = true; + toast({ + description: `Failed to load Sentry instances: ${String(query.error)}`, + variant: "error", + }); + }, [query.error, query.isError, toast]); + + return { + instances: query.data ?? [], + loading: query.isPending, + }; } type InstanceListProps = { @@ -74,7 +64,7 @@ type InstanceListProps = { workspaceId: string; onEdit: (id: string) => void; onDelete: (instance: SentryConfig) => void; - onSaved: () => void; + onSaved: (saved: SentryConfig) => void; onCancel: () => void; }; @@ -136,24 +126,55 @@ function EnabledPill() { ); } +function AddInstanceButton({ onAdd }: { onAdd: () => void }) { + return ( + + ); +} + export function SentryConnectionSection({ workspaceId }: { workspaceId: string }) { - const { instances, loading, reload } = useInstanceList(workspaceId); + const { instances, loading } = useInstanceList(workspaceId); const { toast } = useToast(); + const queryClient = useQueryClient(); const [mode, setMode] = useState({ kind: "none" }); const closeForm = useCallback(() => setMode({ kind: "none" }), []); - const handleSaved = useCallback(async () => { - setMode({ kind: "none" }); - await reload(); - }, [reload]); + const handleSaved = useCallback( + (saved: SentryConfig) => { + queryClient.setQueryData( + qk.integrations.sentry.instances(workspaceId), + (current) => upsertInstance(current, saved), + ); + void queryClient.invalidateQueries({ + queryKey: qk.integrations.sentry.instances(workspaceId), + }); + setMode({ kind: "none" }); + }, + [queryClient, workspaceId], + ); const handleDelete = useCallback( async (instance: SentryConfig) => { if (!confirm(`Remove Sentry instance "${instance.name}"?`)) return; try { await deleteSentryInstance(workspaceId, instance.id); + queryClient.setQueryData( + qk.integrations.sentry.instances(workspaceId), + (current) => (current ?? []).filter((item) => item.id !== instance.id), + ); + void queryClient.invalidateQueries({ + queryKey: qk.integrations.sentry.instances(workspaceId), + }); toast({ description: "Sentry instance removed", variant: "success" }); - await reload(); } catch (err) { const watchCount = sentryInUseWatchCount(err); if (watchCount !== null) { @@ -167,7 +188,7 @@ export function SentryConnectionSection({ workspaceId }: { workspaceId: string } toast({ description: `Delete failed: ${String(err)}`, variant: "error" }); } }, - [workspaceId, toast, reload], + [queryClient, workspaceId, toast], ); const canAddInstance = @@ -187,7 +208,7 @@ export function SentryConnectionSection({ workspaceId }: { workspaceId: string } Instances {loading && instances.length === 0 ? ( -

Loading…

+

Loading...

) : ( )} - {canAddInstance && ( - - )} + {canAddInstance && setMode({ kind: "add" })} />} diff --git a/apps/web/components/session-commands.test.tsx b/apps/web/components/session-commands.test.tsx new file mode 100644 index 0000000000..92cf6dfb7b --- /dev/null +++ b/apps/web/components/session-commands.test.tsx @@ -0,0 +1,140 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { taskId, workflowId, workspaceId, type Task } from "@/lib/types/http"; +import type { CommandItem } from "@/lib/commands/types"; + +const mocks = vi.hoisted(() => ({ + commands: [] as CommandItem[], + addBrowser: vi.fn(), + addChanges: vi.fn(), + addPlan: vi.fn(), + addTerminal: vi.fn(), +})); + +const appState = { + tasks: { activeTaskId: "task-1" as string | null }, +}; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof appState) => unknown) => selector(appState), +})); + +vi.mock("@/hooks/use-register-commands", () => ({ + useRegisterCommands: (commands: CommandItem[]) => { + mocks.commands = commands; + }, +})); + +vi.mock("@/hooks/use-git-operations", () => ({ + useGitOperations: () => ({ + push: vi.fn(), + pull: vi.fn(), + rebase: vi.fn(), + merge: vi.fn(), + }), +})); + +vi.mock("@/hooks/use-git-with-feedback", () => ({ + useGitWithFeedback: () => vi.fn(), +})); + +vi.mock("@/hooks/use-panel-actions", () => ({ + usePanelActions: () => ({ + addBrowser: mocks.addBrowser, + addChanges: mocks.addChanges, + addPlan: mocks.addPlan, + addTerminal: mocks.addTerminal, + }), +})); + +vi.mock("@/components/vcs/vcs-dialogs", () => ({ + useVcsDialogs: () => ({ + openCommitDialog: vi.fn(), + openPRDialog: vi.fn(), + }), +})); + +vi.mock("@/components/task/new-session-dialog", () => ({ + NewSessionDialog: () => null, +})); + +vi.mock("@/components/task/new-subtask-dialog", () => ({ + NewSubtaskDialog: ({ open, parentTaskTitle }: { open: boolean; parentTaskTitle: string }) => + open ?
: null, +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => null, +})); + +vi.mock("@/lib/ws/workspace-files", () => ({ + createFile: vi.fn(), +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: { + getState: () => ({ addFileEditorPanel: vi.fn() }), + }, +})); + +import { SessionCommands } from "./session-commands"; + +function makeTask(): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("workspace-1"), + workflow_id: workflowId("workflow-1"), + workflow_step_id: "step-1", + position: 0, + title: "Query parent task", + description: "", + state: "CREATED", + priority: 0, + repositories: [], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; +} + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +describe("SessionCommands", () => { + beforeEach(() => { + mocks.commands = []; + appState.tasks.activeTaskId = "task-1"; + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("passes the Query task title to the subtask dialog", () => { + const client = createQueryClient(); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + + render( + + + , + ); + + const command = mocks.commands.find((item) => item.id === "subtask-create"); + expect(command).toBeTruthy(); + + act(() => command?.action?.()); + + expect(screen.getByTestId("new-subtask-dialog").dataset.parentTitle).toBe("Query parent task"); + }); +}); diff --git a/apps/web/components/session-commands.tsx b/apps/web/components/session-commands.tsx index 2fb0c66a91..620c4215bf 100644 --- a/apps/web/components/session-commands.tsx +++ b/apps/web/components/session-commands.tsx @@ -30,6 +30,7 @@ import { useDockviewStore } from "@/lib/state/dockview-store"; import { NewSessionDialog } from "@/components/task/new-session-dialog"; import { NewSubtaskDialog } from "@/components/task/new-subtask-dialog"; import type { CommandItem } from "@/lib/commands/types"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; type SessionCommandsProps = { sessionId: string | null; @@ -247,11 +248,8 @@ export function SessionCommands({ const gitWithFeedback = useGitWithFeedback(); const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); - const activeTaskTitle = useAppStore((s) => { - const id = s.tasks.activeTaskId; - if (!id) return ""; - return s.kanban.tasks.find((t: { id: string }) => t.id === id)?.title ?? ""; - }); + const activeTask = useTaskById(activeTaskId); + const activeTaskTitle = activeTask?.title ?? ""; const [showNewAgentDialog, setShowNewAgentDialog] = useState(false); const [showSubtaskDialog, setShowSubtaskDialog] = useState(false); diff --git a/apps/web/components/session/prepare-progress.test.tsx b/apps/web/components/session/prepare-progress.test.tsx index 9e2d4da24d..efe3bb5fe7 100644 --- a/apps/web/components/session/prepare-progress.test.tsx +++ b/apps/web/components/session/prepare-progress.test.tsx @@ -1,5 +1,6 @@ import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Message } from "@/lib/types/http"; import type { PrepareStepInfo } from "@/lib/state/slices/session-runtime/types"; @@ -41,6 +42,15 @@ vi.mock("@/components/state-provider", () => ({ import { PrepareProgress } from "./prepare-progress"; +function renderPrepareProgress() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + describe("PrepareProgress", () => { afterEach(() => { cleanup(); @@ -61,7 +71,7 @@ describe("PrepareProgress", () => { }, ]; - render(); + renderPrepareProgress(); expect(screen.queryByText("Uploading credentials")).toBeNull(); expect(screen.getByText("Waiting for agent controller")).toBeTruthy(); @@ -81,7 +91,7 @@ describe("PrepareProgress", () => { }, ]; - render(); + renderPrepareProgress(); expect(screen.getByText("Reconnecting cloud sandbox")).toBeTruthy(); expect( @@ -105,7 +115,7 @@ describe("PrepareProgress", () => { { name: "Waiting for agent controller", status: "completed" }, ]; - render(); + renderPrepareProgress(); expect(screen.getByText("Environment prepared on a fresh sandbox")).toBeTruthy(); expect(screen.queryByText("Environment prepared with warnings")).toBeNull(); @@ -122,7 +132,7 @@ describe("PrepareProgress", () => { }, ]; - render(); + renderPrepareProgress(); expect(screen.getByText("Environment prepared with warnings")).toBeTruthy(); expect(screen.queryByText("Environment prepared on a fresh sandbox")).toBeNull(); @@ -166,7 +176,7 @@ describe("PrepareProgress per-repo setup script", () => { ]; mockMessages = [makeSetupScriptMessage()]; - render(); + renderPrepareProgress(); expect(screen.getByText("Run repository setup script")).toBeTruthy(); expect(screen.getByText("make install")).toBeTruthy(); @@ -179,7 +189,7 @@ describe("PrepareProgress per-repo setup script", () => { mockSteps = [{ name: "Create worktree", status: "completed" }]; mockMessages = [makeSetupScriptMessage({ exit_code: 2 })]; - render(); + renderPrepareProgress(); expect(screen.getByText("Script exited with code 2")).toBeTruthy(); }); diff --git a/apps/web/components/session/prepare-progress.tsx b/apps/web/components/session/prepare-progress.tsx index 5f847b8c97..533bc10511 100644 --- a/apps/web/components/session/prepare-progress.tsx +++ b/apps/web/components/session/prepare-progress.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconCheck, IconX, @@ -12,6 +13,10 @@ import { } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { ExpandableRow } from "@/components/task/chat/messages/expandable-row"; +import { + prepareProgressQueryOptions, + sessionAgentctlQueryOptions, +} from "@/lib/query/query-options"; import { isFallbackNoticeStep } from "@/lib/prepare/summarize"; import { cn } from "@/lib/utils"; import { stripAnsi } from "@/lib/utils/ansi"; @@ -208,11 +213,17 @@ function deriveStatus(input: DeriveStatusInput): EffectiveStatus { } function usePrepareStatus(sessionId: string) { - const prepareState = useAppStore((state) => state.prepareProgress.bySessionId[sessionId] ?? null); + const prepareQuery = useQuery(prepareProgressQueryOptions(sessionId)); + const agentctlQuery = useQuery(sessionAgentctlQueryOptions(sessionId)); + const storePrepareState = useAppStore( + (state) => state.prepareProgress.bySessionId[sessionId] ?? null, + ); + const prepareState = prepareQuery.data ?? storePrepareState; const sessionState = useAppStore((state) => state.taskSessions.items[sessionId]?.state); - const agentctlStatus = useAppStore( + const storeAgentctlStatus = useAppStore( (state) => state.sessionAgentctl.itemsBySessionId[sessionId]?.status, ); + const agentctlStatus = agentctlQuery.data?.status ?? storeAgentctlStatus; if (!prepareState) { // No live or hydrated prepare state. If the session has moved past the diff --git a/apps/web/components/settings/agent-profile-delete-dialog.test.tsx b/apps/web/components/settings/agent-profile-delete-dialog.test.tsx index e9078ef4d6..1a340cc5ed 100644 --- a/apps/web/components/settings/agent-profile-delete-dialog.test.tsx +++ b/apps/web/components/settings/agent-profile-delete-dialog.test.tsx @@ -1,7 +1,10 @@ import { describe, it, expect, afterEach } from "vitest"; import { render, screen, cleanup } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { StateProvider } from "@/components/state-provider"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import { AgentProfileDeleteConflictDialog } from "./agent-profile-delete-dialog"; import type { AgentProfileDeleteConflict } from "./agent-profile-delete-dialog"; @@ -10,40 +13,44 @@ afterEach(cleanup); const FIXTURE_TIMESTAMP = "2026-01-01T00:00:00.000Z"; function renderConflictDialog(conflict: AgentProfileDeleteConflict | null) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workspaces.all(), [ + { + id: "ws-1", + name: "Office Workspace", + owner_id: "user-1", + created_at: FIXTURE_TIMESTAMP, + updated_at: FIXTURE_TIMESTAMP, + }, + ]); + queryClient.setQueryData(qk.settings.agents(), { + agents: [ + { + id: "codex-acp", + name: "Codex", + supports_mcp: false, + profiles: [], + created_at: FIXTURE_TIMESTAMP, + updated_at: FIXTURE_TIMESTAMP, + }, + ], + }); + return render( - {}} - onConfirm={() => {}} - /> + + {}} + onConfirm={() => {}} + /> + , ); } diff --git a/apps/web/components/settings/agent-profile-delete-dialog.tsx b/apps/web/components/settings/agent-profile-delete-dialog.tsx index eae822b0b6..2ce2d4e181 100644 --- a/apps/web/components/settings/agent-profile-delete-dialog.tsx +++ b/apps/web/components/settings/agent-profile-delete-dialog.tsx @@ -10,7 +10,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@kandev/ui/alert-dialog"; -import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import type { ActiveSessionInfo, RoutingTierReference, @@ -84,8 +85,8 @@ export function AgentProfileDeleteConflictDialog({ const routingTiers = conflict?.routingTiers ?? []; const hasHardBlockers = routingTiers.length > 0; const watchersByKind = groupWatchersByKind(watchers); - const workspaces = useAppStore((s) => s.workspaces.items); - const providers = useAppStore((s) => s.settingsAgents.items); + const { items: workspaces } = useWorkspaces(); + const { settingsAgents: providers } = useSettingsData(true); return ( diff --git a/apps/web/components/settings/agent-profile-page.tsx b/apps/web/components/settings/agent-profile-page.tsx index 07d6bc189e..78d6d9691c 100644 --- a/apps/web/components/settings/agent-profile-page.tsx +++ b/apps/web/components/settings/agent-profile-page.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import Link from "@/components/routing/app-link"; import { useParams } from "@/lib/routing/client-router"; import { IconTrash } from "@tabler/icons-react"; @@ -36,6 +37,8 @@ export { ProfileEnvVarsSection, } from "@/components/settings/profile-edit/profile-env-vars-section"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { qk } from "@/lib/query/keys"; import type { Agent, AgentProfile, @@ -43,7 +46,6 @@ import type { PermissionSetting, PassthroughConfig, } from "@/lib/types/http"; -import { useAppStore } from "@/components/state-provider"; import { AgentLogo } from "@/components/agent-logo"; import { ProfileMcpConfigCard } from "@/app/settings/agents/[agentId]/profile-mcp-config-card"; import { CommandPreviewCard } from "@/app/settings/agents/[agentId]/profiles/[profileId]/command-preview-card"; @@ -181,22 +183,16 @@ function ProfileSettingsCard({ ); } -function useSyncAgentsToStore() { - const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); - const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); +type AgentsQueryData = { agents: Agent[]; total?: number }; + +function useSyncAgentsToQuery() { + const queryClient = useQueryClient(); return (nextAgents: Agent[]) => { - setSettingsAgents(nextAgents); - setAgentProfiles( - nextAgents.flatMap((agentItem) => - agentItem.profiles.map((agentProfile) => ({ - id: agentProfile.id, - label: `${agentProfile.agentDisplayName ?? ""} • ${agentProfile.name}`, - agent_id: agentItem.id, - agent_name: agentItem.name, - cli_passthrough: agentProfile.cliPassthrough ?? false, - })), - ), - ); + queryClient.setQueryData(qk.settings.agents(), (previous) => ({ + ...(previous ?? {}), + agents: nextAgents, + total: previous?.total ?? nextAgents.length, + })); }; } @@ -237,7 +233,7 @@ type ProfileEditorActionsOptions = { setDraft: (p: AgentProfile) => void; setSaveStatus: (s: SaveStatus) => void; settingsAgents: Agent[]; - syncAgentsToStore: (agents: Agent[]) => void; + syncAgentsToQuery: (agents: Agent[]) => void; toast: ReturnType["toast"]; }; @@ -248,7 +244,7 @@ function useProfileSave({ setDraft, setSaveStatus, settingsAgents, - syncAgentsToStore, + syncAgentsToQuery, toast, }: ProfileEditorActionsOptions) { return async () => { @@ -286,7 +282,7 @@ function useProfileSave({ } : agentItem, ); - syncAgentsToStore(nextAgents); + syncAgentsToQuery(nextAgents); setSaveStatus("success"); } catch (error) { setSaveStatus("error"); @@ -303,7 +299,7 @@ function useProfileDelete( agent: Agent, draft: AgentProfile, settingsAgents: Agent[], - syncAgentsToStore: (agents: Agent[]) => void, + syncAgentsToQuery: (agents: Agent[]) => void, toast: ReturnType["toast"], ) { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); @@ -318,7 +314,7 @@ function useProfileDelete( } : agentItem, ); - syncAgentsToStore(nextAgents); + syncAgentsToQuery(nextAgents); window.location.assign("/settings/agents"); }; @@ -480,8 +476,8 @@ function ProfileEditor({ initialMcpConfig, }: ProfileEditorProps) { const { toast } = useToast(); - const settingsAgents = useAppStore((state) => state.settingsAgents.items); - const syncAgentsToStore = useSyncAgentsToStore(); + const { settingsAgents } = useSettingsData(true); + const syncAgentsToQuery = useSyncAgentsToQuery(); const { items: secrets } = useSecrets(); const { draft, setDraft, savedProfile, setSavedProfile, saveStatus, setSaveStatus, isDirty } = useProfileEditorState(profile, permissionSettings); @@ -506,7 +502,7 @@ function ProfileEditor({ setDraft, setSaveStatus, settingsAgents, - syncAgentsToStore, + syncAgentsToQuery, toast, }); const { @@ -517,7 +513,7 @@ function ProfileEditor({ conflict, setConflict, handleForceDelete, - } = useProfileDelete(agent, draft, settingsAgents, syncAgentsToStore, toast); + } = useProfileDelete(agent, draft, settingsAgents, syncAgentsToQuery, toast); return (
diff --git a/apps/web/components/settings/config-chat-agent-section.tsx b/apps/web/components/settings/config-chat-agent-section.tsx index fdc5bd8bd6..5b7e4ee0c7 100644 --- a/apps/web/components/settings/config-chat-agent-section.tsx +++ b/apps/web/components/settings/config-chat-agent-section.tsx @@ -1,22 +1,23 @@ "use client"; import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { updateWorkspaceAction } from "@/app/actions/workspaces"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; +import { patchWorkspaceCache } from "@/lib/query/workspace-cache"; +import { agentProfileId as toAgentProfileId } from "@/lib/types/ids"; export function ConfigChatAgentSection() { - const workspace = useAppStore( - (s) => s.workspaces.items.find((w) => w.id === s.workspaces.activeId) ?? null, - ); - const profiles = useAppStore((s) => s.agentProfiles.items ?? []); + const { activeWorkspace: workspace } = useWorkspaces(); + const { agentProfiles: profiles } = useSettingsData(true); const currentProfileId = workspace?.default_config_agent_profile_id ?? ""; const [saving, setSaving] = useState(false); const { toast } = useToast(); - - const storeApi = useAppStoreApi(); + const queryClient = useQueryClient(); const handleChange = async (value: string) => { const effectiveValue = value === "none" ? "" : value; @@ -26,12 +27,9 @@ export function ConfigChatAgentSection() { await updateWorkspaceAction(workspace.id, { default_config_agent_profile_id: effectiveValue, }); - const { workspaces, setWorkspaces } = storeApi.getState(); - setWorkspaces( - workspaces.items.map((w) => - w.id === workspace.id ? { ...w, default_config_agent_profile_id: effectiveValue } : w, - ), - ); + patchWorkspaceCache(queryClient, workspace.id, { + default_config_agent_profile_id: effectiveValue ? toAgentProfileId(effectiveValue) : null, + }); toast({ title: "Configuration agent updated", variant: "success" }); } catch (error) { toast({ diff --git a/apps/web/components/settings/editors-settings-state.tsx b/apps/web/components/settings/editors-settings-state.tsx index 93ab06a241..c8e711dbe2 100644 --- a/apps/web/components/settings/editors-settings-state.tsx +++ b/apps/web/components/settings/editors-settings-state.tsx @@ -1,11 +1,13 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { useEditors } from "@/hooks/domains/settings/use-editors"; import { createEditor, deleteEditor, updateEditor, updateUserSettings } from "@/lib/api"; import { useRequest } from "@/lib/http/use-request"; import { parseTasksListGroup, parseTasksListSort } from "@/lib/tasks/tasks-list-options"; +import { qk } from "@/lib/query/keys"; import type { EditorOption } from "@/lib/types/http"; import { type ComboboxOption } from "@/components/combobox"; import { @@ -27,11 +29,10 @@ import { import { Badge } from "@kandev/ui/badge"; export function useEditorsSettingsState() { - const setEditors = useAppStore((state) => state.setEditors); const setUserSettings = useAppStore((state) => state.setUserSettings); const currentUserSettings = useAppStore((state) => state.userSettings); - const { editors: storeEditors } = useEditors(); - const [editors, setEditorItems] = useState(() => storeEditors ?? []); + const { editors: queryEditors } = useEditors(); + const [editors, setEditorItems] = useState(() => queryEditors ?? []); const initialDefaultId = resolveDefaultEditorId( editors ?? [], currentUserSettings.defaultEditorId ?? "", @@ -68,8 +69,11 @@ export function useEditorsSettingsState() { const [expandedConfigLang, setExpandedConfigLang] = useState(null); const [lspConfigErrors, setLspConfigErrors] = useState>({}); + useEffect(() => { + setEditorItems(queryEditors ?? []); + }, [queryEditors]); + return { - setEditors, setUserSettings, currentUserSettings, editors, @@ -329,10 +333,14 @@ function applySettingsResponseToStore( export function useApplyEditors(state: EditorsSettingsState) { const { defaultEditorId, setEditorItems, setDefaultEditorId, setBaselineDefaultId } = state; + const queryClient = useQueryClient(); return useCallback( (updater: EditorOption[] | ((prev: EditorOption[]) => EditorOption[])) => { setEditorItems((prev) => { const next = typeof updater === "function" ? updater(prev) : updater; + queryClient.setQueryData<{ editors: EditorOption[] }>(qk.settings.editors(), { + editors: next, + }); const resolvedDefault = resolveDefaultEditorId(next, defaultEditorId); if (resolvedDefault !== defaultEditorId) { setDefaultEditorId(resolvedDefault); @@ -341,7 +349,7 @@ export function useApplyEditors(state: EditorsSettingsState) { return next; }); }, - [defaultEditorId, setEditorItems, setDefaultEditorId, setBaselineDefaultId], + [defaultEditorId, queryClient, setEditorItems, setDefaultEditorId, setBaselineDefaultId], ); } diff --git a/apps/web/components/settings/editors-settings.tsx b/apps/web/components/settings/editors-settings.tsx index 88edd59880..ea3c447cd3 100644 --- a/apps/web/components/settings/editors-settings.tsx +++ b/apps/web/components/settings/editors-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo } from "react"; +import { useCallback, useMemo } from "react"; import { IconEdit, IconTrash, @@ -503,7 +503,6 @@ export function EditorsSettings() { setLspAutoInstallLanguages, setLspConfigStrings, setLspConfigErrors, - setEditors, editors, } = state; const applyEditors = useApplyEditors(state); @@ -540,10 +539,6 @@ export function EditorsSettings() { [availableEditors, state.defaultEditorId], ); - useEffect(() => { - setEditors(editors); - }, [editors, setEditors]); - return ( state.executors.items); - const setExecutors = useAppStore((state) => state.setExecutors); + const { executors, setExecutors } = useExecutorsQuerySync(); const refreshProfiles = useCallback(async () => { try { diff --git a/apps/web/components/settings/profile-edit/inline-secret-select.tsx b/apps/web/components/settings/profile-edit/inline-secret-select.tsx index 813b0d15ed..10c2f5017e 100644 --- a/apps/web/components/settings/profile-edit/inline-secret-select.tsx +++ b/apps/web/components/settings/profile-edit/inline-secret-select.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { IconPlus, IconLoader2 } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Input } from "@kandev/ui/input"; @@ -8,7 +9,7 @@ import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Textarea } from "@kandev/ui/textarea"; import { createSecret } from "@/lib/api/domains/secrets-api"; -import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; import type { SecretListItem } from "@/lib/types/http-secrets"; const NONE_VALUE = "__none__"; @@ -81,7 +82,7 @@ function InlineCreateForm({ onCreated: (item: SecretListItem) => void; onCancel: () => void; }) { - const addSecret = useAppStore((state) => state.addSecret); + const queryClient = useQueryClient(); const [name, setName] = useState(""); const [value, setValue] = useState(""); const [saving, setSaving] = useState(false); @@ -93,13 +94,16 @@ function InlineCreateForm({ setError(null); try { const item = await createSecret({ name: name.trim(), value: value.trim() }); - addSecret(item); + queryClient.setQueryData(qk.settings.secrets(), (prev) => [ + ...(prev ?? []).filter((secret) => secret.id !== item.id), + item, + ]); onCreated(item); } catch (err) { setError(err instanceof Error ? err.message : "Failed to create secret"); setSaving(false); } - }, [name, value, addSecret, onCreated]); + }, [name, value, onCreated, queryClient]); return (
diff --git a/apps/web/components/settings/profile-status-panels.tsx b/apps/web/components/settings/profile-status-panels.tsx index 01f9406dcb..0509684127 100644 --- a/apps/web/components/settings/profile-status-panels.tsx +++ b/apps/web/components/settings/profile-status-panels.tsx @@ -11,7 +11,7 @@ import { } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; +import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { AgentLoginDialog } from "@/components/settings/agent-login-dialog"; import { HostShellDialog } from "@/components/settings/host-shell-dialog"; @@ -113,9 +113,8 @@ export function NoAuthPanel({ // LoginCommand we open the dedicated login dialog (pre-fills the command); // otherwise we drop the user into a plain host shell so they can explore // (e.g. ` --help`) and run whatever sign-in flow the CLI documents. - const loginCommand = useAppStore((state) => - state.availableAgents.items.find((a) => a.name === agentName), - )?.login_command; + const { items: availableAgents } = useAvailableAgents(); + const loginCommand = availableAgents.find((a) => a.name === agentName)?.login_command; const canLogin = isAuth && Boolean(loginCommand); const showTerminal = isAuth; const hint = noAuthHint({ isAuth, canLogin }); diff --git a/apps/web/components/settings/prompts-settings.tsx b/apps/web/components/settings/prompts-settings.tsx index a2dc0d1f46..15d3283ff5 100644 --- a/apps/web/components/settings/prompts-settings.tsx +++ b/apps/web/components/settings/prompts-settings.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useMemo, useRef, useState, useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { IconEdit, IconTrash, IconLock } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Badge } from "@kandev/ui/badge"; @@ -17,9 +18,9 @@ import { Textarea } from "@kandev/ui/textarea"; import { SettingsPageTemplate } from "@/components/settings/settings-page-template"; import { useToast } from "@/components/toast-provider"; import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; -import { useAppStore } from "@/components/state-provider"; import { createPrompt, deletePrompt, updatePrompt } from "@/lib/api"; import { useRequest } from "@/lib/http/use-request"; +import { qk } from "@/lib/query/keys"; import type { CustomPrompt } from "@/lib/types/http"; const defaultFormState = { @@ -298,9 +299,7 @@ function DeletePromptDialog({ deleteTarget, onClose, onConfirm, isBusy }: Delete } function usePromptsState() { - const { loaded: promptsLoaded } = useCustomPrompts(); - const prompts = useAppStore((state) => state.prompts.items); - const setPrompts = useAppStore((state) => state.setPrompts); + const { loaded: promptsLoaded, prompts } = useCustomPrompts(); const [editingId, setEditingId] = useState(null); const [showCreate, setShowCreate] = useState(false); const [formState, setFormState] = useState(defaultFormState); @@ -309,7 +308,6 @@ function usePromptsState() { return { promptsLoaded, prompts, - setPrompts, editingId, setEditingId, showCreate, @@ -325,7 +323,6 @@ function usePromptsState() { function usePromptsActions(state: ReturnType) { const { prompts, - setPrompts, editingId, setEditingId, setShowCreate, @@ -335,6 +332,7 @@ function usePromptsActions(state: ReturnType) { formState, } = state; const { toast } = useToast(); + const queryClient = useQueryClient(); const resetForm = useCallback(() => { setEditingId(null); @@ -344,9 +342,11 @@ function usePromptsActions(state: ReturnType) { const applyPrompts = useCallback( (next: CustomPrompt[]) => { - setPrompts([...next].sort((a, b) => a.name.localeCompare(b.name))); + queryClient.setQueryData<{ prompts: CustomPrompt[] }>(qk.settings.prompts(), { + prompts: [...next].sort((a, b) => a.name.localeCompare(b.name)), + }); }, - [setPrompts], + [queryClient], ); const isValid = useMemo( diff --git a/apps/web/components/settings/secrets-settings.tsx b/apps/web/components/settings/secrets-settings.tsx index 77a7475a60..db5dc15560 100644 --- a/apps/web/components/settings/secrets-settings.tsx +++ b/apps/web/components/settings/secrets-settings.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useMemo, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { IconEdit, IconTrash, IconEye, IconEyeOff, IconKey } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { @@ -15,7 +16,6 @@ import { Input } from "@kandev/ui/input"; import { Textarea } from "@kandev/ui/textarea"; import { SettingsPageTemplate } from "@/components/settings/settings-page-template"; import { useSecrets } from "@/hooks/domains/settings/use-secrets"; -import { useAppStore } from "@/components/state-provider"; import { createSecret, updateSecret, @@ -23,6 +23,7 @@ import { revealSecret, } from "@/lib/api/domains/secrets-api"; import { useRequest } from "@/lib/http/use-request"; +import { qk } from "@/lib/query/keys"; import type { SecretListItem } from "@/lib/types/http-secrets"; type SecretFormState = { @@ -230,11 +231,7 @@ function DeleteSecretDialog({ target, onClose, onConfirm, isBusy }: DeleteSecret /* ------------------------------------------------------------------ */ function useSecretsState() { - const { loaded } = useSecrets(); - const items = useAppStore((s) => s.secrets.items); - const addSecret = useAppStore((s) => s.addSecret); - const updateSecretInStore = useAppStore((s) => s.updateSecret); - const removeSecret = useAppStore((s) => s.removeSecret); + const { loaded, items } = useSecrets(); const [editingId, setEditingId] = useState(null); const [showCreate, setShowCreate] = useState(false); @@ -244,9 +241,6 @@ function useSecretsState() { return { loaded, items, - addSecret, - updateSecretInStore, - removeSecret, editingId, setEditingId, showCreate, @@ -259,10 +253,8 @@ function useSecretsState() { } function useSecretsActions(state: ReturnType) { + const queryClient = useQueryClient(); const { - addSecret: addToStore, - updateSecretInStore, - removeSecret: removeFromStore, editingId, setEditingId, setShowCreate, @@ -286,7 +278,10 @@ function useSecretsActions(state: ReturnType) { const createRequest = useRequest(async (s: SecretFormState) => { const item = await createSecret({ name: s.name.trim(), value: s.value }, { cache: "no-store" }); - addToStore(item); + queryClient.setQueryData(qk.settings.secrets(), (prev) => [ + ...(prev ?? []).filter((existing) => existing.id !== item.id), + item, + ]); resetForm(); }); @@ -296,13 +291,19 @@ function useSecretsActions(state: ReturnType) { }; if (s.value.trim()) payload.value = s.value; const item = await updateSecret(id, payload, { cache: "no-store" }); - updateSecretInStore(item); + queryClient.setQueryData(qk.settings.secrets(), (prev) => + (prev ?? []).map((existing) => + existing.id === item.id ? { ...existing, ...item } : existing, + ), + ); resetForm(); }); const deleteRequest = useRequest(async (id: string) => { await deleteSecret(id, { cache: "no-store" }); - removeFromStore(id); + queryClient.setQueryData(qk.settings.secrets(), (prev) => + (prev ?? []).filter((secret) => secret.id !== id), + ); if (editingId === id) resetForm(); }); diff --git a/apps/web/components/settings/settings-layout-client.test.tsx b/apps/web/components/settings/settings-layout-client.test.tsx index d64cfb4e80..c708d4fcf3 100644 --- a/apps/web/components/settings/settings-layout-client.test.tsx +++ b/apps/web/components/settings/settings-layout-client.test.tsx @@ -26,6 +26,19 @@ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: typeof state) => unknown) => selector(state), })); +vi.mock("@/hooks/domains/workspace/use-workspaces", () => ({ + useWorkspaces: () => ({ + items: state.workspaces.items, + activeId: state.workspaces.activeId, + }), +})); + +vi.mock("@/components/task/workspace-switcher", () => ({ + WorkspaceSwitcher: ({ activeWorkspaceId }: { activeWorkspaceId: string | null }) => ( +
+ ), +})); + vi.mock("@/components/page-topbar", () => ({ PageTopbar: ({ actions }: { actions?: ReactNode }) => (
{actions}
@@ -53,14 +66,15 @@ describe("SettingsLayoutClient integrations actions", () => { afterEach(() => cleanup()); - it("keeps copy config available without rendering the workspace switcher", () => { + it("shows copy config beside the integration workspace switcher", () => { render(
Settings page
, ); - expect(screen.queryByTestId("integration-workspace-switcher")).toBeNull(); + expect(screen.getByTestId("integration-workspace-switcher")).toBeTruthy(); + expect(screen.getByTestId("mock-workspace-switcher").dataset.activeWorkspaceId).toBe("ws-1"); expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-1"); }); @@ -73,6 +87,7 @@ describe("SettingsLayoutClient integrations actions", () => { , ); + expect(screen.queryByTestId("integration-workspace-switcher")).toBeNull(); expect(screen.getByTestId(COPY_CONFIG_TEST_ID).dataset.sourceWorkspaceId).toBe("ws-1"); }); diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index c1fa6fd601..56216075ae 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, type MouseEvent } from "react"; -import { usePathname } from "@/lib/routing/client-router"; +import { useCallback, useEffect, useMemo, useState, type MouseEvent } from "react"; +import { usePathname, useRouter, useSearchParams } from "@/lib/routing/client-router"; import { Button } from "@kandev/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@kandev/ui/sheet"; import { TooltipProvider } from "@kandev/ui/tooltip"; @@ -10,12 +10,17 @@ import { PageTopbar } from "@/components/page-topbar"; import Link from "@/components/routing/app-link"; import { SettingsTree } from "@/components/app-sidebar/sections/settings/settings-tree"; import { useAppStore } from "@/components/state-provider"; +import { WorkspaceSwitcher } from "@/components/task/workspace-switcher"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; +import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; import { IntegrationCopyConfigMenu } from "@/components/integrations/integration-copy-config-menu"; import { integrationFromPathname } from "@/components/integrations/integration-copy-config"; import { safeDecodePathSegment } from "@/lib/routing/path"; +const WORKSPACE_SYNC_FAILED_KEY = "kandev:settings:integration-workspace:sync-failed:v1"; + // Brand/initialism overrides so the derived label matches how the rest of the -// app spells these (e.g. "github" → "GitHub", not "Github"). Anything not +// app spells these (e.g. "github" -> "GitHub", not "Github"). Anything not // listed here falls back to dash-aware title-casing of the path segment. const SEGMENT_LABEL_OVERRIDES: Record = { github: "GitHub", @@ -36,7 +41,7 @@ function titleCase(segment: string): string { } // Derive the human-readable label for the current /settings sub-page from the -// deepest non-id path segment. /settings → null (the topbar still shows +// deepest non-id path segment. /settings -> null (the topbar still shows // "Settings" as the page itself). UUID-looking segments are skipped so e.g. // /settings/workspace/ resolves to "Workspace" not the raw id. function deriveCurrentPageLabel(pathname: string): string | null { @@ -67,7 +72,7 @@ function deriveParents(pathname: string): Array<{ label: string; href: string }> ); if (automationsMatch && automationsMatch[2]) { // Only inject the Automations crumb when we're on a sub-page (new or - // edit), not on the listing page itself — the listing page title is + // edit), not on the listing page itself; the listing page title is // already "Automations". parents.push({ label: "Automations", @@ -81,7 +86,7 @@ function deriveParents(pathname: string): Array<{ label: string; href: string }> export function SettingsLayoutClient({ children }: { children: React.ReactNode }) { const pathname = usePathname(); const isAgentDetail = pathname.startsWith("/settings/agents/") && pathname !== "/settings/agents"; - const showIntegrationCopyAction = integrationFromPathname(pathname) !== null; + const showIntegrationActions = integrationFromPathname(pathname) !== null; if (isAgentDetail) { return ( @@ -90,7 +95,7 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode } backHref="/settings/agents" backLabel="Agents" parents={[]} - showIntegrationCopyAction={showIntegrationCopyAction} + showIntegrationActions={showIntegrationActions} > {children} @@ -107,28 +112,99 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode } backHref="/" backLabel="Kandev" parents={parents} - showIntegrationCopyAction={showIntegrationCopyAction} + showIntegrationActions={showIntegrationActions} > {children} ); } -function IntegrationCopyConfigAction() { +// useWorkspaceQueryParamSync keeps the top-right switcher and the `?workspace` +// query param in agreement. On load (or when a shared deep link points at a +// valid workspace), it adopts the query param as the active workspace without +// persisting it as the user's global default. setWorkspaceParam writes the +// param back when the user picks a workspace. +function useWorkspaceQueryParamSync( + workspaces: Array<{ id: string }>, + activeId: string | null, + setActiveWorkspace: (id: string) => void, +) { + const router = useRouter(); + const searchParams = useSearchParams(); + const paramWorkspace = searchParams.get("workspace"); + + useEffect(() => { + if (!paramWorkspace || paramWorkspace === activeId) return; + if (!workspaces.some((w) => w.id === paramWorkspace)) return; + setActiveWorkspace(paramWorkspace); + }, [paramWorkspace, activeId, workspaces, setActiveWorkspace]); + + const setWorkspaceParam = useCallback( + (workspaceId: string) => { + const next = new URLSearchParams(window.location.search); + next.set("workspace", workspaceId); + router.replace(`${window.location.pathname}?${next.toString()}`, { scroll: false }); + }, + [router], + ); + + return setWorkspaceParam; +} + +function workspaceIdFromPathname(pathname: string): string | null { + const match = pathname.match(/^\/settings\/workspace\/([^/]+)(?:\/|$)/); + return safeDecodePathSegment(match?.[1]); +} + +function IntegrationActions() { const pathname = usePathname(); - const workspaces = useAppStore((s) => s.workspaces.items); - const activeId = useAppStore((s) => s.workspaces.activeId); + const { items: workspaces, activeId } = useWorkspaces(); + const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); const routeWorkspaceId = workspaceIdFromPathname(pathname); - const selected = + const scopedWorkspaceId = routeWorkspaceId && workspaces.some((workspace) => workspace.id === routeWorkspaceId) ? routeWorkspaceId - : (activeId ?? workspaces[0]?.id ?? null); + : null; + const selected = scopedWorkspaceId ?? activeId ?? workspaces[0]?.id ?? null; const integration = integrationFromPathname(pathname); + const showSwitcher = pathname.startsWith("/settings/integrations"); + const persistWorkspace = useMemo( + () => + createQueuedUserSettingsSync(WORKSPACE_SYNC_FAILED_KEY, (workspaceId) => ({ + workspace_id: workspaceId, + })), + [], + ); + const setWorkspaceParam = useWorkspaceQueryParamSync(workspaces, activeId, setActiveWorkspace); + + const onSelect = useCallback( + (workspaceId: string) => { + setActiveWorkspace(workspaceId); + setWorkspaceParam(workspaceId); + void persistWorkspace(workspaceId); + }, + [persistWorkspace, setActiveWorkspace, setWorkspaceParam], + ); if (!integration || !selected || workspaces.length === 0) return null; return (
+ {showSwitcher ? ( +
+ + Editing workspace + + +
+ ) : null} ; - showIntegrationCopyAction: boolean; + showIntegrationActions: boolean; children: React.ReactNode; }) { const pathname = usePathname(); @@ -222,7 +293,7 @@ function SettingsShell({ parents={parents} leading={} className="h-10" - actions={showIntegrationCopyAction ? : undefined} + actions={showIntegrationActions ? : undefined} /> {/* Scroll the content, not the topbar: min-h-0 lets this flex child shrink below its content height so overflow-y-auto can take effect. */} diff --git a/apps/web/components/settings/sprites-settings.tsx b/apps/web/components/settings/sprites-settings.tsx index b64d7b3cc7..c88552ab9a 100644 --- a/apps/web/components/settings/sprites-settings.tsx +++ b/apps/web/components/settings/sprites-settings.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { Badge } from "@kandev/ui/badge"; import { Button } from "@kandev/ui/button"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; @@ -15,13 +16,18 @@ import { IconSparkles, } from "@tabler/icons-react"; import { useSprites } from "@/hooks/domains/settings/use-sprites"; -import { useAppStore } from "@/components/state-provider"; import { testSpritesConnection, destroySprite, destroyAllSprites, } from "@/lib/api/domains/sprites-api"; -import type { SpritesInstance, SpritesTestResult, SpritesTestStep } from "@/lib/types/http-sprites"; +import { qk } from "@/lib/query/keys"; +import type { + SpritesInstance, + SpritesStatus, + SpritesTestResult, + SpritesTestStep, +} from "@/lib/types/http-sprites"; export function SpritesConnectionCard({ secretId }: { secretId?: string }) { const { status } = useSprites(secretId); @@ -156,8 +162,8 @@ function StepRow({ step }: { step: SpritesTestStep }) { } export function SpritesInstancesCard({ secretId }: { secretId?: string }) { + const queryClient = useQueryClient(); const { instances, loading } = useSprites(secretId); - const removeSpritesInstance = useAppStore((state) => state.removeSpritesInstance); const [destroying, setDestroying] = useState(null); const [destroyingAll, setDestroyingAll] = useState(false); @@ -166,25 +172,39 @@ export function SpritesInstancesCard({ secretId }: { secretId?: string }) { setDestroying(name); try { await destroySprite(name, secretId); - removeSpritesInstance(name); + queryClient.setQueryData( + qk.settings.spritesInstances(secretId), + (prev) => (prev ?? []).filter((instance) => instance.name !== name), + ); + queryClient.setQueryData( + qk.settings.spritesStatus(secretId), + (prev) => + prev + ? { + ...prev, + instance_count: Math.max(0, prev.instance_count - 1), + } + : prev, + ); } finally { setDestroying(null); } }, - [secretId, removeSpritesInstance], + [queryClient, secretId], ); const handleDestroyAll = useCallback(async () => { setDestroyingAll(true); try { await destroyAllSprites(secretId); - for (const inst of instances) { - removeSpritesInstance(inst.name); - } + queryClient.setQueryData(qk.settings.spritesInstances(secretId), []); + queryClient.setQueryData(qk.settings.spritesStatus(secretId), (prev) => + prev ? { ...prev, instance_count: 0 } : prev, + ); } finally { setDestroyingAll(false); } - }, [secretId, instances, removeSpritesInstance]); + }, [queryClient, secretId]); return ( diff --git a/apps/web/components/settings/system/feature-toggles-settings.test.tsx b/apps/web/components/settings/system/feature-toggles-settings.test.tsx index 04d0022602..cb34346269 100644 --- a/apps/web/components/settings/system/feature-toggles-settings.test.tsx +++ b/apps/web/components/settings/system/feature-toggles-settings.test.tsx @@ -1,6 +1,7 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { RuntimeFlagState } from "@/lib/types/runtime-flags"; import { FeatureTogglesSettings } from "./feature-toggles-settings"; @@ -40,20 +41,33 @@ afterEach(() => { vi.clearAllMocks(); }); +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +function renderFeatureToggles( + props: React.ComponentProps, + queryClient = createQueryClient(), +) { + return render( + + + + + , + ); +} + describe("FeatureTogglesSettings", () => { it("shows restart support details without offering restart when unsupported", () => { - render( - - - , - ); + renderFeatureToggles({ + initialFlags: [flagState()], + restartCapability: { + supported: false, + mode: "manual", + reason: "Automatic restart is not available for this launch mode.", + }, + }); expect(screen.getByText("Restart required")).not.toBeNull(); expect(screen.getByLabelText("Restart support details")).not.toBeNull(); @@ -66,11 +80,7 @@ describe("FeatureTogglesSettings", () => { flags: [flagState({ requires_restart_to_apply: false })], }); - render( - - - , - ); + renderFeatureToggles({ initialFlags: [], restartCapability: null }); await waitFor(() => expect(fetchRuntimeFlagsMock).toHaveBeenCalledTimes(1)); expect(await screen.findByText(DEBUG_MODE_LABEL)).not.toBeNull(); @@ -85,11 +95,7 @@ describe("FeatureTogglesSettings", () => { }), ); - render( - - - , - ); + renderFeatureToggles({ initialFlags: [], restartCapability: null }); await waitFor(() => expect(fetchRuntimeFlagsMock).toHaveBeenCalledTimes(1)); expect(screen.getByText("Loading feature toggles...")).not.toBeNull(); @@ -103,11 +109,7 @@ describe("FeatureTogglesSettings", () => { it("keeps the retry state and shows a toast when the empty initial reload fails", async () => { fetchRuntimeFlagsMock.mockRejectedValueOnce(new Error("boom")); - render( - - - , - ); + renderFeatureToggles({ initialFlags: [], restartCapability: null }); expect(await screen.findByText(FEATURE_TOGGLES_LOAD_FAILURE)).not.toBeNull(); expect(screen.getByRole("button", { name: "Retry" })).not.toBeNull(); @@ -121,11 +123,7 @@ describe("FeatureTogglesSettings", () => { it("keeps the retry state without a toast when the empty initial reload returns no flags", async () => { fetchRuntimeFlagsMock.mockResolvedValueOnce({ flags: [] }); - render( - - - , - ); + renderFeatureToggles({ initialFlags: [], restartCapability: null }); expect(await screen.findByText(FEATURE_TOGGLES_LOAD_FAILURE)).not.toBeNull(); expect(screen.getByRole("button", { name: "Retry" })).not.toBeNull(); @@ -140,19 +138,15 @@ describe("FeatureTogglesSettings", () => { }), ); - const firstRender = render( - - - , + const queryClient = createQueryClient(); + const firstRender = renderFeatureToggles( + { initialFlags: [], restartCapability: null }, + queryClient, ); await waitFor(() => expect(fetchRuntimeFlagsMock).toHaveBeenCalledTimes(1)); firstRender.unmount(); - render( - - - , - ); + renderFeatureToggles({ initialFlags: [], restartCapability: null }, queryClient); expect(fetchRuntimeFlagsMock).toHaveBeenCalledTimes(1); resolveFlags({ flags: [flagState({ requires_restart_to_apply: false })] }); diff --git a/apps/web/components/settings/system/feature-toggles-settings.tsx b/apps/web/components/settings/system/feature-toggles-settings.tsx index d79dfa8022..5e2fd99003 100644 --- a/apps/web/components/settings/system/feature-toggles-settings.tsx +++ b/apps/web/components/settings/system/feature-toggles-settings.tsx @@ -1,15 +1,7 @@ "use client"; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type Dispatch, - type MutableRefObject, - type SetStateAction, -} from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; import { Card, CardContent } from "@kandev/ui/card"; @@ -18,7 +10,9 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { IconInfoCircle, IconPower, IconRotateClockwise } from "@tabler/icons-react"; import { useToast } from "@/components/toast-provider"; import { useKandevRestart } from "@/hooks/domains/system/use-kandev-restart"; -import { fetchRuntimeFlags, updateRuntimeFlag } from "@/lib/api/domains/runtime-flags-api"; +import { updateRuntimeFlag } from "@/lib/api/domains/runtime-flags-api"; +import { qk } from "@/lib/query/keys"; +import { runtimeFlagsQueryOptions } from "@/lib/query/query-options/settings"; import type { RuntimeFlagState } from "@/lib/types/runtime-flags"; import type { RestartCapability } from "@/lib/types/system"; import { FeatureToggleCard } from "./feature-toggle-card"; @@ -29,53 +23,45 @@ type Props = { restartCapability: RestartCapability | null; }; -let bootstrapRuntimeFlagsRequest: ReturnType | null = null; - export function FeatureTogglesSettings({ initialFlags, restartCapability }: Props) { - const [flags, setFlags] = useState(initialFlags); - const [isLoadingFlags, setIsLoadingFlags] = useState(initialFlags.length === 0); + const queryClient = useQueryClient(); + const flagsQuery = useQuery({ + ...runtimeFlagsQueryOptions(), + initialData: initialFlags.length > 0 ? { flags: initialFlags } : undefined, + }); + const flags = flagsQuery.data?.flags ?? initialFlags; + const isLoadingFlags = flagsQuery.isFetching && flags.length === 0; const [savingKeys, setSavingKeys] = useState>(() => new Set()); - const requestSeqRef = useRef(0); - const attemptedEmptyInitialReloadRef = useRef(false); const { toast } = useToast(); const pendingRestart = useMemo( () => flags.some((flag) => flag.requires_restart_to_apply), [flags], ); - const reload = useCallback( - async (options?: { bootstrap?: boolean }) => { - const seq = nextRequestSeq(requestSeqRef); - setIsLoadingFlags(true); - try { - const res = await fetchRuntimeFlagsForReload(options?.bootstrap === true); - setFlagsIfLatest(requestSeqRef, seq, res.flags, setFlags); - } catch (err) { - toast({ - title: "Failed to load feature toggles", - description: errorMessage(err), - variant: "error", - }); - } finally { - if (seq === requestSeqRef.current) { - setIsLoadingFlags(false); - } - } - }, - [toast], - ); + const reload = useCallback(async () => { + const res = await flagsQuery.refetch(); + if (res.error) { + toast({ + title: "Failed to load feature toggles", + description: errorMessage(res.error), + variant: "error", + }); + } + }, [flagsQuery, toast]); const onRestartComplete = useCallback(() => void reload(), [reload]); const restart = useKandevRestart({ onComplete: onRestartComplete }); useEffect(() => { - if (flags.length > 0 || attemptedEmptyInitialReloadRef.current) return; - attemptedEmptyInitialReloadRef.current = true; - void reload({ bootstrap: true }); - }, [flags.length, reload]); + if (!flagsQuery.error) return; + toast({ + title: "Failed to load feature toggles", + description: errorMessage(flagsQuery.error), + variant: "error", + }); + }, [flagsQuery.error, toast]); const setOverride = async (flag: RuntimeFlagState, override: boolean | null) => { - const seq = nextRequestSeq(requestSeqRef); setSavingKeys((prev) => { const next = new Set(prev); next.add(flag.key); @@ -83,7 +69,7 @@ export function FeatureTogglesSettings({ initialFlags, restartCapability }: Prop }); try { const res = await updateRuntimeFlag(flag.key, override); - setFlagsIfLatest(requestSeqRef, seq, res.flags, setFlags); + queryClient.setQueryData(qk.settings.runtimeFlags(), res); toast({ title: "Feature toggle saved", variant: "success" }); } catch (err) { toast({ @@ -130,16 +116,6 @@ export function FeatureTogglesSettings({ initialFlags, restartCapability }: Prop ); } -function fetchRuntimeFlagsForReload(bootstrap: boolean): ReturnType { - if (!bootstrap) return fetchRuntimeFlags(); - if (bootstrapRuntimeFlagsRequest === null) { - bootstrapRuntimeFlagsRequest = fetchRuntimeFlags().finally(() => { - bootstrapRuntimeFlagsRequest = null; - }); - } - return bootstrapRuntimeFlagsRequest; -} - function FeatureTogglesEmptyState({ isLoading, onRetry, @@ -245,19 +221,3 @@ function restartSupportMessage(supported: boolean, reason: string | undefined): function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } - -function nextRequestSeq(seqRef: MutableRefObject): number { - seqRef.current += 1; - return seqRef.current; -} - -function setFlagsIfLatest( - seqRef: MutableRefObject, - seq: number, - flags: RuntimeFlagState[], - setFlags: Dispatch>, -) { - if (seq === seqRef.current) { - setFlags(flags); - } -} diff --git a/apps/web/components/slack/slack-settings.tsx b/apps/web/components/slack/slack-settings.tsx index 3df37fc3b5..61132ddda9 100644 --- a/apps/web/components/slack/slack-settings.tsx +++ b/apps/web/components/slack/slack-settings.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import Link from "@/components/routing/app-link"; import { IconBrandSlack } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; @@ -19,14 +20,14 @@ import { type IntegrationAuthHealth, } from "@/components/integrations/auth-status-banner"; import { WorkspaceScopedSection } from "@/components/integrations/workspace-scoped-section"; -import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; import { - getSlackConfig, setSlackConfig, deleteSlackConfig, testSlackConnection, } from "@/lib/api/domains/slack-api"; import { listUtilityAgents, type UtilityAgent } from "@/lib/api/domains/utility-api"; +import { qk } from "@/lib/query/keys"; +import { slackConfigQueryOptions } from "@/lib/query/query-options/slack"; import type { SlackConfig, TestSlackConnectionResult } from "@/lib/types/slack"; const DEFAULT_PREFIX = "!kandev"; @@ -389,6 +390,7 @@ function useSettingsActions({ setTestResult, }: SettingsActionsArgs) { const { toast } = useToast(); + const queryClient = useQueryClient(); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [deleting, setDeleting] = useState(false); @@ -430,6 +432,7 @@ function useSettingsActions({ }, { workspaceId }, ); + queryClient.setQueryData(qk.integrations.slack.config(workspaceId), saved); setConfig(saved); setForm(configToForm(saved)); setTestResult(null); @@ -439,13 +442,14 @@ function useSettingsActions({ } finally { setSaving(false); } - }, [workspaceId, form, toast, setConfig, setForm, setTestResult]); + }, [workspaceId, form, queryClient, toast, setConfig, setForm, setTestResult]); const handleDelete = useCallback(async () => { if (!confirm("Remove Slack configuration?")) return; setDeleting(true); try { await deleteSlackConfig({ workspaceId }); + queryClient.setQueryData(qk.integrations.slack.config(workspaceId), null); setConfig(null); setForm(emptyForm); setTestResult(null); @@ -455,48 +459,38 @@ function useSettingsActions({ } finally { setDeleting(false); } - }, [workspaceId, toast, setConfig, setForm, setTestResult]); + }, [workspaceId, queryClient, toast, setConfig, setForm, setTestResult]); return { saving, testing, deleting, handleTest, handleSave, handleDelete }; } function useSlackSettings(workspaceId: string) { const { toast } = useToast(); + const configQuery = useQuery(slackConfigQueryOptions(workspaceId)); const [config, setConfig] = useState(null); const [form, setForm] = useState(emptyForm); - const [loading, setLoading] = useState(true); const [testResult, setTestResult] = useState(null); + const formHydratedRef = useRef(false); const health = configToHealth(config); const { agents, loadingAgents } = useUtilityAgentsLoader(); - const load = useCallback(async () => { - setLoading(true); - try { - const cfg = await getSlackConfig({ workspaceId }); - setConfig(cfg); + useEffect(() => { + if (!configQuery.isSuccess) return; + const cfg = configQuery.data ?? null; + setConfig(cfg); + if (!formHydratedRef.current) { setForm(configToForm(cfg)); - } catch (err) { - toast({ description: `Failed to load Slack config: ${String(err)}`, variant: "error" }); - } finally { - setLoading(false); + formHydratedRef.current = true; } - }, [workspaceId, toast]); - - useEffect(() => { - void load(); - }, [load]); + }, [configQuery.data, configQuery.isSuccess]); - // Background refresh so the auth-health banner picks up new probe results. useEffect(() => { - const id = setInterval(() => { - getSlackConfig({ workspaceId }) - .then((cfg) => setConfig(cfg)) - .catch(() => { - /* transient failures are fine — next tick retries */ - }); - }, INTEGRATION_STATUS_REFRESH_MS); - return () => clearInterval(id); - }, [workspaceId]); + if (!configQuery.isError) return; + toast({ + description: `Failed to load Slack config: ${String(configQuery.error)}`, + variant: "error", + }); + }, [configQuery.error, configQuery.isError, toast]); const update = useCallback( (key: K, value: FormState[K]) => @@ -515,7 +509,7 @@ function useSlackSettings(workspaceId: string) { return { config, form, - loading, + loading: configQuery.isFetching && !configQuery.isSuccess, saving, testing, deleting, diff --git a/apps/web/components/state-hydrator.test.tsx b/apps/web/components/state-hydrator.test.tsx new file mode 100644 index 0000000000..099152a78d --- /dev/null +++ b/apps/web/components/state-hydrator.test.tsx @@ -0,0 +1,97 @@ +/* eslint-disable max-lines-per-function, sonarjs/no-duplicate-string */ +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import { QueryProvider } from "@/lib/query/provider"; +import type { Message, Workspace } from "@/lib/types/http"; +import type { AgentProfile, InboxItem, OfficeMeta, Project } from "@/lib/state/slices/office/types"; +import { StateProvider } from "./state-provider"; +import { StateHydrator } from "./state-hydrator"; + +describe("StateHydrator", () => { + it("seeds the active TanStack Query client from route-transition state", () => { + const queryClient = makeQueryClient(); + const message = { + id: "message-1", + session_id: "session-1", + content: "hydrated", + } as Message; + + render( + + + + + , + ); + + expect(queryClient.getQueryData(qk.session.messages("session-1"))).toEqual({ + messages: [message], + hasMore: false, + oldestCursor: "message-1", + }); + }); + + it("seeds office server-state query keys from hydrated state", () => { + const queryClient = makeQueryClient(); + const agent = { id: "agent-1", name: "CEO" } as AgentProfile; + const project = { + id: "project-1", + name: "Launch", + } as Project; + const inboxItem = { + id: "inbox-1", + type: "approval", + title: "Approve", + } as InboxItem; + + render( + + + + + , + ); + + expect(queryClient.getQueryData(qk.office.agents("workspace-1"))).toEqual({ + agents: [agent], + }); + expect(queryClient.getQueryData(qk.office.projects("workspace-1"))).toEqual({ + projects: [project], + }); + expect(queryClient.getQueryData(qk.office.inbox("workspace-1"))).toEqual({ + items: [inboxItem], + total_count: 1, + }); + }); +}); diff --git a/apps/web/components/state-hydrator.tsx b/apps/web/components/state-hydrator.tsx index 652d652f24..c7010f2407 100644 --- a/apps/web/components/state-hydrator.tsx +++ b/apps/web/components/state-hydrator.tsx @@ -1,28 +1,62 @@ "use client"; import { useLayoutEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import type { AppState } from "@/lib/state/store"; import { useAppStoreApi } from "@/components/state-provider"; +import { seedQueryClientFromInitialState, type QuerySeedInitialState } from "@/lib/query/seed"; type StateHydratorProps = { - initialState: Partial; + initialState: QuerySeedInitialState; /** Session ID to force-merge even if it's active (for navigation refresh) */ sessionId?: string; }; export function StateHydrator({ initialState, sessionId }: StateHydratorProps) { const store = useAppStoreApi(); + const queryClient = useQueryClient(); // Use useLayoutEffect to hydrate state synchronously before child effects run. // This ensures SSR-hydrated data is available before hooks like useSettingsData // decide whether to fetch data. useLayoutEffect(() => { if (Object.keys(initialState).length) { - store.getState().hydrate(initialState, { + store.getState().hydrate(toStoreInitialState(initialState), { forceMergeSessionId: sessionId, }); + seedQueryClientFromInitialState(queryClient, initialState, { sessionId }); } - }, [initialState, sessionId, store]); + }, [initialState, queryClient, sessionId, store]); return null; } + +function toStoreInitialState(initialState: QuerySeedInitialState): Partial { + const { workspaces, office, ...rest } = initialState; + const storeOffice = toStoreOfficeInitialState(office); + return { + ...(rest as Partial), + ...(workspaces ? { workspaces: { activeId: workspaces.activeId ?? null } } : {}), + ...(storeOffice ? { office: storeOffice } : {}), + } as Partial; +} + +function toStoreOfficeInitialState( + office: QuerySeedInitialState["office"], +): Partial | undefined { + if (!office) return undefined; + const { + agents: _agents, + projects: _projects, + skills: _skills, + routines: _routines, + inboxItems: _inboxItems, + inboxCount: _inboxCount, + dashboard: _dashboard, + activity: _activity, + runs: _runs, + meta: _meta, + ...storeOffice + } = office; + return storeOffice as Partial; +} diff --git a/apps/web/components/state-provider.tsx b/apps/web/components/state-provider.tsx index 47088eb4ac..efe9763286 100644 --- a/apps/web/components/state-provider.tsx +++ b/apps/web/components/state-provider.tsx @@ -149,3 +149,7 @@ export function useAppStoreApi() { } return store; } + +export function useOptionalAppStoreApi() { + return useContext(StoreContext); +} diff --git a/apps/web/components/system-metrics/topbar-metrics.test.tsx b/apps/web/components/system-metrics/topbar-metrics.test.tsx index 19db97420a..da3eb02c71 100644 --- a/apps/web/components/system-metrics/topbar-metrics.test.tsx +++ b/apps/web/components/system-metrics/topbar-metrics.test.tsx @@ -1,10 +1,13 @@ import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { StateProvider } from "@/components/state-provider"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice"; -import { defaultSystemState } from "@/lib/state/slices/system/system-slice"; import type { AppState } from "@/lib/state/store"; +import type { SystemMetricsSnapshot } from "@/lib/types/system"; import { TopbarMetrics } from "./topbar-metrics"; const responsiveState = vi.hoisted(() => ({ isMobile: true })); @@ -27,13 +30,21 @@ vi.mock("@/hooks/use-system-metrics-subscription", () => ({ useSystemMetricsSubscription: subscribeMock, })); -function renderMetrics(initialState: Partial, activeSessionId?: string | null) { +function renderMetrics( + initialState: Partial, + metrics: SystemMetricsSnapshot, + activeSessionId?: string | null, +) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.system.metrics(), metrics); return render( - - - - - , + + + + + + + , ); } @@ -44,65 +55,65 @@ function initialState(showInTopbar: boolean): Partial { loaded: true, systemMetricsDisplay: { showInTopbar }, }, - system: { - ...defaultSystemState.system, - metrics: { - timestamp: "2026-06-23T10:00:00Z", - interval_seconds: 5, - sources: [ + }; +} + +function metricsSnapshot(): SystemMetricsSnapshot { + return { + timestamp: "2026-06-23T10:00:00Z", + interval_seconds: 5, + sources: [ + { + id: "backend", + label: "Host", + kind: "backend", + metrics: [ { - id: "backend", - label: "Host", - kind: "backend", - metrics: [ - { - id: "cpu_percent", - label: "CPU", - unit: "%", - value: 42, - available: true, - }, - { - id: "memory_percent", - label: "Memory", - unit: "%", - value: 68, - available: true, - }, - { - id: "disk_percent", - label: "Disk", - unit: "%", - value: 12, - available: true, - }, - ], + id: "cpu_percent", + label: "CPU", + unit: "%", + value: 42, + available: true, }, { - id: "execution-session-1", - label: "Executor", - kind: "execution", - session_id: "session-1", - metrics: [ - { - id: "cpu_percent", - label: "CPU", - unit: "%", - value: 17, - available: true, - }, - { - id: "memory_percent", - label: "Memory", - unit: "%", - value: 33, - available: true, - }, - ], + id: "memory_percent", + label: "Memory", + unit: "%", + value: 68, + available: true, + }, + { + id: "disk_percent", + label: "Disk", + unit: "%", + value: 12, + available: true, }, ], }, - }, + { + id: "execution-session-1", + label: "Executor", + kind: "execution", + session_id: "session-1", + metrics: [ + { + id: "cpu_percent", + label: "CPU", + unit: "%", + value: 17, + available: true, + }, + { + id: "memory_percent", + label: "Memory", + unit: "%", + value: 33, + available: true, + }, + ], + }, + ], }; } @@ -117,7 +128,7 @@ describe("TopbarMetrics", () => { }); it("renders a compact metrics pill on mobile when enabled", () => { - renderMetrics(initialState(true)); + renderMetrics(initialState(true), metricsSnapshot()); const metrics = screen.getByTestId("mobile-topbar-metrics"); expect(metrics).toBeTruthy(); @@ -130,7 +141,7 @@ describe("TopbarMetrics", () => { }); it("prefers the active executor source in compact task topbars", () => { - renderMetrics(initialState(true), "session-1"); + renderMetrics(initialState(true), metricsSnapshot(), "session-1"); expect(screen.getByLabelText("Executor metrics")).toBeTruthy(); expect(screen.getByLabelText("CPU 17%")).toBeTruthy(); @@ -139,7 +150,7 @@ describe("TopbarMetrics", () => { }); it("does not subscribe or render when topbar metrics are disabled", () => { - renderMetrics(initialState(false)); + renderMetrics(initialState(false), metricsSnapshot()); expect(screen.queryByTestId("mobile-topbar-metrics")).toBeNull(); expect(subscribeMock).toHaveBeenCalledWith(false); diff --git a/apps/web/components/system-metrics/topbar-metrics.tsx b/apps/web/components/system-metrics/topbar-metrics.tsx index bb91bbe62e..9ef1cddee6 100644 --- a/apps/web/components/system-metrics/topbar-metrics.tsx +++ b/apps/web/components/system-metrics/topbar-metrics.tsx @@ -12,9 +12,11 @@ import { } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { formatDistanceToNow } from "date-fns"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useSystemMetricsSubscription } from "@/hooks/use-system-metrics-subscription"; +import { systemMetricsQueryOptions } from "@/lib/query/query-options/system"; import type { SystemMetricSample, SystemMetricsSource } from "@/lib/types/system"; type TopbarMetricsProps = { @@ -23,7 +25,8 @@ type TopbarMetricsProps = { export function TopbarMetrics({ activeSessionId }: TopbarMetricsProps) { const enabled = useAppStore((s) => s.userSettings.systemMetricsDisplay.showInTopbar); - const snapshot = useAppStore((s) => s.system.metrics); + const metricsQuery = useQuery(systemMetricsQueryOptions()); + const snapshot = metricsQuery.data ?? null; const { usesDesktopWorkbench } = useResponsiveBreakpoint(); const shouldRender = enabled; useSystemMetricsSubscription(shouldRender); diff --git a/apps/web/components/task-create-dialog-effects.test.ts b/apps/web/components/task-create-dialog-effects.test.ts index 697d4edc31..159f20a9fe 100644 --- a/apps/web/components/task-create-dialog-effects.test.ts +++ b/apps/web/components/task-create-dialog-effects.test.ts @@ -1,8 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useDefaultSelectionsEffect, useWorkflowAgentProfileEffect, + useWorkflowStepsEffect, } from "./task-create-dialog-effects"; import { decideAgentProfileAutopick } from "./task-create-dialog-autopick"; import type { DialogFormState, StoreSelections } from "@/components/task-create-dialog-types"; @@ -10,6 +13,12 @@ import type { AgentProfileOption } from "@/lib/state/slices"; import type { Workspace } from "@/lib/types/http"; import { STORAGE_KEYS } from "@/lib/settings/constants"; +const listWorkflowStepsMock = vi.fn(); + +vi.mock("@/lib/api/domains/workflow-api", () => ({ + listWorkflowSteps: (...args: unknown[]) => listWorkflowStepsMock(...args), +})); + // Minimal fake of DialogFormState - the hook destructures only three fields, // so the rest can be undefined behind an `as` cast and never read. type Fake = Pick< @@ -45,6 +54,61 @@ function makeProfile(id: string): AgentProfileOption { beforeEach(() => { localStorage.clear(); + listWorkflowStepsMock.mockReset(); +}); + +function createQueryWrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); +} + +describe("useWorkflowStepsEffect", () => { + it("loads fetched steps through the workflow-step query", async () => { + listWorkflowStepsMock.mockResolvedValueOnce({ + steps: [ + { id: "step-2", name: "Second", position: 2, events: { on_entry: [] } }, + { id: "step-1", name: "First", position: 1, events: { on_exit: [] } }, + ], + }); + const setFetchedSteps = vi.fn(); + const fs = { + selectedWorkflowId: "workflow-2", + setFetchedSteps, + } as unknown as DialogFormState; + + renderHook(() => useWorkflowStepsEffect(fs, "workflow-1"), { + wrapper: createQueryWrapper(), + }); + + await waitFor(() => + expect(setFetchedSteps).toHaveBeenLastCalledWith([ + { id: "step-1", title: "First", events: { on_exit: [] } }, + { id: "step-2", title: "Second", events: { on_entry: [] } }, + ]), + ); + expect(listWorkflowStepsMock).toHaveBeenCalledWith( + "workflow-2", + expect.objectContaining({ + init: expect.objectContaining({ signal: expect.any(AbortSignal) }), + }), + ); + }); + + it("clears fetched steps when the selected workflow is already current", () => { + const setFetchedSteps = vi.fn(); + const fs = { + selectedWorkflowId: "workflow-1", + setFetchedSteps, + } as unknown as DialogFormState; + + renderHook(() => useWorkflowStepsEffect(fs, "workflow-1"), { + wrapper: createQueryWrapper(), + }); + + expect(setFetchedSteps).toHaveBeenCalledWith(null); + expect(listWorkflowStepsMock).not.toHaveBeenCalled(); + }); }); describe("useWorkflowAgentProfileEffect", () => { diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 6e195c4776..492698f5ff 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -1,6 +1,7 @@ "use client"; import { useEffect, useMemo, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import type { Repository, Executor, ExecutorProfile } from "@/lib/types/http"; import { DEFAULT_LOCAL_EXECUTOR_TYPE } from "@/lib/utils"; import { useToast } from "@/components/toast-provider"; @@ -8,8 +9,8 @@ import { discoverRepositoriesAction, getLocalRepositoryStatusAction, } from "@/app/actions/workspaces"; -import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; import { getLocalStorage } from "@/lib/local-storage"; +import { workflowStepsQueryOptions } from "@/lib/query/query-options"; import { STORAGE_KEYS } from "@/lib/settings/constants"; import { parseGitHubAnyUrl } from "@/hooks/domains/github/use-pr-info-by-url"; import type { @@ -36,25 +37,21 @@ const selectionDebug = createDebugLogger("task-create:selection"); export function useWorkflowStepsEffect(fs: DialogFormState, workflowId: string | null) { const { selectedWorkflowId, setFetchedSteps } = fs; + const shouldFetch = Boolean(selectedWorkflowId && selectedWorkflowId !== workflowId); + const query = useQuery({ + ...workflowStepsQueryOptions(selectedWorkflowId ?? ""), + enabled: shouldFetch, + }); + useEffect(() => { - if (!selectedWorkflowId || selectedWorkflowId === workflowId) { - void Promise.resolve().then(() => setFetchedSteps(null)); + if (!shouldFetch || !query.data) { + setFetchedSteps(null); return; } - let cancelled = false; - listWorkflowSteps(selectedWorkflowId) - .then((response) => { - if (cancelled) return; - const sorted = [...response.steps].sort((a, b) => a.position - b.position); - setFetchedSteps(sorted.map((s) => ({ id: s.id, title: s.name, events: s.events }))); - }) - .catch(() => { - if (!cancelled) setFetchedSteps(null); - }); - return () => { - cancelled = true; - }; - }, [selectedWorkflowId, workflowId, setFetchedSteps]); + setFetchedSteps( + query.data.map((step) => ({ id: step.id, title: step.name, events: step.events })), + ); + }, [query.data, setFetchedSteps, shouldFetch]); } export function useDiscoverReposEffect( diff --git a/apps/web/components/task-create-dialog-repo-chips.tsx b/apps/web/components/task-create-dialog-repo-chips.tsx index ec4683cfbb..b4dabfabd7 100644 --- a/apps/web/components/task-create-dialog-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-repo-chips.tsx @@ -471,6 +471,9 @@ function useRepoChipData({ ); }, [filteredRepos, discoveredRepositories, excludedRepoIds, row.localPath]); + // One hook for both row sources (workspace repo by id OR on-machine path). + // Query keys include the source shape so workspace + discovered rows dedupe + // independently without a Zustand server-state mirror. const branchSource = useMemo(() => { if (!workspaceId) return null; if (row.repositoryId) { diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index f065992a8b..bfa1f30f69 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -1,7 +1,16 @@ /* eslint-disable max-lines -- groups all create-dialog selector subcomponents; splitting per-selector files is a separate refactor. */ "use client"; -import { useEffect, useLayoutEffect, useRef, useState, memo, useCallback, useMemo } from "react"; +import { + useEffect, + useLayoutEffect, + useRef, + useState, + memo, + useCallback, + useMemo, + type RefObject, +} from "react"; import { Textarea } from "@kandev/ui/textarea"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { IconPaperclip } from "@tabler/icons-react"; @@ -29,6 +38,7 @@ import { useTaskCreatePromptMention } from "@/hooks/use-task-create-prompt-menti import { cn } from "@/lib/utils"; const CURSOR_POINTER_CLASS = "cursor-pointer"; +const MENTION_CONTROL_KEYS = new Set(["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"]); type RepositoryOption = { value: string; @@ -637,11 +647,26 @@ function useTextareaHandlers( mention: ReturnType, onKeyDown: TaskFormInputsProps["onKeyDown"], ) { - const { handleChange: mentionHandleChange, handleKeyDown: mentionHandleKeyDown } = mention; + const { + handleChange: mentionHandleChange, + handleKeyDown: mentionHandleKeyDown, + isOpen: mentionIsOpen, + } = mention; const handleChange = useCallback( (e: React.ChangeEvent) => mentionHandleChange(e.target.value), [mentionHandleChange], ); + const handleKeyDownCapture = useCallback( + (e: React.KeyboardEvent) => { + if (!mentionIsOpen || !MENTION_CONTROL_KEYS.has(e.key)) return; + mentionHandleKeyDown(e); + if (e.defaultPrevented) { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation?.(); + } + }, + [mentionHandleKeyDown, mentionIsOpen], + ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { mentionHandleKeyDown(e); @@ -650,7 +675,34 @@ function useTextareaHandlers( }, [mentionHandleKeyDown, onKeyDown], ); - return { handleChange, handleKeyDown }; + return { handleChange, handleKeyDown, handleKeyDownCapture }; +} + +function useMentionEscapePropagationGuard( + textareaRef: RefObject, + mention: ReturnType, +) { + const { closeMenu, isOpen } = mention; + + useLayoutEffect(() => { + const textarea = textareaRef.current; + if (!textarea || !isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + closeMenu(); + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + textarea.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => { + window.removeEventListener("keydown", handleKeyDown, { capture: true }); + textarea.removeEventListener("keydown", handleKeyDown, { capture: true }); + }; + }, [closeMenu, isOpen, textareaRef]); } function useFileInputClick(addFiles: (files: File[]) => Promise | void) { @@ -734,7 +786,11 @@ export const TaskFormInputs = memo(function TaskFormInputs({ value: description, onChange: setDescriptionValue, }); - const { handleChange, handleKeyDown } = useTextareaHandlers(mention, onKeyDown); + useMentionEscapePropagationGuard(textareaRef, mention); + const { handleChange, handleKeyDown, handleKeyDownCapture } = useTextareaHandlers( + mention, + onKeyDown, + ); const { fileInputRef, handleAttachClick, handleFileInputChange } = useFileInputClick(addFiles); const voiceBinding = useMemo( () => ({ onTranscript: insertAtCursor, onAutoSend: onVoiceAutoSend }), @@ -762,6 +818,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({ } value={description} onChange={handleChange} + onKeyDownCapture={handleKeyDownCapture} onKeyDown={handleKeyDown} onPaste={handlePaste} data-testid="task-description-input" diff --git a/apps/web/components/task-create-dialog-state.test.ts b/apps/web/components/task-create-dialog-state.test.ts index f25db185df..f867e198cb 100644 --- a/apps/web/components/task-create-dialog-state.test.ts +++ b/apps/web/components/task-create-dialog-state.test.ts @@ -1,8 +1,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { act, renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { computeDialogDefaultStepId } from "./task-create-dialog-defaults"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; -import { useDialogFormState } from "./task-create-dialog-state"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import { + repositoryId as toRepositoryId, + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, + type Repository, + type Task, + type Workflow, + type WorkflowSnapshot, +} from "@/lib/types/http"; +import { + useDialogFormState, + useSessionRepoName, + useTaskCreateDialogData, +} from "./task-create-dialog-state"; import { buildRepositoriesPayload } from "./task-create-dialog-helpers"; // `useBranchesByURL` triggers a real network ensure() when given a URL — stub @@ -44,6 +63,17 @@ vi.mock("@/hooks/domains/github/use-pr-info-by-url", async (importOriginal) => { }; }); +vi.mock("@/hooks/domains/settings/use-remote-auth-specs", () => ({ + useRemoteAuthSpecs: () => ({ specs: [], loaded: true }), +})); + +const TASK_ID = toTaskId("task-1"); +const WORKSPACE_ID = toWorkspaceId("workspace-1"); +const WORKFLOW_ID = toWorkflowId("workflow-1"); +const STEP_ID = "step-1"; +const REPOSITORY_ID = toRepositoryId("repo-1"); +const CREATED_AT = "2026-06-24T00:00:00Z"; + function snapshot(workflowId: string): WorkflowSnapshotData { return { workflowId, @@ -67,6 +97,124 @@ function snapshot(workflowId: string): WorkflowSnapshotData { }; } +function workflow(): Workflow { + return { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Query Workflow", + sort_order: 0, + hidden: false, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function rawWorkflowSnapshot(): WorkflowSnapshot { + return { + workflow: workflow(), + steps: [ + { + id: STEP_ID, + workflow_id: WORKFLOW_ID, + name: "Query Step", + color: "bg-green-500", + position: 0, + allow_manual_move: true, + is_start_step: true, + }, + ], + tasks: [], + }; +} + +function repository(): Repository { + return { + id: REPOSITORY_ID, + workspace_id: WORKSPACE_ID, + name: "Query Repo", + source_type: "local", + local_path: "/work/repo", + provider: "", + provider_repo_id: "", + provider_owner: "", + provider_name: "", + default_branch: "main", + worktree_branch_prefix: "", + pull_before_worktree: false, + setup_script: "", + cleanup_script: "", + dev_script: "", + copy_files: "", + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function rawTask(): Task { + return { + id: TASK_ID, + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: STEP_ID, + position: 0, + title: "Query task", + description: "", + state: "TODO", + priority: 0, + repositories: [ + { + id: "task-repo-1", + task_id: TASK_ID, + repository_id: REPOSITORY_ID, + base_branch: "main", + position: 0, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + ], + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function queryClientWithDialogData() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData(qk.tasks.detail(TASK_ID), rawTask()); + client.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), [repository()]); + client.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [workflow()]); + client.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), rawWorkflowSnapshot()); + return client; +} + +function wrapperFor(client: QueryClient) { + const initialState = { + tasks: { activeTaskId: TASK_ID }, + workspaces: { + activeId: WORKSPACE_ID, + items: [ + { + id: WORKSPACE_ID, + name: "Workspace", + owner_id: "owner-1", + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + ], + }, + kanban: { workflowId: null, steps: [], tasks: [] }, + kanbanMulti: { snapshots: {} }, + } as unknown as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { + client, + children: createElement(StateProvider, { initialState, children }), + }); + }; +} + describe("computeDialogDefaultStepId", () => { it("uses the resolved workflow when falling back to snapshot steps", () => { expect( @@ -124,6 +272,33 @@ describe("computeDialogDefaultStepId", () => { }); }); +describe("task-create dialog Query data", () => { + it("resolves session repository names from Query task detail when legacy kanban is empty", () => { + const client = queryClientWithDialogData(); + const { result } = renderHook(() => useSessionRepoName(true), { + wrapper: wrapperFor(client), + }); + + expect(result.current).toBe("Query Repo"); + }); + + it("returns workflow snapshots from Query caches when legacy kanbanMulti is empty", () => { + const client = queryClientWithDialogData(); + const { result } = renderHook( + () => { + const fs = useDialogFormState(false, WORKSPACE_ID, null); + return useTaskCreateDialogData(false, WORKSPACE_ID, WORKFLOW_ID, null, fs); + }, + { wrapper: wrapperFor(client) }, + ); + + expect(result.current.snapshots[WORKFLOW_ID]?.steps).toEqual([ + expect.objectContaining({ id: STEP_ID, title: "Query Step" }), + ]); + expect(result.current.computed.effectiveDefaultStepId).toBe(STEP_ID); + }); +}); + describe("useDialogFormState — remoteRepos mode", () => { it("seeds one empty remoteRepos row when useRemote toggles on with an empty list", () => { const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); @@ -319,8 +494,6 @@ describe("buildRepositoriesPayload — remoteRepos rows", () => { const PR_URL_42 = "https://github.com/acme/site/pull/42"; const PR_TITLE_42 = "PR #42: Test PR"; const USER_TYPED_TITLE = "my own title"; -const ISSUE_URL_1456 = "https://github.com/acme/site/issues/1456"; -const ISSUE_TITLE_1456 = "Issue #1456: Fix remote picker"; function seedPRInfo(url: string, prNumber: number, suggestedTitle: string) { prInfoMap.set(url, { @@ -331,13 +504,6 @@ function seedPRInfo(url: string, prNumber: number, suggestedTitle: string) { }); } -function seedIssueInfo(url: string, issueNumber: number, suggestedTitle: string) { - prInfoMap.set(url, { - issueNumber, - suggestedTitle, - }); -} - describe("useDialogFormState — title autofill from first row GitHub URL info", () => { beforeEach(() => { prInfoMap.clear(); @@ -444,78 +610,3 @@ describe("useDialogFormState — title autofill from first row GitHub URL info", expect(result.current.taskName).toBe(""); }); }); - -describe("useDialogFormState — title autofill from first row GitHub issue info", () => { - beforeEach(() => { - prInfoMap.clear(); - }); - - it("seeds the task title from the first row's issue info when title is empty", () => { - seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456); - const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); - act(() => { - result.current.setUseRemote(true); - }); - const key = result.current.remoteRepos[0]?.key; - act(() => { - result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 }); - }); - expect(result.current.taskName).toBe(ISSUE_TITLE_1456); - expect(result.current.hasTitle).toBe(true); - }); - - it("does NOT overwrite a title the user typed themselves", () => { - seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456); - const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); - act(() => { - result.current.setTaskName(USER_TYPED_TITLE); - result.current.setUseRemote(true); - }); - const key = result.current.remoteRepos[0]?.key; - act(() => { - result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 }); - }); - expect(result.current.taskName).toBe(USER_TYPED_TITLE); - }); - - it("does NOT re-apply autofill after the user clears the title", () => { - seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456); - const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); - act(() => { - result.current.setUseRemote(true); - }); - const key = result.current.remoteRepos[0]?.key; - act(() => { - result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 }); - }); - expect(result.current.taskName).toBe(ISSUE_TITLE_1456); - act(() => { - result.current.setTaskName(""); - }); - expect(result.current.taskName).toBe(""); - }); - - it("re-applies autofill when the user switches to a different issue URL", () => { - const newIssueURL = "https://github.com/acme/site/issues/1457"; - seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456); - seedIssueInfo(newIssueURL, 1457, "Issue #1457: Add issue URL paste"); - const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); - act(() => { - result.current.setUseRemote(true); - }); - const key = result.current.remoteRepos[0]?.key; - act(() => { - result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 }); - }); - expect(result.current.taskName).toBe(ISSUE_TITLE_1456); - act(() => { - result.current.setTaskName(""); - }); - expect(result.current.taskName).toBe(""); - - act(() => { - result.current.updateRemoteRepo(key!, { url: newIssueURL }); - }); - expect(result.current.taskName).toBe("Issue #1457: Add issue URL paste"); - }); -}); diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 311bd363be..d7b97260b4 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -9,9 +9,13 @@ import type { import { useBranchesByURL } from "@/hooks/domains/github/use-branches-by-url"; import { usePRInfoByURL } from "@/hooks/domains/github/use-pr-info-by-url"; import { useAppStore } from "@/components/state-provider"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; -import { useEnsureUserSettings } from "@/hooks/use-ensure-user-settings"; +import { useWorkflows } from "@/hooks/use-workflows"; import { getTaskCreateDraft, setTaskCreateDraft, removeTaskCreateDraft } from "@/lib/local-storage"; import type { StepType, @@ -547,19 +551,14 @@ export { useDialogComputed } from "@/components/task-create-dialog-computed"; export function useSessionRepoName(isSessionMode: boolean) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); - const kanbanTasks = useAppStore((state) => state.kanban.tasks); - const reposByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const activeTask = useTaskById(activeTaskId); + const repositories = useAllCachedRepositories(); return useMemo(() => { if (!isSessionMode) return undefined; - const activeTask = activeTaskId ? kanbanTasks.find((t) => t.id === activeTaskId) : null; const repoId = activeTask?.repositoryId; if (!repoId) return undefined; - for (const repos of Object.values(reposByWorkspace)) { - const repo = repos.find((r) => r.id === repoId); - if (repo) return repo.name; - } - return undefined; - }, [isSessionMode, activeTaskId, kanbanTasks, reposByWorkspace]); + return repositories.find((repo) => repo.id === repoId)?.name; + }, [isSessionMode, activeTask?.repositoryId, repositories]); } export function useTaskCreateDialogData( @@ -569,20 +568,16 @@ export function useTaskCreateDialogData( defaultStepId: string | null, fs: DialogFormState, ) { - const workflows = useAppStore((state) => state.workflows.items); - const workspaces = useAppStore((state) => state.workspaces.items); - const agentProfiles = useAppStore((state) => state.agentProfiles.items); - const executors = useAppStore((state) => state.executors.items); - const settingsData = useAppStore((state) => state.settingsData); - const availableAgentsLoaded = useAppStore((state) => state.availableAgents.loaded); - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); - const taskCreateUserSettings = useEnsureUserSettings(open); - - useSettingsData(open); + const { workflows } = useWorkflows(workspaceId, open); + const { items: workspaces } = useWorkspaces(); + const taskCreateLastUsed = useAppStore((state) => state.userSettings.taskCreateLastUsed); + const userSettingsLoaded = useAppStore((state) => state.userSettings.loaded); + const { snapshots } = useAllWorkflowSnapshots(workspaceId); + + const settingsCatalog = useSettingsData(open); const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, open); // Per-repo branch loading lives in each chip now (RepoChipsRow). No - // global branch query is needed here — the chip uses useRepositoryBranches - // for its own row, and the store dedupes by repositoryId. + // global branch query is needed here; each row reads its own Query cache key. const branchesLoading = false; const computed = useDialogComputed({ fs, @@ -591,13 +586,13 @@ export function useTaskCreateDialogData( workflowId, defaultStepId, settingsData: { - agentsLoaded: settingsData.agentsLoaded, - executorsLoaded: settingsData.executorsLoaded, - capabilitiesLoaded: availableAgentsLoaded, + agentsLoaded: settingsCatalog.settingsData.agentsLoaded, + executorsLoaded: settingsCatalog.settingsData.executorsLoaded, + capabilitiesLoaded: settingsCatalog.settingsData.capabilitiesLoaded, }, - agentProfiles, + agentProfiles: settingsCatalog.agentProfiles, workspaces, - executors, + executors: settingsCatalog.executors, repositories, workflows, snapshots, @@ -605,14 +600,14 @@ export function useTaskCreateDialogData( return { workflows, workspaces, - agentProfiles, - executors, + agentProfiles: settingsCatalog.agentProfiles, + executors: settingsCatalog.executors, snapshots, repositories, repositoriesLoading, branchesLoading, - taskCreateLastUsed: taskCreateUserSettings.userSettings.taskCreateLastUsed, - userSettingsLoaded: taskCreateUserSettings.loaded, + taskCreateLastUsed, + userSettingsLoaded, computed, }; } diff --git a/apps/web/components/task-create-dialog-title-autofill-issues.test.ts b/apps/web/components/task-create-dialog-title-autofill-issues.test.ts new file mode 100644 index 0000000000..25387fbd89 --- /dev/null +++ b/apps/web/components/task-create-dialog-title-autofill-issues.test.ts @@ -0,0 +1,123 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useDialogFormState } from "./task-create-dialog-state"; + +const prInfoMap = new Map< + string, + { + issueNumber?: number; + suggestedTitle: string; + } +>(); + +vi.mock("@/hooks/domains/github/use-branches-by-url", () => ({ + useBranchesByURL: () => ({ + branches: () => [], + loading: () => false, + ensure: () => undefined, + }), +})); + +vi.mock("@/hooks/domains/github/use-pr-info-by-url", async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + usePRInfoByURL: () => ({ + info: (url: string) => prInfoMap.get(url), + loading: () => false, + ensure: () => undefined, + clear: () => undefined, + }), + }; +}); + +vi.mock("@/hooks/domains/settings/use-remote-auth-specs", () => ({ + useRemoteAuthSpecs: () => ({ specs: [], loaded: true }), +})); + +const ISSUE_URL_1456 = "https://github.com/acme/site/issues/1456"; +const ISSUE_TITLE_1456 = "Issue #1456: Fix remote picker"; +const USER_TYPED_TITLE = "my own title"; + +function seedIssueInfo(url: string, issueNumber: number, suggestedTitle: string) { + prInfoMap.set(url, { + issueNumber, + suggestedTitle, + }); +} + +describe("useDialogFormState issue title autofill", () => { + beforeEach(() => { + prInfoMap.clear(); + }); + + it("seeds the task title from the first row's issue info when title is empty", () => { + seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456); + const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); + act(() => { + result.current.setUseRemote(true); + }); + const key = result.current.remoteRepos[0]?.key; + act(() => { + result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 }); + }); + expect(result.current.taskName).toBe(ISSUE_TITLE_1456); + expect(result.current.hasTitle).toBe(true); + }); + + it("does NOT overwrite a title the user typed themselves", () => { + seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456); + const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); + act(() => { + result.current.setTaskName(USER_TYPED_TITLE); + result.current.setUseRemote(true); + }); + const key = result.current.remoteRepos[0]?.key; + act(() => { + result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 }); + }); + expect(result.current.taskName).toBe(USER_TYPED_TITLE); + }); + + it("does NOT re-apply autofill after the user clears the title", () => { + seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456); + const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); + act(() => { + result.current.setUseRemote(true); + }); + const key = result.current.remoteRepos[0]?.key; + act(() => { + result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 }); + }); + expect(result.current.taskName).toBe(ISSUE_TITLE_1456); + act(() => { + result.current.setTaskName(""); + }); + expect(result.current.taskName).toBe(""); + }); + + it("re-applies autofill when the user switches to a different issue URL", () => { + const newIssueURL = "https://github.com/acme/site/issues/1457"; + seedIssueInfo(ISSUE_URL_1456, 1456, ISSUE_TITLE_1456); + seedIssueInfo(newIssueURL, 1457, "Issue #1457: Add issue URL paste"); + const { result } = renderHook(() => useDialogFormState(true, "ws-1", null)); + act(() => { + result.current.setUseRemote(true); + }); + const key = result.current.remoteRepos[0]?.key; + act(() => { + result.current.updateRemoteRepo(key!, { url: ISSUE_URL_1456 }); + }); + expect(result.current.taskName).toBe(ISSUE_TITLE_1456); + act(() => { + result.current.setTaskName(""); + }); + expect(result.current.taskName).toBe(""); + + act(() => { + result.current.updateRemoteRepo(key!, { url: newIssueURL }); + }); + expect(result.current.taskName).toBe("Issue #1457: Add issue URL paste"); + }); +}); diff --git a/apps/web/components/task-create-dialog-types.ts b/apps/web/components/task-create-dialog-types.ts index c80061c5f0..b7ecdbcd36 100644 --- a/apps/web/components/task-create-dialog-types.ts +++ b/apps/web/components/task-create-dialog-types.ts @@ -1,15 +1,14 @@ import type React from "react"; -import type { LocalRepository, Repository, Executor, Task } from "@/lib/types/http"; +import type { LocalRepository, Repository, Executor, Task, Workspace } from "@/lib/types/http"; import type { UseBranchesByURLResult } from "@/hooks/domains/github/use-branches-by-url"; import type { UsePRInfoByURLResult } from "@/hooks/domains/github/use-pr-info-by-url"; -import type { AgentProfileOption, WorkspaceState } from "@/lib/state/slices"; +import type { AgentProfileOption } from "@/lib/state/slices"; import type { KanbanMultiState, + WorkflowItem, WorkflowSnapshotData, - WorkflowsState, } from "@/lib/state/slices/kanban/types"; -type Workspace = WorkspaceState["items"][number]; import type { useRepositoryOptions, useBranchOptions, @@ -405,7 +404,7 @@ export type DialogFormBodyProps = { agentProfilesLoading: boolean; executorsLoading: boolean; isCreatingSession: boolean; - workflows: WorkflowsState["items"]; + workflows: WorkflowItem[]; snapshots: KanbanMultiState["snapshots"]; effectiveWorkflowId: string | null; fs: DialogFormState; diff --git a/apps/web/components/task/auth-methods-indicator.tsx b/apps/web/components/task/auth-methods-indicator.tsx deleted file mode 100644 index 0480655f98..0000000000 --- a/apps/web/components/task/auth-methods-indicator.tsx +++ /dev/null @@ -1,82 +0,0 @@ -"use client"; - -import { memo, useCallback } from "react"; -import { IconKey, IconTerminal2 } 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 { authenticateSession } from "@/lib/api/domains/session-api"; - -type AuthMethodsIndicatorProps = { - sessionId: string | null; -}; - -export const AuthMethodsIndicator = memo(function AuthMethodsIndicator({ - sessionId, -}: AuthMethodsIndicatorProps) { - const authMethods = useAppStore((state) => { - if (!sessionId) return undefined; - return state.agentCapabilities.bySessionId[sessionId]?.authMethods; - }); - - const handleAuthenticate = useCallback( - async (methodId: string) => { - if (!sessionId) return; - try { - await authenticateSession(sessionId, methodId); - } catch { - // Auth call failed — agent will report via events if successful - } - }, - [sessionId], - ); - - if (!sessionId || !authMethods || authMethods.length === 0) { - return null; - } - - return ( - - - - - -
-
Authentication
- {authMethods.map((method) => ( -
-
{method.name}
- {method.description && ( -
{method.description}
- )} - {method.terminalAuth ? ( -
- - - {method.terminalAuth.command} - {method.terminalAuth.args ? ` ${method.terminalAuth.args.join(" ")}` : ""} - -
- ) : ( - - )} -
- ))} -
-
-
- ); -}); diff --git a/apps/web/components/task/base-branch-picker.test.tsx b/apps/web/components/task/base-branch-picker.test.tsx new file mode 100644 index 0000000000..7144d98b56 --- /dev/null +++ b/apps/web/components/task/base-branch-picker.test.tsx @@ -0,0 +1,142 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { + repositoryId, + taskId, + workflowId, + workspaceId, + type Repository, + type Task, +} from "@/lib/types/http"; + +type MockState = { + kanban: { tasks: [] }; + environmentIdBySessionId: Record; + bumpSessionCommitsRefetch: ReturnType; +}; + +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +const repositories: Repository[] = [ + { + id: repositoryId("repo-1"), + workspace_id: workspaceId("ws-1"), + name: "repo-one", + source_type: "local", + local_path: "/repo-one", + provider: "", + provider_repo_id: "", + provider_owner: "", + provider_name: "", + default_branch: "main", + worktree_branch_prefix: "", + pull_before_worktree: false, + setup_script: "", + cleanup_script: "", + dev_script: "", + copy_files: "", + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }, +]; + +let mockState: MockState; + +vi.mock("@tabler/icons-react", () => ({ + IconChevronDown: () => , + IconLoader2: () => , +})); + +vi.mock("@kandev/ui/popover", () => ({ + Popover: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverContent: ({ children }: { children: ReactNode }) =>
{children}
, + PopoverTrigger: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/hooks/domains/workspace/use-repository-cache", () => ({ + useAllCachedRepositories: () => repositories, +})); + +vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({ + useBranches: () => ({ branches: [], isLoading: false }), +})); + +vi.mock("@/hooks/use-environment-session-id", () => ({ + useEnvironmentSessionId: () => "session-1", +})); + +vi.mock("@/hooks/domains/session/use-cumulative-diff", () => ({ + invalidateCumulativeDiffCache: vi.fn(), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), + updateTaskRepositoryBaseBranch: vi.fn(), +})); + +import { BaseBranchPicker } from "./base-branch-picker"; + +function makeTask(): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-1", + position: 1, + title: "Query task", + description: "", + state: "CREATED", + priority: 0, + repositories: [ + { + id: "task-repo-1", + task_id: taskId("task-1"), + repository_id: repositoryId("repo-1"), + base_branch: "main", + position: 0, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }, + ], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("BaseBranchPicker", () => { + beforeEach(() => { + mockState = { + kanban: { tasks: [] }, + environmentIdBySessionId: { "session-1": "env-1" }, + bumpSessionCommitsRefetch: vi.fn(), + }; + }); + + it("resolves the task repository link from task detail Query when kanban tasks are empty", () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + + render(, { + wrapper: wrapper(client), + }); + + expect(screen.getByTestId("base-branch-picker-trigger").textContent).toContain("main"); + }); +}); diff --git a/apps/web/components/task/base-branch-picker.tsx b/apps/web/components/task/base-branch-picker.tsx index faa22e85a7..2bdb45eebd 100644 --- a/apps/web/components/task/base-branch-picker.tsx +++ b/apps/web/components/task/base-branch-picker.tsx @@ -5,12 +5,14 @@ import { IconChevronDown, IconLoader2 } from "@tabler/icons-react"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { cn } from "@/lib/utils"; import { useAppStore } from "@/components/state-provider"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { useBranches } from "@/hooks/domains/workspace/use-repository-branches"; import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; import { invalidateCumulativeDiffCache } from "@/hooks/domains/session/use-cumulative-diff"; import { updateTaskRepositoryBaseBranch } from "@/lib/api/domains/kanban-api"; import { useToast } from "@/components/toast-provider"; -import { repositoryId, type Branch, type Repository } from "@/lib/types/http"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { repositoryId, type Branch } from "@/lib/types/http"; type ResolvedRepo = { taskRepositoryId: string; @@ -25,14 +27,13 @@ type ResolvedRepo = { * tasks the empty `repositoryName` falls back to the only row. */ function useResolvedTaskRepo(taskId: string | null, repositoryName: string): ResolvedRepo | null { - const task = useAppStore((s) => (taskId ? s.kanban.tasks.find((t) => t.id === taskId) : null)); - const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const task = useTaskById(taskId); + const repositories = useAllCachedRepositories(); return useMemo(() => { if (!task?.repositories?.length) return null; - const allRepos = Object.values(reposByWorkspace).flat() as Repository[]; if (repositoryName === "" && task.repositories.length === 1) { const link = task.repositories[0]!; - const repo = allRepos.find((r) => r.id === repositoryId(link.repository_id)); + const repo = repositories.find((r) => r.id === repositoryId(link.repository_id)); return repo ? { taskRepositoryId: link.id, @@ -42,7 +43,7 @@ function useResolvedTaskRepo(taskId: string | null, repositoryName: string): Res } : null; } - const repo = allRepos.find((r) => r.name === repositoryName); + const repo = repositories.find((r) => r.name === repositoryName); if (!repo) return null; const link = task.repositories.find((l) => repositoryId(l.repository_id) === repo.id); return link @@ -53,7 +54,7 @@ function useResolvedTaskRepo(taskId: string | null, repositoryName: string): Res repositoryId: repo.id, } : null; - }, [task, reposByWorkspace, repositoryName]); + }, [task, repositories, repositoryName]); } /** diff --git a/apps/web/components/task/changes-panel-data.test.tsx b/apps/web/components/task/changes-panel-data.test.tsx new file mode 100644 index 0000000000..64b4710cac --- /dev/null +++ b/apps/web/components/task/changes-panel-data.test.tsx @@ -0,0 +1,250 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { + repositoryId, + taskId, + workflowId, + workspaceId, + type Repository, + type Task, +} from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type MockState = { + tasks: { activeTaskId: string | null }; + kanban: { tasks: KanbanState["tasks"] }; + taskSessions: { items: Record }; + pendingPrUrlByTaskId: { byTaskId: Record> }; +}; + +let mockState: MockState; +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +const repositories: Repository[] = [ + { + id: repositoryId("repo-1"), + workspace_id: workspaceId("ws-1"), + name: "repo-one", + source_type: "local", + local_path: "/repo-one", + provider: "", + provider_repo_id: "", + provider_owner: "", + provider_name: "", + default_branch: "main", + worktree_branch_prefix: "", + pull_before_worktree: false, + setup_script: "", + cleanup_script: "", + dev_script: "", + copy_files: "", + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }, +]; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/hooks/domains/session/use-session-git", () => ({ + useSessionGit: () => ({ + hasAnything: false, + hasUnstaged: false, + hasStaged: false, + hasCommits: false, + canPush: false, + canCreatePR: false, + unstagedFiles: [], + stagedFiles: [], + allFiles: [], + cumulativeDiff: null, + commits: [], + pendingStageFiles: new Set(), + ahead: 0, + isLoading: false, + loadingOperation: null, + perRepoStatus: [], + stage: vi.fn(), + unstage: vi.fn(), + stageAll: vi.fn(), + unstageAll: vi.fn(), + stageFile: vi.fn(() => Promise.resolve()), + unstageFile: vi.fn(() => Promise.resolve()), + }), +})); + +vi.mock("@/hooks/use-session-file-reviews", () => ({ + useSessionFileReviews: () => ({ reviews: new Map() }), +})); + +vi.mock("@/hooks/use-environment-session-id", () => ({ + useEnvironmentSessionId: () => "session-1", +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock("@/components/vcs/vcs-dialogs", () => ({ + useVcsDialogs: () => ({ + openCommitDialog: vi.fn(), + openPRDialog: vi.fn(), + }), +})); + +vi.mock("@/components/task/changes-panel-hooks", () => ({ + useChangesGitHandlers: () => ({ + handleGitOperation: vi.fn(), + handlePush: vi.fn(), + handleForcePush: vi.fn(), + handleRevertCommit: vi.fn(), + }), + useChangesDialogHandlers: () => ({ + handleBulkDiscardClick: vi.fn(), + }), +})); + +vi.mock("@/hooks/domains/session/use-repo-display-name", () => ({ + useRepoDisplayName: () => undefined, +})); + +vi.mock("@/hooks/domains/session/use-base-branch-by-repo", () => ({ + useBaseBranchByRepo: () => ({}), +})); + +vi.mock("@/hooks/domains/workspace/use-repository-cache", () => ({ + useRepositoriesByWorkspace: () => ({ "ws-1": repositories }), +})); + +vi.mock("@/hooks/domains/github/use-task-pr", () => ({ + useActiveTaskPR: () => ({ + owner: "owner", + repo: "repo", + pr_number: 1, + last_synced_at: "sync", + }), +})); + +vi.mock("@/hooks/domains/github/use-active-task-pr-files", () => ({ + useActiveTaskPRsWithFiles: () => ({ + prs: [ + { + owner: "owner", + repo: "repo", + pr_number: 1, + repository_id: "repo-1", + head_branch: "branch-a", + last_synced_at: "sync", + pr_url: "https://example.test/pr/1", + }, + { + owner: "owner", + repo: "repo", + pr_number: 2, + repository_id: "repo-1", + head_branch: "branch-b", + last_synced_at: "sync", + pr_url: "https://example.test/pr/2", + }, + ], + filesByPRKey: { + "owner/repo/1/sync": [ + { + filename: "a.ts", + status: "modified", + additions: 1, + deletions: 0, + patch: "@@", + }, + ], + "owner/repo/2/sync": [ + { + filename: "b.ts", + status: "modified", + additions: 1, + deletions: 0, + patch: "@@", + }, + ], + }, + }), +})); + +vi.mock("@/hooks/domains/github/use-pr-commits", () => ({ + usePRCommits: () => ({ commits: [] }), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), +})); + +import { useChangesPanelData } from "./changes-panel-data"; + +function makeTask(): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-1", + position: 1, + title: "Query task", + description: "", + state: "CREATED", + priority: 0, + repositories: [ + { + id: "task-repo-1", + task_id: taskId("task-1"), + repository_id: repositoryId("repo-1"), + base_branch: "main", + position: 0, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }, + { + id: "task-repo-2", + task_id: taskId("task-1"), + repository_id: repositoryId("repo-2"), + base_branch: "main", + position: 1, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }, + ], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + +describe("useChangesPanelData", () => { + beforeEach(() => { + mockState = { + tasks: { activeTaskId: "task-1" }, + kanban: { tasks: [] }, + taskSessions: { items: { "session-1": { base_branch: "main" } } }, + pendingPrUrlByTaskId: { byTaskId: {} }, + }; + }); + + it("uses task detail Query repositories to group multiple PRs on a multi-repo task", () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + + const { result } = renderHook(() => useChangesPanelData(), { wrapper: wrapper(client) }); + + expect(result.current.prFiles.map((file) => file.repository_name)).toEqual([ + "repo-one · branch-a", + "repo-one · branch-b", + ]); + }); +}); diff --git a/apps/web/components/task/changes-panel-data.tsx b/apps/web/components/task/changes-panel-data.tsx index 4de791a7ee..fc18b029b7 100644 --- a/apps/web/components/task/changes-panel-data.tsx +++ b/apps/web/components/task/changes-panel-data.tsx @@ -12,9 +12,11 @@ import type { PRDiffFile } from "@/lib/types/github"; import { useChangesGitHandlers, useChangesDialogHandlers } from "./changes-panel-hooks"; import { useRepoDisplayName } from "@/hooks/domains/session/use-repo-display-name"; import { useBaseBranchByRepo } from "@/hooks/domains/session/use-base-branch-by-repo"; +import { useRepositoriesByWorkspace } from "@/hooks/domains/workspace/use-repository-cache"; import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useActiveTaskPRsWithFiles } from "@/hooks/domains/github/use-active-task-pr-files"; import { usePRCommits } from "@/hooks/domains/github/use-pr-commits"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; import { type ChangedFile, computePRGroupStamp, @@ -31,26 +33,13 @@ import type { OpenDiffOptions } from "./changes-diff-target"; function useChangesPanelStoreData() { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useEnvironmentSessionId(); - const taskTitle = useAppStore((state) => { - if (!state.tasks.activeTaskId) return undefined; - return state.kanban.tasks.find((t: { id: string }) => t.id === state.tasks.activeTaskId)?.title; - }); const baseBranch = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId]?.base_branch : undefined, ); - const existingPrUrl = useAppStore((state) => { - const taskId = state.tasks.activeTaskId; - if (!taskId) return undefined; - // Multi-branch tasks hold N PRs per task. The panel-header "View PR" - // button is a single-URL surface, so collapse only when there's exactly - // one PR — otherwise the per-repo buttons (prByRepo) take over and the - // generic button is hidden to avoid silently linking to a sibling. - const taskPRs = state.taskPRs.byTaskId[taskId]; - if (Array.isArray(taskPRs) && taskPRs.length === 1) return taskPRs[0]?.pr_url; - if (Array.isArray(taskPRs) && taskPRs.length > 1) return undefined; - return state.pendingPrUrlByTaskId.byTaskId[taskId]?.[""]; - }); - return { activeTaskId, activeSessionId, taskTitle, baseBranch, existingPrUrl }; + const pendingPrUrl = useAppStore((state) => + activeTaskId ? state.pendingPrUrlByTaskId.byTaskId[activeTaskId]?.[""] : undefined, + ); + return { activeTaskId, activeSessionId, baseBranch, pendingPrUrl }; } type DialogsType = ReturnType & ReturnType; @@ -150,14 +139,11 @@ function usePerRepoCallbacks( function useChangesPanelPRData() { const { prs, filesByPRKey } = useActiveTaskPRsWithFiles(); - const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const activeTask = useTaskById(activeTaskId); + const reposByWorkspace = useRepositoriesByWorkspace(); const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]); - const taskHasMultipleRepos = useAppStore((s) => { - const taskId = s.tasks.activeTaskId; - if (!taskId) return false; - const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return (task?.repositories?.length ?? 0) > 1; - }); + const taskHasMultipleRepos = (activeTask?.repositories?.length ?? 0) > 1; const taskPR = useActiveTaskPR(); const refreshKey = taskPR?.last_synced_at ?? null; const { commits: prCommitsList } = usePRCommits( @@ -206,7 +192,7 @@ function useChangesPanelPRData() { }, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos]); const hasPRFiles = prFiles.length > 0; const hasPRCommits = prCommitsList.length > 0; - return { prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles }; + return { prs, prDiffFiles, prCommitsList, hasPRFiles, hasPRCommits, prFiles }; } function hasCumulativeFiles(files: Record | null | undefined): boolean { @@ -214,7 +200,7 @@ function hasCumulativeFiles(files: Record | null | undefined): } export function useChangesPanelData() { - const { activeTaskId, activeSessionId, baseBranch, existingPrUrl } = useChangesPanelStoreData(); + const { activeTaskId, activeSessionId, baseBranch, pendingPrUrl } = useChangesPanelStoreData(); const baseBranchByRepo = useBaseBranchByRepo(activeTaskId); const git = useSessionGit(activeSessionId); const { toast } = useToast(); @@ -234,10 +220,8 @@ export function useChangesPanelData() { const dialogs = { ...localDialogs, ...vcsDialogs }; const repoCallbacks = usePerRepoCallbacks(git, vcsDialogs, gitHandlers); const repoDisplayName = useRepoDisplayName(activeSessionId); - const taskPRsForMap = useAppStore((state) => - activeTaskId ? state.taskPRs.byTaskId[activeTaskId] : undefined, - ); - const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const taskPRsForMap = prData.prs; + const reposByWorkspace = useRepositoriesByWorkspace(); const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]); const pendingByRepo = useAppStore((state) => activeTaskId ? state.pendingPrUrlByTaskId.byTaskId[activeTaskId] : undefined, @@ -251,6 +235,15 @@ export function useChangesPanelData() { stagedFiles.length > 0 || (git.statusLoaded && hasCumulativeFiles(git.cumulativeDiff?.files)) || prData.prFiles.length > 0; + const existingPrUrl = useMemo(() => { + // Multi-branch tasks hold N PRs per task. The panel-header "View PR" + // button is a single-URL surface, so collapse only when there's exactly + // one PR — otherwise the per-repo buttons (prByRepo) take over and the + // generic button is hidden to avoid silently linking to a sibling. + if (taskPRsForMap.length === 1) return taskPRsForMap[0]?.pr_url; + if (taskPRsForMap.length > 1) return undefined; + return pendingPrUrl; + }, [pendingPrUrl, taskPRsForMap]); return { activeTaskId, activeSessionId, diff --git a/apps/web/components/task/changes-tab.tsx b/apps/web/components/task/changes-tab.tsx index dd962d75d7..70c6b3913b 100644 --- a/apps/web/components/task/changes-tab.tsx +++ b/apps/web/components/task/changes-tab.tsx @@ -9,8 +9,7 @@ import { ContextMenuTrigger, } from "@kandev/ui/context-menu"; import { useAppStore } from "@/components/state-provider"; -import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; -import { useSessionChangesCount } from "@/hooks/domains/session/use-session-changes-count"; +import { useSessionChangesSummary } from "@/hooks/domains/session/use-session-changes-count"; import { cn } from "@kandev/ui/lib/utils"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; import { autoActivateChangesPanel } from "./changes-panel-focus"; @@ -25,12 +24,7 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) { const onDoubleClick = useTabMaximizeOnDoubleClick(api); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); - const gitStatus = useSessionGitStatus(activeSessionId); - const totalCount = useSessionChangesCount(activeSessionId ?? null); - - // gitStatus is undefined until the first WS git-status event arrives, - // which marks the end of the initial data load for this session. - const gitStatusLoaded = gitStatus !== undefined; + const { totalCount, loaded: changesLoaded } = useSessionChangesSummary(activeSessionId ?? null); const prevTotalRef = useRef(totalCount); const seenCountRef = useRef(api.isActive ? totalCount : 0); @@ -74,11 +68,15 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) { const increased = totalCount > prev && totalCount > 0; const decreased = totalCount < prev; - // Auto-activate on real post-load updates, but only after initial git data - // has settled. gitStatusLoaded is false until the first WS git-status event - // arrives, guaranteeing existing changes on page refresh do not steal focus. + // Auto-activate on real post-load updates, but only after the initial + // git-status and commits snapshots have both settled. Existing changes can + // arrive in either source during page refresh and must not steal focus. if (!initializedRef.current) { - if (gitStatusLoaded) initializedRef.current = true; + if (changesLoaded) { + initializedRef.current = true; + prevTotalRef.current = totalCount; + seenCountRef.current = api.isActive ? totalCount : 0; + } } else if (increased) { // The product behavior is to surface every new git update unless the // changes panel shares a group with agent session panels. @@ -96,7 +94,7 @@ export function ChangesTab(props: IDockviewPanelHeaderProps) { const unseen = Math.max(0, totalCount - seenCountRef.current); requestAnimationFrame(() => setBadgeCount(unseen)); } - }, [totalCount, api, gitStatusLoaded, activeSessionId]); + }, [totalCount, api, changesLoaded, activeSessionId]); // Cleanup flash timer on unmount useEffect(() => { diff --git a/apps/web/components/task/chat/chat-input-area.test.tsx b/apps/web/components/task/chat/chat-input-area.test.tsx index aaf8c77dab..932f2e20dc 100644 --- a/apps/web/components/task/chat/chat-input-area.test.tsx +++ b/apps/web/components/task/chat/chat-input-area.test.tsx @@ -4,11 +4,12 @@ import { act, cleanup, renderHook } from "@testing-library/react"; const toastMock = vi.fn(); const handleSendMessageMock = vi.fn(); -const mockState = {}; +const mockState = { + workspaces: { activeId: "workspace-1" }, +}; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), - useAppStoreApi: () => ({ getState: () => mockState }), })); vi.mock("@/components/toast-provider", () => ({ @@ -55,6 +56,10 @@ vi.mock("@/hooks/domains/kanban/use-plan-actions", () => ({ }), })); +vi.mock("@/hooks/domains/kanban/use-all-workflow-snapshots", () => ({ + useAllWorkflowSnapshots: () => ({ snapshots: {} }), +})); + vi.mock("@/hooks/domains/session/use-executor-environment-availability", () => ({ useExecutorEnvironmentAvailability: () => ({ unavailable: false, diff --git a/apps/web/components/task/chat/chat-input-area.tsx b/apps/web/components/task/chat/chat-input-area.tsx index d3c775983a..3bd873a012 100644 --- a/apps/web/components/task/chat/chat-input-area.tsx +++ b/apps/web/components/task/chat/chat-input-area.tsx @@ -11,7 +11,8 @@ import { ShareButton, shareableSessionStateClient } from "@/components/task/shar import { getWebSocketClient } from "@/lib/ws/connection"; import { useKeyboardShortcut } from "@/hooks/use-keyboard-shortcut"; import { useMessageHandler, buildTaskMentionsContext } from "@/hooks/use-message-handler"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { useAppStore } from "@/components/state-provider"; import { getShortcut } from "@/lib/keyboard/shortcut-overrides"; import { type ContextFile } from "@/lib/state/context-files-store"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; @@ -139,7 +140,8 @@ export function useSubmitHandler( onSend?: (message: string) => void, ) { const [isSending, setIsSending] = useState(false); - const storeApi = useAppStoreApi(); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const { snapshots } = useAllWorkflowSnapshots(activeWorkspaceId); const { toast } = useToast(); const { resolvedSessionId, @@ -178,7 +180,7 @@ export function useSubmitHandler( if (onSend) { // Expand task mentions because onSend bypasses useMessageHandler.buildFinalMessage. const taskCtx = inlineTaskMentions?.length - ? buildTaskMentionsContext(inlineTaskMentions, storeApi.getState()) + ? buildTaskMentionsContext(inlineTaskMentions, snapshots) : ""; await onSend(finalMessage + taskCtx); } else { @@ -212,7 +214,7 @@ export function useSubmitHandler( [ isSending, onSend, - storeApi, + snapshots, handleSendMessage, markCommentsSent, planComments, diff --git a/apps/web/components/task/chat/chat-input-container.tsx b/apps/web/components/task/chat/chat-input-container.tsx index a2449811b0..c5621d6a0b 100644 --- a/apps/web/components/task/chat/chat-input-container.tsx +++ b/apps/web/components/task/chat/chat-input-container.tsx @@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { NewSessionDialog } from "@/components/task/new-session-dialog"; import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { Message } from "@/lib/types/http"; import type { DiffComment } from "@/lib/diff/types"; @@ -140,11 +141,9 @@ function FailedSessionBanner({ const agentProfileId = useAppStore((s) => sessionId ? (s.taskSessions.items[sessionId]?.agent_profile_id ?? "") : "", ); - const profileExists = useAppStore( - (s) => - agentProfileId !== "" && - s.agentProfiles.items.some((p: { id: string }) => p.id === agentProfileId), - ); + const { agentProfiles } = useSettingsData(Boolean(agentProfileId)); + const profileExists = + agentProfileId !== "" && agentProfiles.some((profile) => profile.id === agentProfileId); const handleRecover = useCallback( async (action: "resume" | "fresh_start") => { diff --git a/apps/web/components/task/chat/context-items/lazy-plan-preview.test.tsx b/apps/web/components/task/chat/context-items/lazy-plan-preview.test.tsx new file mode 100644 index 0000000000..93ce381844 --- /dev/null +++ b/apps/web/components/task/chat/context-items/lazy-plan-preview.test.tsx @@ -0,0 +1,62 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { taskPlanQueryOptions } from "@/lib/query/query-options"; +import type { TaskPlan } from "@/lib/types/http"; +import { LazyPlanPreview } from "./lazy-plan-preview"; + +vi.mock("@/lib/api/domains/plan-api", () => ({ + createTaskPlan: vi.fn(), + deleteTaskPlan: vi.fn(), + getPlanRevision: vi.fn(), + getTaskPlan: vi.fn(), + listPlanRevisions: vi.fn(), + revertPlanRevision: vi.fn(), + updateTaskPlan: vi.fn(), +})); + +const TASK_ID = "task-1"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); +} + +function renderWithQuery(client: QueryClient, children: ReactNode) { + return render({children}); +} + +function planFixture(content: string): TaskPlan { + return { + id: "plan-1", + task_id: TASK_ID, + title: "Plan", + content, + created_by: "agent", + created_at: "2026-06-23T00:00:00Z", + updated_at: "2026-06-23T00:00:00Z", + } as TaskPlan; +} + +describe("LazyPlanPreview", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("renders cached Query plan content without a Zustand provider", () => { + const client = createQueryClient(); + client.setQueryData( + taskPlanQueryOptions(TASK_ID).queryKey, + planFixture("## Plan\n\n1. Ship Query-backed context"), + ); + + renderWithQuery(client, ); + + expect(screen.getByText("Plan")).toBeTruthy(); + expect(screen.getByText(/Ship Query-backed context/)).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/chat/context-items/lazy-plan-preview.tsx b/apps/web/components/task/chat/context-items/lazy-plan-preview.tsx index d334f8779c..7f5de2d762 100644 --- a/apps/web/components/task/chat/context-items/lazy-plan-preview.tsx +++ b/apps/web/components/task/chat/context-items/lazy-plan-preview.tsx @@ -1,44 +1,21 @@ "use client"; -import { useEffect } from "react"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { getTaskPlan } from "@/lib/api/domains/plan-api"; +import { useQuery } from "@tanstack/react-query"; +import { taskPlanQueryOptions } from "@/lib/query/query-options"; type LazyPlanPreviewProps = { taskId: string | null; }; export function LazyPlanPreview({ taskId }: LazyPlanPreviewProps) { - const plan = useAppStore((state) => (taskId ? (state.taskPlans.byTaskId[taskId] ?? null) : null)); - const loaded = useAppStore((state) => - taskId ? (state.taskPlans.loadedByTaskId[taskId] ?? false) : false, - ); - const loading = useAppStore((state) => - taskId ? (state.taskPlans.loadingByTaskId[taskId] ?? false) : false, - ); - - const storeApi = useAppStoreApi(); - - useEffect(() => { - if (!taskId || loaded || loading) return; - - const { setTaskPlanLoading } = storeApi.getState(); - setTaskPlanLoading(taskId, true); - - getTaskPlan(taskId) - .then((result) => { - storeApi.getState().setTaskPlan(taskId, result); - }) - .catch(() => { - storeApi.getState().setTaskPlanLoading(taskId, false); - }); - }, [taskId, loaded, loading, storeApi]); + const planQuery = useQuery(taskPlanQueryOptions(taskId ?? "")); + const plan = planQuery.data; if (!taskId) { return
No task selected
; } - if (loading || !loaded) { + if (planQuery.isFetching || plan === undefined) { return (
Plan
diff --git a/apps/web/components/task/chat/context-items/prompt-preview.tsx b/apps/web/components/task/chat/context-items/prompt-preview.tsx index 68ebc1675b..2f34f0f652 100644 --- a/apps/web/components/task/chat/context-items/prompt-preview.tsx +++ b/apps/web/components/task/chat/context-items/prompt-preview.tsx @@ -1,6 +1,6 @@ "use client"; -import { useAppStore } from "@/components/state-provider"; +import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; type PromptPreviewProps = { content: string | null; @@ -28,10 +28,8 @@ type PromptPreviewFromStoreProps = { }; export function PromptPreviewFromStore({ promptId }: PromptPreviewFromStoreProps) { - const content = useAppStore((state) => { - const prompt = state.prompts.items.find((p) => p.id === promptId); - return prompt?.content ?? null; - }); + const { prompts } = useCustomPrompts(); + const content = prompts.find((p) => p.id === promptId)?.content ?? null; return ; } diff --git a/apps/web/components/task/chat/message-renderer.tsx b/apps/web/components/task/chat/message-renderer.tsx index 2fcef0e5fd..ae864ab1f6 100644 --- a/apps/web/components/task/chat/message-renderer.tsx +++ b/apps/web/components/task/chat/message-renderer.tsx @@ -1,6 +1,7 @@ "use client"; import { memo, useState, useCallback, type ReactElement } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconPlayerPlay } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import type { Message, TaskSessionState } from "@/lib/types/http"; @@ -8,6 +9,7 @@ import type { ToolCallMetadata } from "@/components/task/chat/types"; import { launchSession } from "@/lib/services/session-launch-service"; import { buildStartCreatedRequest } from "@/lib/services/session-launch-helpers"; import { useAppStore } from "@/components/state-provider"; +import { prepareProgressQueryOptions } from "@/lib/query/query-options"; import { useTask } from "@/hooks/use-task"; import { ChatMessage } from "@/components/task/chat/messages/chat-message"; import { PermissionRequestMessage } from "@/components/task/chat/messages/permission-request-message"; @@ -43,9 +45,11 @@ type AdapterContext = { function TaskDescriptionStartButton({ taskId, sessionId }: { taskId: string; sessionId: string }) { const [isStarting, setIsStarting] = useState(false); - const prepareStatus = useAppStore( + const prepareQuery = useQuery(prepareProgressQueryOptions(sessionId)); + const storePrepareStatus = useAppStore( (state) => state.prepareProgress.bySessionId[sessionId]?.status ?? null, ); + const prepareStatus = prepareQuery.data?.status ?? storePrepareStatus; const handleStart = useCallback(async () => { setIsStarting(true); diff --git a/apps/web/components/task/chat/messages/action-message.test.tsx b/apps/web/components/task/chat/messages/action-message.test.tsx index 1dcf3c9e04..64fc7fc253 100644 --- a/apps/web/components/task/chat/messages/action-message.test.tsx +++ b/apps/web/components/task/chat/messages/action-message.test.tsx @@ -1,6 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { StateProvider } from "@/components/state-provider"; +import { makeQueryClient } from "@/lib/query/client"; import { ActionMessage } from "./action-message"; import { sessionId as toSessionId, @@ -64,9 +66,12 @@ function renderAction(comment: Message, sessionState?: TaskSessionState) { const initialState: Partial = sessionState ? { taskSessions: { items: { "sess-1": { state: sessionState } as TaskSession } } } : {}; + const queryClient = makeQueryClient(); return render(, { wrapper: ({ children }) => ( - {children} + + {children} + ), }); } diff --git a/apps/web/components/task/chat/messages/agent-status.tsx b/apps/web/components/task/chat/messages/agent-status.tsx index 316fbe4058..69bf6b1d25 100644 --- a/apps/web/components/task/chat/messages/agent-status.tsx +++ b/apps/web/components/task/chat/messages/agent-status.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState, useCallback } from "react"; import { IconAlertCircle, IconAlertTriangle, IconChevronDown } from "@tabler/icons-react"; import type { Message, TaskSessionState } from "@/lib/types/http"; import { useSessionTurn } from "@/hooks/domains/session/use-session-turn"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useAppStore } from "@/components/state-provider"; import { GridSpinner } from "@/components/grid-spinner"; import { resolveAgentErrorLabel } from "./agent-error-label"; @@ -253,7 +254,7 @@ function useAgentLabel(sessionId: string | null, dynamicLabel?: boolean): string const agentProfileId = useAppStore((state) => sessionId ? state.taskSessions.items[sessionId]?.agent_profile_id : undefined, ) as string | undefined; - const agentProfiles = useAppStore((state) => state.agentProfiles.items); + const { agentProfiles } = useSettingsData(Boolean(dynamicLabel && agentProfileId)); if (!dynamicLabel || !agentProfileId) return null; const profile = agentProfiles.find((p) => p.id === agentProfileId); return profile ? profile.label.split(" \u2022 ")[0] : null; diff --git a/apps/web/components/task/chat/messages/chat-message.test.tsx b/apps/web/components/task/chat/messages/chat-message.test.tsx index b106198e7a..846ee7b32a 100644 --- a/apps/web/components/task/chat/messages/chat-message.test.tsx +++ b/apps/web/components/task/chat/messages/chat-message.test.tsx @@ -1,20 +1,26 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { StateProvider } from "@/components/state-provider"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import { ChatMessage } from "./chat-message"; import { sessionId as toSessionId, taskId as toTaskId, + workspaceId, + workflowId, type CustomPrompt, type Message, + type Task, type TaskSession, } from "@/lib/types/http"; const SENDER_TASK_ID = "task-sender"; const SENDER_TITLE = "Fix login bug"; const SENDER_BADGE_SELECTOR = "[data-testid='sender-task-badge']"; -const MESSAGE_TIMESTAMP = "2026-05-04T00:00:00Z"; +const CREATED_AT = "2026-05-04T00:00:00Z"; const PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; const OPEN_ATTACHMENT_1_LABEL = "Open Attachment 1"; @@ -34,7 +40,7 @@ function userMessage(overrides: Partial): Message { author_type: "user", content: "hello", type: "message", - created_at: MESSAGE_TIMESTAMP, + created_at: CREATED_AT, ...overrides, }; } @@ -45,34 +51,40 @@ function customPrompt(name: string): CustomPrompt { name, content: `${name} content`, builtin: false, - created_at: MESSAGE_TIMESTAMP, - updated_at: MESSAGE_TIMESTAMP, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function taskForBadge(id: string, title: string): Task { + return { + id: toTaskId(id), + workspace_id: workspaceId("workspace-1"), + workflow_id: workflowId("workflow-1"), + workflow_step_id: "step-1", + position: 0, + title, + description: "", + state: "CREATED", + priority: 0, + repositories: [], + created_at: CREATED_AT, + updated_at: CREATED_AT, }; } function wrapper(tasks: Array<{ id: string; title: string }> = [], prompts: CustomPrompt[] = []) { return function Wrapper({ children }: { children: ReactNode }) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.settings.prompts(), { prompts, total: prompts.length }); + for (const task of tasks) { + const cachedTask = taskForBadge(task.id, task.title); + queryClient.setQueryData(qk.tasks.detail(cachedTask.id), cachedTask); + } return ( - ({ - id: t.id, - title: t.title, - workflow_step_id: "", - priority: 0, - parent_id: undefined, - })), - } as unknown as never, - prompts: { items: prompts, loaded: true, loading: false }, - }} - > - {children} - + + {children} + ); }; } @@ -173,18 +185,22 @@ function renderAgentMessageWithSession(session: Partial, metadata = id: toSessionId("sess-1"), task_id: toTaskId("task-target"), state: "COMPLETED", - started_at: MESSAGE_TIMESTAMP, - updated_at: MESSAGE_TIMESTAMP, + started_at: CREATED_AT, + updated_at: CREATED_AT, ...session, }; + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.settings.prompts(), { prompts: [], total: 0 }); const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - + + + {children} + + ); return render( @@ -223,7 +239,7 @@ describe("ChatMessage sender badge", () => { }); it("renders a non-clickable greyed badge when sender task is unknown", () => { - // No tasks seeded — sender task is "deleted" or cross-workspace. + // No tasks seeded; sender task is "deleted" or cross-workspace. const { container } = renderWithSender([], { sender_task_id: "task-deleted", sender_task_title: "Old title", @@ -237,7 +253,7 @@ describe("ChatMessage sender badge", () => { }); it("uses the live title when it differs from the snapshot", () => { - // The badge re-resolves the title from the kanban store so renames are + // The badge re-resolves the title from the Query cache so renames are // reflected without re-sending the message. const { container } = renderWithSender([{ id: SENDER_TASK_ID, title: "Renamed task" }], { sender_task_id: SENDER_TASK_ID, diff --git a/apps/web/components/task/chat/messages/chat-message.tsx b/apps/web/components/task/chat/messages/chat-message.tsx index 057a0a6d24..64edd1bfd8 100644 --- a/apps/web/components/task/chat/messages/chat-message.tsx +++ b/apps/web/components/task/chat/messages/chat-message.tsx @@ -20,9 +20,9 @@ import { SenderTaskBadge, type SenderTaskInfo } from "./sender-task-badge"; import { MemoizedMarkdown } from "@/components/shared/memoized-markdown"; import { markdownComponents } from "@/components/shared/markdown-components"; import { ImagePreviewDialog } from "@/components/task/chat/image-preview-dialog"; -import { useAppStore } from "@/components/state-provider"; import { HoverCard, HoverCardTrigger, HoverCardContent } from "@kandev/ui/hover-card"; import { PromptPreview } from "@/components/task/chat/context-items/prompt-preview"; +import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; import { buildPromptMentionNames, splitPreparedPromptMentionSegments, @@ -173,7 +173,7 @@ type MarkdownChildrenProps = ComponentPropsW }; function usePromptMentionNames() { - const prompts = useAppStore((state) => state.prompts.items); + const { prompts } = useCustomPrompts(); return useMemo(() => prompts.map((prompt) => prompt.name), [prompts]); } @@ -266,11 +266,10 @@ const PROMPT_MENTION_CHIP_CLASS = * doesn't need to switch to the raw message view. */ function PromptMentionChip({ name, value }: { name: string; value: string }) { - const content = useAppStore( - useCallback( - (state) => state.prompts.items.find((prompt) => prompt.name === name)?.content ?? null, - [name], - ), + const { prompts } = useCustomPrompts(); + const content = useMemo( + () => prompts.find((prompt) => prompt.name === name)?.content ?? null, + [name, prompts], ); if (!content) { diff --git a/apps/web/components/task/chat/messages/message-actions.tsx b/apps/web/components/task/chat/messages/message-actions.tsx index 9d0eb443ad..a7a4d8f971 100644 --- a/apps/web/components/task/chat/messages/message-actions.tsx +++ b/apps/web/components/task/chat/messages/message-actions.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useShallow } from "zustand/react/shallow"; import { IconCheck, @@ -15,6 +16,7 @@ import { cn } from "@/lib/utils"; import { formatRelativeTime } from "@/lib/utils"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useAppStore } from "@/components/state-provider"; +import { sessionModelsQueryOptions } from "@/lib/query/query-options"; import type { Message, Turn } from "@/lib/types/http"; import { buildMessageDebugEntries, @@ -354,6 +356,34 @@ function MessageMetaInfo({ ); } +function useMessageTurn(message: Message): Turn | null { + return useAppStore( + useShallow((state) => { + const turnId = message.turn_id; + const turn = + turnId && message.session_id + ? (state.turns.bySession[message.session_id]?.find((item) => item.id === turnId) ?? null) + : null; + return turn; + }), + ); +} + +function useSessionModelsForMessage(message: Message) { + const sessionModelsQuery = useQuery(sessionModelsQueryOptions(message.session_id ?? "")); + const storeSessionModels = useAppStore((state) => + message.session_id ? state.sessionModels.bySessionId[message.session_id] : undefined, + ); + return sessionModelsQuery.data ?? storeSessionModels; +} + +function useMessageUsageMultiplier(message: Message, turn: Turn | null) { + const sessionModels = useSessionModelsForMessage(message); + const metadataModel = (message.metadata?.model ?? turn?.metadata?.model) as string | undefined; + const modelId = metadataModel ?? sessionModels?.currentModelId; + return sessionModels?.models.find((model) => model.modelId === modelId)?.usageMultiplier ?? null; +} + export function MessageActions({ message, showCopy = true, @@ -371,24 +401,8 @@ export function MessageActions({ }: MessageActionsProps) { const { copied, copy } = useCopyToClipboard(); const sessionConfigText = useMessageSessionConfigText(message, showModel); - const { turn, usageMultiplier } = useAppStore( - useShallow((state) => { - const turnId = message.turn_id; - const turn = - turnId && message.session_id - ? (state.turns.bySession[message.session_id]?.find((item) => item.id === turnId) ?? null) - : null; - if (!message.session_id) return { turn, usageMultiplier: null }; - const sessionModels = state.sessionModels.bySessionId[message.session_id]; - const metadataModel = (message.metadata?.model ?? turn?.metadata?.model) as - | string - | undefined; - const modelId = metadataModel ?? sessionModels?.currentModelId; - const usageMultiplier = - sessionModels?.models.find((model) => model.modelId === modelId)?.usageMultiplier ?? null; - return { turn, usageMultiplier }; - }), - ); + const turn = useMessageTurn(message); + const usageMultiplier = useMessageUsageMultiplier(message, turn); const handleCopy = async () => { await copy(message.content); }; diff --git a/apps/web/components/task/chat/pr-archive-banners.test.tsx b/apps/web/components/task/chat/pr-archive-banners.test.tsx index 941aed8aa4..7289a04b0b 100644 --- a/apps/web/components/task/chat/pr-archive-banners.test.tsx +++ b/apps/web/components/task/chat/pr-archive-banners.test.tsx @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import { taskId as toTaskId, workspaceId, workflowId, type Task } from "@/lib/types/http"; const archiveAndSwitchMock = vi.fn(); const toastMock = vi.fn(); @@ -11,20 +16,11 @@ const taskPRsByTaskId = vi.hoisted(() => ({ })); const mockState = { - taskPRs: { - get byTaskId() { - return taskPRsByTaskId.value; - }, - }, - kanban: { - tasks: [{ id: "task-1", title: "Task One", primaryExecutorType: "worktree" }], - }, - kanbanMulti: { snapshots: {} }, + clearPendingPrUrlForTaskPR: vi.fn(), }; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), - useAppStoreApi: () => ({ getState: () => mockState }), })); vi.mock("@/hooks/use-task-actions", () => ({ @@ -35,10 +31,14 @@ vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: toastMock }), })); -vi.mock("@/lib/api", () => ({ +vi.mock("@/lib/api/domains/kanban-api", () => ({ getSubtaskCount: (...args: unknown[]) => mockGetSubtaskCount(...args), })); +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => null, +})); + vi.mock("@/lib/local-storage", async (importOriginal) => ({ ...(await importOriginal()), markPRClosedBannerDismissed: vi.fn(), @@ -49,9 +49,41 @@ vi.mock("@/lib/local-storage", async (importOriginal) => ({ import { PRClosedBanner, PRMergedBanner } from "./pr-archive-banners"; +const CREATED_AT = "2026-05-04T00:00:00Z"; const MERGED_ARCHIVE_BUTTON = "pr-merged-archive-button"; const MERGED_ARCHIVE_CONFIRM = "pr-merged-archive-confirm"; +function taskForBanner(id: string, title: string): Task { + return { + id: toTaskId(id), + workspace_id: workspaceId("workspace-1"), + workflow_id: workflowId("workflow-1"), + workflow_step_id: "step-1", + position: 0, + title, + description: "", + state: "CREATED", + priority: 0, + repositories: [], + primary_executor_type: "worktree", + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function createTestQueryClient() { + const queryClient = makeQueryClient(); + for (const [taskId, prs] of Object.entries(taskPRsByTaskId.value)) { + queryClient.setQueryData(qk.integrations.github.taskPr(taskId), prs); + } + queryClient.setQueryData(qk.tasks.detail("task-1"), taskForBanner("task-1", "Task One")); + return queryClient; +} + +function renderWithQuery(ui: ReactNode) { + return render({ui}); +} + beforeEach(() => { archiveAndSwitchMock.mockResolvedValue(undefined); mockGetSubtaskCount.mockResolvedValue({ count: 0 }); @@ -68,7 +100,7 @@ afterEach(() => { describe("PRMergedBanner", () => { it("opens the confirmation dialog instead of archiving directly", async () => { - render(); + renderWithQuery(); fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); @@ -78,7 +110,7 @@ describe("PRMergedBanner", () => { }); it("archives after confirming, without a success toast", async () => { - render(); + renderWithQuery(); fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); fireEvent.click(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM)); @@ -90,7 +122,7 @@ describe("PRMergedBanner", () => { }); it("does not archive when the dialog is cancelled", async () => { - render(); + renderWithQuery(); fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); fireEvent.click(await screen.findByText("Cancel")); @@ -102,7 +134,7 @@ describe("PRMergedBanner", () => { it("keeps the failure toast when archive fails", async () => { archiveAndSwitchMock.mockRejectedValueOnce(new Error("archive failed")); - render(); + renderWithQuery(); fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); fireEvent.click(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM)); @@ -117,7 +149,7 @@ describe("PRMergedBanner", () => { it("passes cascade=true through when the subtask checkbox is ticked", async () => { mockGetSubtaskCount.mockResolvedValue({ count: 2 }); - render(); + renderWithQuery(); fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); fireEvent.click(await screen.findByTestId("archive-cascade-checkbox")); @@ -133,7 +165,7 @@ describe("PRMergedBanner", () => { archiveAndSwitchMock.mockImplementation( () => new Promise((resolve) => (resolveArchive = resolve)), ); - render(); + renderWithQuery(); fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); fireEvent.click(await screen.findByTestId(MERGED_ARCHIVE_CONFIRM)); @@ -152,11 +184,11 @@ describe("PRMergedBanner", () => { ); }); - it("falls back to a generic title when the task is not in the store", async () => { + it("falls back to a generic title when the task is not in the Query cache", async () => { taskPRsByTaskId.value = { "task-2": [{ pr_number: 7, state: "merged" }], }; - render(); + renderWithQuery(); fireEvent.click(screen.getByTestId(MERGED_ARCHIVE_BUTTON)); @@ -170,7 +202,7 @@ describe("PRClosedBanner", () => { taskPRsByTaskId.value = { "task-1": [{ pr_number: 42, state: "closed" }], }; - render(); + renderWithQuery(); fireEvent.click(screen.getByTestId("pr-closed-archive-button")); expect(archiveAndSwitchMock).not.toHaveBeenCalled(); diff --git a/apps/web/components/task/chat/pr-archive-banners.tsx b/apps/web/components/task/chat/pr-archive-banners.tsx index a134759e8f..3e20bf0be0 100644 --- a/apps/web/components/task/chat/pr-archive-banners.tsx +++ b/apps/web/components/task/chat/pr-archive-banners.tsx @@ -3,10 +3,10 @@ import { useCallback, useState, type ReactNode } from "react"; import { IconGitMerge, IconGitPullRequestClosed, IconX } from "@tabler/icons-react"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useToast } from "@/components/toast-provider"; -import { findTaskInSnapshots } from "@/lib/kanban/find-task"; import { markPRClosedBannerDismissed, markPRMergedBannerDismissed, @@ -20,17 +20,15 @@ type ArchiveTarget = { title: string; executorType?: string | null }; // dialog as every other archive surface. Only failures toast; on success the // archive-and-switch flow moves the user to the next task. function useBannerArchiveConfirm(taskId: string) { - const store = useAppStoreApi(); + const task = useTaskById(taskId); const archiveAndSwitch = useArchiveAndSwitchTask(); const { toast } = useToast(); const [target, setTarget] = useState(null); const [isPending, setIsPending] = useState(false); const requestArchive = useCallback(() => { - const state = store.getState(); - const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); setTarget({ title: task?.title ?? "this task", executorType: task?.primaryExecutorType }); - }, [store, taskId]); + }, [task]); const closeConfirm = useCallback(() => setTarget(null), []); @@ -116,7 +114,7 @@ function ArchiveDismissBanner({ } export function PRMergedBanner({ taskId }: { taskId: string }) { - const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); + const { prs: taskPRs } = useTaskPR(taskId); const [dismissed, setDismissed] = useState(() => wasPRMergedBannerDismissed(taskId)); const handleDismiss = useCallback(() => { @@ -126,7 +124,7 @@ export function PRMergedBanner({ taskId }: { taskId: string }) { // Multi-repo: only show "ready to archive" once every PR is merged. A // single merged repo with others still open means the task isn't done yet. - const allMerged = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "merged"); + const allMerged = taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "merged"); if (!allMerged || dismissed) return null; const bannerText = @@ -149,7 +147,7 @@ export function PRMergedBanner({ taskId }: { taskId: string }) { } export function PRClosedBanner({ taskId }: { taskId: string }) { - const taskPRs = useAppStore((state) => state.taskPRs.byTaskId[taskId]); + const { prs: taskPRs } = useTaskPR(taskId); const [dismissed, setDismissed] = useState(() => wasPRClosedBannerDismissed(taskId)); const handleDismiss = useCallback(() => { @@ -159,7 +157,7 @@ export function PRClosedBanner({ taskId }: { taskId: string }) { // Mirror the merged banner's all-or-nothing rule: show only once every PR is // closed-without-merging. A mix of merged + closed shows neither banner. - const allClosed = !!taskPRs && taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "closed"); + const allClosed = taskPRs.length > 0 && taskPRs.every((pr) => pr.state === "closed"); if (!allClosed || dismissed) return null; const bannerText = diff --git a/apps/web/components/task/chat/task-mention-items.test.ts b/apps/web/components/task/chat/task-mention-items.test.ts index aec8126cb0..aadde22909 100644 --- a/apps/web/components/task/chat/task-mention-items.test.ts +++ b/apps/web/components/task/chat/task-mention-items.test.ts @@ -1,22 +1,21 @@ import { describe, it, expect } from "vitest"; import { buildTaskMentionItems } from "./task-mention-items"; -import type { AppState } from "@/lib/state/store"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; -function makeState(overrides: Partial = {}): AppState { - const base = { - kanban: { workflowId: "wf-1", steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - workflows: { items: [], activeId: null }, - tasks: { activeTaskId: null, activeSessionId: null, pinnedSessionId: null }, - } as unknown as AppState; - return { ...base, ...overrides } as AppState; +function snapshot(overrides: Partial = {}): WorkflowSnapshotData { + return { + workflowId: "wf-1", + workflowName: "Main flow", + steps: [], + tasks: [], + ...overrides, + }; } describe("buildTaskMentionItems / basics", () => { it("returns tasks from the current workflow with workflow/step names resolved", () => { - const state = makeState({ - kanban: { - workflowId: "wf-1", + const snapshots = { + "wf-1": snapshot({ steps: [{ id: "step-1", title: "Todo", color: "", position: 0 }], tasks: [ { @@ -24,17 +23,15 @@ describe("buildTaskMentionItems / basics", () => { workflowStepId: "step-1", title: "Implement auth", position: 0, - state: "in_progress", + state: "IN_PROGRESS", }, ], - }, - workflows: { - items: [{ id: "wf-1", workspaceId: "ws-1", name: "Main flow" }], - activeId: "wf-1", - }, - } as unknown as Partial); + }), + }; - const items = buildTaskMentionItems(state, null); + const items = buildTaskMentionItems(snapshots, null, [ + { id: "wf-1", workspaceId: "ws-1", name: "Main flow" }, + ]); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ kind: "task", @@ -45,90 +42,76 @@ describe("buildTaskMentionItems / basics", () => { title: "Implement auth", workflowId: "wf-1", workflowStepId: "step-1", - state: "in_progress", + state: "IN_PROGRESS", }, }); }); it("excludes the current task by id", () => { - const state = makeState({ - kanban: { - workflowId: "wf-1", - steps: [], + const snapshots = { + "wf-1": snapshot({ tasks: [ { id: "task-a", workflowStepId: "step-1", title: "A", position: 0 }, { id: "task-b", workflowStepId: "step-1", title: "B", position: 1 }, ], - }, - } as unknown as Partial); + }), + }; - const items = buildTaskMentionItems(state, "task-a"); + const items = buildTaskMentionItems(snapshots, "task-a"); expect(items.map((i) => i.task?.taskId)).toEqual(["task-b"]); }); }); describe("buildTaskMentionItems / merging and filtering", () => { - it("merges tasks from kanbanMulti snapshots and dedupes by id", () => { - const state = makeState({ - kanban: { - workflowId: "wf-1", + it("merges tasks from workflow snapshot Query caches and dedupes by id", () => { + const snapshots = { + "wf-1": snapshot({ + workflowName: "Main", steps: [], - tasks: [{ id: "task-a", workflowStepId: "step-1", title: "A", position: 0 }], - }, - kanbanMulti: { - snapshots: { - "wf-1": { - workflowId: "wf-1", - workflowName: "Main", - steps: [], - tasks: [ - { id: "task-a", workflowStepId: "step-1", title: "A (dup)", position: 0 }, - { id: "task-c", workflowStepId: "step-2", title: "C", position: 0 }, - ], - }, - "wf-2": { - workflowId: "wf-2", - workflowName: "Other", - steps: [{ id: "step-9", title: "Review", color: "", position: 0 }], - tasks: [{ id: "task-d", workflowStepId: "step-9", title: "D", position: 0 }], - }, - }, - isLoading: false, - }, - } as unknown as Partial); + tasks: [ + { id: "task-a", workflowStepId: "step-1", title: "A", position: 0 }, + { id: "task-a", workflowStepId: "step-1", title: "A (dup)", position: 1 }, + { id: "task-c", workflowStepId: "step-2", title: "C", position: 2 }, + ], + }), + "wf-2": snapshot({ + workflowId: "wf-2", + workflowName: "Other", + steps: [{ id: "step-9", title: "Review", color: "", position: 0 }], + tasks: [{ id: "task-d", workflowStepId: "step-9", title: "D", position: 0 }], + }), + }; - const ids = buildTaskMentionItems(state, null).map((i) => i.task?.taskId); + const ids = buildTaskMentionItems(snapshots, null).map((i) => i.task?.taskId); expect(ids).toEqual(["task-a", "task-c", "task-d"]); }); - it("skips stale kanban tasks whose step is not in the current workflow's steps", () => { - const state = makeState({ - kanban: { - workflowId: "wf-1", + it("skips stale snapshot tasks whose step is not in that workflow's steps", () => { + const snapshots = { + "wf-1": snapshot({ steps: [{ id: "step-current", title: "Todo", color: "", position: 0 }], tasks: [ { id: "task-fresh", workflowStepId: "step-current", title: "Fresh", position: 0 }, - // Left over from a previous workflow: its step is not in wf-1's steps, - // so tagging it with wf-1 would be wrong. + // Left over from a previous workflow: its step is not in wf-1's steps. { id: "task-stale", workflowStepId: "step-other", title: "Stale", position: 1 }, ], - }, - } as unknown as Partial); + }), + }; - const ids = buildTaskMentionItems(state, null).map((i) => i.task?.taskId); + const ids = buildTaskMentionItems(snapshots, null).map((i) => i.task?.taskId); expect(ids).toEqual(["task-fresh"]); }); it("falls back to placeholder names when workflow/step are missing", () => { - const state = makeState({ - kanban: { - workflowId: "wf-1", + const snapshots = { + "wf-1": snapshot({ + workflowName: "", steps: [], tasks: [{ id: "task-a", workflowStepId: "step-missing", title: "A", position: 0 }], - }, - } as unknown as Partial); + }), + }; - const [item] = buildTaskMentionItems(state, null); + const [item] = buildTaskMentionItems(snapshots, null); expect(item.description).toBe("Workflow · Step"); }); }); diff --git a/apps/web/components/task/chat/task-mention-items.ts b/apps/web/components/task/chat/task-mention-items.ts index 5f5b2df9e1..ea2164fafb 100644 --- a/apps/web/components/task/chat/task-mention-items.ts +++ b/apps/web/components/task/chat/task-mention-items.ts @@ -1,21 +1,24 @@ import type { MentionItem } from "@/hooks/use-inline-mention"; -import type { AppState } from "@/lib/state/store"; +import type { WorkflowItem, WorkflowSnapshotData } from "@/lib/state/slices"; -type TaskLike = AppState["kanban"]["tasks"][number]; +type TaskLike = WorkflowSnapshotData["tasks"][number]; +type WorkflowSnapshots = Record; -function buildWorkflowNameMap(state: AppState): Map { +function buildWorkflowNameMap( + snapshots: WorkflowSnapshots, + workflows: WorkflowItem[], +): Map { const m = new Map(); - for (const w of state.workflows.items) m.set(w.id, w.name); - for (const [wfId, snap] of Object.entries(state.kanbanMulti.snapshots)) { + for (const w of workflows) m.set(w.id, w.name); + for (const [wfId, snap] of Object.entries(snapshots)) { if (!m.has(wfId) && snap.workflowName) m.set(wfId, snap.workflowName); } return m; } -function buildStepTitleMap(state: AppState): Map { +function buildStepTitleMap(snapshots: WorkflowSnapshots): Map { const m = new Map(); - for (const s of state.kanban.steps) m.set(s.id, s.title); - for (const snap of Object.values(state.kanbanMulti.snapshots)) { + for (const snap of Object.values(snapshots)) { for (const s of snap.steps ?? []) m.set(s.id, s.title); } return m; @@ -44,13 +47,14 @@ function toMentionItem( } export function buildTaskMentionItems( - state: AppState, + snapshots: WorkflowSnapshots, currentTaskId: string | null, + workflows: WorkflowItem[] = [], ): MentionItem[] { const items: MentionItem[] = []; const seen = new Set(); - const workflowNameById = buildWorkflowNameMap(state); - const stepTitleById = buildStepTitleMap(state); + const workflowNameById = buildWorkflowNameMap(snapshots, workflows); + const stepTitleById = buildStepTitleMap(snapshots); const addTask = (task: TaskLike, workflowId: string) => { if (task.id === currentTaskId || seen.has(task.id)) return; @@ -60,19 +64,13 @@ export function buildTaskMentionItems( items.push(toMentionItem(task, workflowId, workflowName, stepTitle)); }; - if (state.kanban.workflowId) { - // Guard against stale tasks left over from a previous workflow: only add - // entries whose workflowStepId belongs to the current workflow's steps, - // since we tag them with state.kanban.workflowId here. - const activeStepIds = new Set(state.kanban.steps.map((s) => s.id)); - for (const t of state.kanban.tasks) { - if (activeStepIds.size > 0 && !activeStepIds.has(t.workflowStepId)) continue; - addTask(t, state.kanban.workflowId); + for (const [wfId, snap] of Object.entries(snapshots)) { + const stepIds = new Set(snap.steps.map((s) => s.id)); + for (const t of snap.tasks) { + if (stepIds.size > 0 && !stepIds.has(t.workflowStepId)) continue; + addTask(t, wfId); } } - for (const [wfId, snap] of Object.entries(state.kanbanMulti.snapshots)) { - for (const t of snap.tasks) addTask(t, wfId); - } return items; } diff --git a/apps/web/components/task/chat/tiptap-input.tsx b/apps/web/components/task/chat/tiptap-input.tsx index 79987bdfd6..954aa432f2 100644 --- a/apps/web/components/task/chat/tiptap-input.tsx +++ b/apps/web/components/task/chat/tiptap-input.tsx @@ -10,9 +10,12 @@ import { useMemo, } from "react"; import { EditorContent } from "@tiptap/react"; +import { useQuery } from "@tanstack/react-query"; import { useShallow } from "zustand/react/shallow"; import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { useAllCachedWorkflows } from "@/hooks/use-workflow-cache"; +import { useAppStore } from "@/components/state-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; import { EditorContextProvider } from "./editor-context"; @@ -22,6 +25,7 @@ import { SlashCommandMenu } from "./slash-command-menu"; import { buildTaskMentionItems } from "./task-mention-items"; import { extractUserHistory } from "./message-history"; import { useDrainOlderMessages } from "./use-drain-older-messages"; +import { availableCommandsQueryOptions } from "@/lib/query/query-options"; import { createMentionSuggestion, createSlashSuggestion, @@ -124,8 +128,12 @@ async function fetchFileResults( function useMentionItems(sessionId: string | null, taskId: string | null) { const { prompts } = useCustomPrompts(); - const storeApi = useAppStoreApi(); + const workflows = useAllCachedWorkflows(); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const { snapshots } = useAllWorkflowSnapshots(activeWorkspaceId); const promptsRef = useRef(prompts); + const workflowsRef = useRef(workflows); + const snapshotsRef = useRef(snapshots); const sessionIdRef = useRef(sessionId); const taskIdRef = useRef(taskId); const lastFileSearchRef = useRef<{ query: string; results: string[] }>({ @@ -134,51 +142,52 @@ function useMentionItems(sessionId: string | null, taskId: string | null) { }); useLayoutEffect(() => { promptsRef.current = prompts; + workflowsRef.current = workflows; + snapshotsRef.current = snapshots; sessionIdRef.current = sessionId; taskIdRef.current = taskId; }); - return useCallback( - async (query: string): Promise => { - const allItems: MentionItem[] = []; - allItems.push(...buildTaskMentionItems(storeApi.getState(), taskIdRef.current)); + return useCallback(async (query: string): Promise => { + const allItems: MentionItem[] = []; + allItems.push( + ...buildTaskMentionItems(snapshotsRef.current, taskIdRef.current, workflowsRef.current), + ); + allItems.push({ + id: "__plan__", + kind: "plan", + label: "Plan", + description: "Include the plan as context", + onSelect: () => {}, + }); + for (const p of promptsRef.current) { allItems.push({ - id: "__plan__", - kind: "plan", - label: "Plan", - description: "Include the plan as context", + id: p.id, + kind: "prompt", + label: p.name, + description: p.content.length > 100 ? p.content.slice(0, 100) + "..." : p.content, onSelect: () => {}, }); - for (const p of promptsRef.current) { - allItems.push({ - id: p.id, - kind: "prompt", - label: p.name, - description: p.content.length > 100 ? p.content.slice(0, 100) + "..." : p.content, - onSelect: () => {}, - }); - } - const sid = sessionIdRef.current; - if (sid) { - try { - const files = await fetchFileResults(sid, query, lastFileSearchRef.current); - for (const filePath of files) { - allItems.push({ - id: filePath, - kind: "file", - label: filePath, - description: "File", - onSelect: () => {}, - }); - } - } catch { - // ignore + } + const sid = sessionIdRef.current; + if (sid) { + try { + const files = await fetchFileResults(sid, query, lastFileSearchRef.current); + for (const filePath of files) { + allItems.push({ + id: filePath, + kind: "file", + label: filePath, + description: "File", + onSelect: () => {}, + }); } + } catch { + // ignore } - return filterItems(allItems, query); - }, - [storeApi], - ); + } + return filterItems(allItems, query); + }, []); } // ── Suggestion configs hook ────────────────────────────────────────── @@ -200,9 +209,8 @@ function useSuggestionConfigs({ setMentionMenu, setSlashMenu, }: SuggestionConfigsInput) { - const agentCommands = useAppStore((state) => - sessionId ? state.availableCommands.bySessionId[sessionId] : undefined, - ); + const commandsQuery = useQuery(availableCommandsQueryOptions(sessionId ?? "")); + const agentCommands = commandsQuery.data; const slashCommands = useMemo((): SlashCommand[] => { if (!agentCommands || agentCommands.length === 0) return []; return agentCommands diff --git a/apps/web/components/task/chat/use-chat-panel-state.test.tsx b/apps/web/components/task/chat/use-chat-panel-state.test.tsx new file mode 100644 index 0000000000..088b8c9df0 --- /dev/null +++ b/apps/web/components/task/chat/use-chat-panel-state.test.tsx @@ -0,0 +1,134 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { taskId, workflowId, workspaceId, type Task, type WorkflowStep } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type MockState = { + documentPanel: { activeDocumentBySessionId: Record }; + chatInput: { planModeBySessionId: Record }; + taskSessions: { items: Record | null }> }; + kanban: { tasks: KanbanState["tasks"]; steps: KanbanState["steps"] }; + setActiveDocument: ReturnType; + setPlanMode: ReturnType; +}; + +let mockState: MockState; +const applyBuiltInPreset = vi.fn(); +const addContextFile = vi.fn(); +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/lib/state/layout-store", () => ({ + useLayoutStore: () => vi.fn(), +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: () => applyBuiltInPreset, +})); + +vi.mock("@/lib/state/context-files-store", () => ({ + useContextFilesStore: (selector: (state: Record) => unknown) => + selector({ + addFile: addContextFile, + removeFile: vi.fn(), + }), +})); + +vi.mock("@/components/task/chat/use-plan-mode-helpers", () => ({ + useAutoDisablePlanMode: vi.fn(), + usePlanLayoutHandlers: () => ({ + togglePlanLayout: vi.fn(), + handlePlanModeChange: vi.fn(), + }), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), +})); + +vi.mock("@/lib/api/domains/workflow-api", () => ({ + listWorkflowSteps: vi.fn(() => new Promise(() => {})), +})); + +import { usePlanMode } from "./use-chat-panel-state"; + +function makeTask(): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-plan", + position: 1, + title: "Query task", + description: "", + state: "CREATED", + priority: 0, + repositories: [], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function makeStep(): WorkflowStep { + return { + id: "step-plan", + workflow_id: workflowId("wf-1"), + name: "Plan", + position: 1, + color: "bg-blue-500", + events: { on_enter: [{ type: "enable_plan_mode" }] }, + allow_manual_move: true, + prompt: "", + is_start_step: false, + show_in_command_panel: true, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + +describe("usePlanMode", () => { + beforeEach(() => { + applyBuiltInPreset.mockClear(); + addContextFile.mockClear(); + mockState = { + documentPanel: { activeDocumentBySessionId: {} }, + chatInput: { planModeBySessionId: {} }, + taskSessions: { items: { "session-1": { metadata: { plan_mode: true } } } }, + kanban: { tasks: [], steps: [] }, + setActiveDocument: vi.fn(), + setPlanMode: vi.fn(), + }; + }); + + it("auto-applies plan layout from task detail and workflow-step Query data", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + client.setQueryData(qk.workflows.steps("wf-1"), [makeStep()]); + + renderHook(() => usePlanMode("session-1", "task-1"), { + wrapper: wrapper(client), + }); + + await waitFor(() => expect(applyBuiltInPreset).toHaveBeenCalledWith("plan")); + expect(mockState.setActiveDocument).toHaveBeenCalledWith("session-1", { + type: "plan", + taskId: "task-1", + }); + expect(addContextFile).toHaveBeenCalledWith("session-1", { + path: "plan:context", + name: "Plan", + }); + }); +}); diff --git a/apps/web/components/task/chat/use-chat-panel-state.ts b/apps/web/components/task/chat/use-chat-panel-state.ts index 91cca123ca..168c2a014b 100644 --- a/apps/web/components/task/chat/use-chat-panel-state.ts +++ b/apps/web/components/task/chat/use-chat-panel-state.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { useLayoutStore } from "@/lib/state/layout-store"; import { useDockviewStore } from "@/lib/state/dockview-store"; @@ -12,6 +13,12 @@ import { useSessionMcp } from "@/hooks/domains/session/use-session-mcp"; import { useProcessedMessages } from "@/hooks/use-processed-messages"; import { useSessionModel } from "@/hooks/domains/session/use-session-model"; import { useQueue } from "@/hooks/domains/session/use-queue"; +import { + availableCommandsQueryOptions, + sessionTodosQueryOptions, + taskQueryOptions, + workflowStepsQueryOptions, +} from "@/lib/query/query-options"; import { useContextFilesStore, type ContextFile } from "@/lib/state/context-files-store"; import { useCommentsStore, @@ -154,14 +161,21 @@ export function usePlanMode(resolvedSessionId: string | null, taskId: string | n ? state.taskSessions.items[resolvedSessionId]?.metadata?.plan_mode === true : false, ); - const currentStepHasPlanMode = useAppStore((s) => { - if (!taskId) return false; - const task = s.kanban.tasks.find((t) => t.id === taskId); - const stepId = task?.workflowStepId; + const taskQuery = useQuery({ + ...taskQueryOptions(taskId ?? ""), + enabled: Boolean(taskId), + }); + const workflowId = taskQuery.data?.workflow_id ?? null; + const stepId = taskQuery.data?.workflow_step_id ?? null; + const workflowStepsQuery = useQuery({ + ...workflowStepsQueryOptions(workflowId ?? ""), + enabled: Boolean(workflowId), + }); + const currentStepHasPlanMode = useMemo(() => { if (!stepId) return false; - const step = s.kanban.steps.find((st) => st.id === stepId); + const step = workflowStepsQuery.data?.find((st) => st.id === stepId); return step?.events?.on_enter?.some((a) => a.type === "enable_plan_mode") ?? false; - }); + }, [stepId, workflowStepsQuery.data]); const planModeEnabled = planModeFromStore; const planLayoutVisible = activeDocument?.type === "plan"; @@ -438,9 +452,8 @@ function useSessionData( session?.agent_profile_id, ); const chatSubmitKey = useAppStore((state) => state.userSettings.chatSubmitKey); - const agentCommands = useAppStore((state) => - resolvedSessionId ? state.availableCommands.bySessionId[resolvedSessionId] : undefined, - ); + const commandsQuery = useQuery(availableCommandsQueryOptions(resolvedSessionId ?? "")); + const agentCommands = commandsQuery.data; const { clearAll: clearQueue, editEntry: editQueueEntry, @@ -468,19 +481,18 @@ function useSessionTodoItems( resolvedSessionId: string | null, messageTodos: Array<{ text: string; done?: boolean }>, ) { - const storeTodos = useAppStore((s) => - resolvedSessionId ? s.sessionTodos.bySessionId[resolvedSessionId] : undefined, - ); + const todosQuery = useQuery(sessionTodosQueryOptions(resolvedSessionId ?? "")); + const todos = todosQuery.data; return useMemo(() => { - if (storeTodos && storeTodos.length > 0) { - return storeTodos.map((e: { description: string; status: string }) => ({ + if (todos && todos.length > 0) { + return todos.map((e: { description: string; status: string }) => ({ text: e.description, done: e.status === "completed", status: e.status as TodoStatus, })); } return messageTodos; - }, [storeTodos, messageTodos]); + }, [todos, messageTodos]); } export type UseChatPanelStateOptions = { diff --git a/apps/web/components/task/dockview-header-actions.test.tsx b/apps/web/components/task/dockview-header-actions.test.tsx new file mode 100644 index 0000000000..81e0f1f7c1 --- /dev/null +++ b/apps/web/components/task/dockview-header-actions.test.tsx @@ -0,0 +1,218 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, waitFor } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { taskId, workflowId, workspaceId, type Task, type WorkflowStep } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type NewTaskDropdownProps = { + workspaceId: string | null; + workflowId: string | null; + steps: Array<{ id: string; title: string; color?: string; events?: Record }>; + activeTaskId: string | null; + activeTaskTitle: string; +}; + +type MockState = { + workspaces: { activeId: string | null }; + tasks: { activeTaskId: string | null; activeSessionId: string | null }; + taskSessions: { items: Record }; + kanban: { + workflowId: string | null; + steps: KanbanState["steps"]; + tasks: KanbanState["tasks"]; + }; + environmentIdBySessionId: Record; + setActiveTask: ReturnType; + setActiveSession: ReturnType; +}; + +const capturedDropdownProps: NewTaskDropdownProps[] = []; +let mockState: MockState; +let mockDockviewState: { + sidebarGroupId: string; + centerGroupId: string; + rightTopGroupId: string; + rightBottomGroupId: string; +}; + +function defaultMockState(): MockState { + return { + workspaces: { activeId: "ws-1" }, + tasks: { activeTaskId: "task-1", activeSessionId: null }, + taskSessions: { items: {} }, + kanban: { workflowId: null, steps: [], tasks: [] }, + environmentIdBySessionId: {}, + setActiveTask: vi.fn(), + setActiveSession: vi.fn(), + }; +} + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), + useAppStoreApi: () => ({ getState: () => mockState }), +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: (selector: (state: typeof mockDockviewState) => unknown) => + selector(mockDockviewState), + performLayoutSwitch: vi.fn(), +})); + +vi.mock("@/components/task/new-task-dropdown", () => ({ + NewTaskDropdown: (props: NewTaskDropdownProps) => { + capturedDropdownProps.push(props); + return null; + }, +})); + +vi.mock("@/components/task/new-session-dialog", () => ({ + NewSessionDialog: () => null, +})); + +vi.mock("@/components/task/dockview-add-panel-items", () => ({ + AddPanelMenuItems: () => null, + MENU_ITEM_CLASS: "", +})); + +vi.mock("@/components/task/dockview-group-actions", () => ({ + GroupSplitCloseActionsView: () => null, + useDockviewGroupWidth: () => 320, +})); + +vi.mock("@/components/task/repository-scripts-menu", () => ({ + useActiveSessionDevScript: () => null, +})); + +vi.mock("@/hooks/domains/session/use-user-shells", () => ({ + useUserShells: vi.fn(), +})); + +vi.mock("@/hooks/domains/session/use-ensure-default-terminal-ordinary", () => ({ + useEnsureDefaultTerminalOrdinary: vi.fn(), +})); + +vi.mock("@/hooks/use-environment-session-id", () => ({ + useEnvironmentId: () => null, +})); + +vi.mock("@/hooks/domains/github/use-task-pr", () => ({ + useTaskPR: () => ({ prs: [] }), +})); + +vi.mock("@/hooks/domains/workspace/use-repository-cache", () => ({ + useAllCachedRepositories: () => [], +})); + +vi.mock("@/hooks/domains/workspace/use-repository-scripts", () => ({ + useRepositoryScripts: () => ({ scripts: [] }), +})); + +vi.mock("@/lib/api", () => ({ + startProcess: vi.fn(), +})); + +vi.mock("@/lib/api/domains/user-shell-api", () => ({ + createUserShell: vi.fn(), +})); + +vi.mock("@/lib/links", () => ({ + replaceTaskUrl: vi.fn(), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), +})); + +vi.mock("@/lib/api/domains/workflow-api", () => ({ + listWorkflowSteps: vi.fn(), +})); + +import { RightHeaderActions } from "./dockview-header-actions"; + +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +function makeTask(overrides: Partial = {}): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-query"), + workflow_step_id: "step-query", + position: 1, + title: "Query task title", + description: "", + state: "CREATED", + priority: 0, + repositories: [], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + ...overrides, + }; +} + +function makeStep(overrides: Partial = {}): WorkflowStep { + return { + id: "step-query", + workflow_id: workflowId("wf-query"), + name: "Query step", + position: 1, + color: "bg-green-500", + events: { on_enter: [{ type: "auto_start_agent" }] }, + allow_manual_move: true, + prompt: "", + is_start_step: true, + show_in_command_panel: true, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + ...overrides, + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("RightHeaderActions", () => { + beforeEach(() => { + capturedDropdownProps.length = 0; + mockState = defaultMockState(); + mockDockviewState = { + sidebarGroupId: "sidebar-group", + centerGroupId: "center-group", + rightTopGroupId: "right-top-group", + rightBottomGroupId: "right-bottom-group", + }; + }); + + it("passes task-create context from Query caches when legacy kanban mirrors are empty", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + client.setQueryData(qk.workflows.steps("wf-query"), [makeStep()]); + + const props = { + group: { id: "sidebar-group", panels: [] }, + containerApi: {}, + } as unknown as Parameters[0]; + + render(, { wrapper: wrapper(client) }); + + await waitFor(() => expect(capturedDropdownProps).toHaveLength(1)); + expect(capturedDropdownProps[0]).toMatchObject({ + workspaceId: "ws-1", + workflowId: "wf-query", + activeTaskId: "task-1", + activeTaskTitle: "Query task title", + steps: [ + { + id: "step-query", + title: "Query step", + color: "bg-green-500", + events: { on_enter: [{ type: "auto_start_agent" }] }, + }, + ], + }); + }); +}); diff --git a/apps/web/components/task/dockview-header-actions.tsx b/apps/web/components/task/dockview-header-actions.tsx index 2764b0c27f..22c9bdb527 100644 --- a/apps/web/components/task/dockview-header-actions.tsx +++ b/apps/web/components/task/dockview-header-actions.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { type IDockviewHeaderActionsProps } from "dockview-react"; import { IconPlus, @@ -19,14 +20,17 @@ import { } from "@kandev/ui/dropdown-menu"; import { useDockviewStore, performLayoutSwitch } from "@/lib/state/dockview-store"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { useEnvironmentId } from "@/hooks/use-environment-session-id"; import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; +import { isPassthroughSession } from "@/lib/session/is-passthrough-session"; import { startProcess } from "@/lib/api"; import { createUserShell } from "@/lib/api/domains/user-shell-api"; import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-scripts"; import { replaceTaskUrl } from "@/lib/links"; import type { Task, ProcessInfo } from "@/lib/types/http"; import { sessionId as toSessionId } from "@/lib/types/ids"; +import { taskQueryOptions, workflowStepsQueryOptions } from "@/lib/query/query-options"; import type { ProcessStatusEntry } from "@/lib/state/slices"; import { AddPanelMenuItems, MENU_ITEM_CLASS } from "./dockview-add-panel-items"; import { useUserShells } from "@/hooks/domains/session/use-user-shells"; @@ -68,7 +72,7 @@ function useLeftHeaderState( const taskId = useAppStore((state) => state.tasks.activeTaskId); const isPassthrough = useAppStore((state) => { if (!activeSessionId) return false; - return state.taskSessions.items[activeSessionId]?.is_passthrough === true; + return isPassthroughSession(state.taskSessions.items[activeSessionId]); }); const { prs } = useTaskPR(taskId); const hasChanges = Boolean( @@ -293,34 +297,28 @@ export function RightHeaderActions(props: IDockviewHeaderActionsProps) { function SidebarRightActions() { const workspaceId = useAppStore((state) => state.workspaces.activeId); - const kanban = useAppStore((state) => state.kanban); - // Use kanban.workflowId (task context) not workflows.activeId so "All Workflows" isn't clobbered when viewing a task. - const workflowId = kanban.workflowId; const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); - const activeTaskTitle = useAppStore((state) => { - const id = state.tasks.activeTaskId; - if (!id) return ""; - return state.kanban.tasks.find((t: { id: string }) => t.id === id)?.title ?? ""; + const taskQuery = useQuery({ + ...taskQueryOptions(activeTaskId ?? ""), + enabled: Boolean(activeTaskId), }); + // Use the task detail workflow (task context), not workflows.activeId, so + // "All Workflows" board selection isn't clobbered when viewing a task. + const workflowId = taskQuery.data?.workflow_id ?? null; + const stepsQuery = useQuery({ + ...workflowStepsQueryOptions(workflowId ?? ""), + enabled: Boolean(workflowId), + }); + const activeTaskTitle = taskQuery.data?.title ?? ""; const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); const appStore = useAppStoreApi(); - const steps = (kanban?.steps ?? []).map( - (s: { - id: string; - title: string; - color?: string; - events?: { - on_enter?: Array<{ type: string; config?: Record }>; - on_turn_complete?: Array<{ type: string; config?: Record }>; - }; - }) => ({ - id: s.id, - title: s.title, - color: s.color, - events: s.events, - }), - ); + const steps = (stepsQuery.data ?? []).map((s) => ({ + id: s.id, + title: s.name, + color: s.color, + events: s.events, + })); const handleTaskCreated = useCallback( (task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => { @@ -379,15 +377,14 @@ function RightTopGroupActions() { function CenterRightActions() { const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); - const repository = useAppStore((state) => { - if (!activeSessionId) return null; - const session = state.taskSessions.items[activeSessionId]; - if (!session) return null; - const repoId = session.repository_id; - if (!repoId) return null; - const allRepos = Object.values(state.repositories.itemsByWorkspaceId).flat(); - return allRepos.find((r) => r.id === repoId) ?? null; - }); + const repositoryId = useAppStore((state) => + activeSessionId ? (state.taskSessions.items[activeSessionId]?.repository_id ?? null) : null, + ); + const repositories = useAllCachedRepositories(); + const repository = useMemo( + () => repositories.find((repo) => repo.id === repositoryId) ?? null, + [repositories, repositoryId], + ); const hasDevScript = Boolean(repository?.dev_script?.trim()); const addBrowserPanel = useDockviewStore((s) => s.addBrowserPanel); diff --git a/apps/web/components/task/dockview-panel-content.tsx b/apps/web/components/task/dockview-panel-content.tsx index 4edd0fb161..1d1b7e8117 100644 --- a/apps/web/components/task/dockview-panel-content.tsx +++ b/apps/web/components/task/dockview-panel-content.tsx @@ -1,17 +1,19 @@ "use client"; -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { PRDetailPanelComponent } from "@/components/github/pr-detail-panel"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAppStore } from "@/components/state-provider"; import { useActiveTaskHasRepos } from "@/hooks/domains/kanban/use-active-task-has-repos"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; import { useSessionChangesCount } from "@/hooks/domains/session/use-session-changes-count"; import type { ReviewSource } from "@/hooks/domains/session/use-review-sources"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; import { useFileEditors } from "@/hooks/use-file-editors"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; import { setPanelTitle } from "@/lib/layout/panel-portal-manager"; +import { isPassthroughSession } from "@/lib/session/is-passthrough-session"; import { useDockviewStore } from "@/lib/state/dockview-store"; -import type { AppState } from "@/lib/state/store"; import { BrowserPanel } from "./browser-panel"; import type { OpenDiffOptions } from "./changes-diff-target"; import { ChangesPanel } from "./changes-panel"; @@ -35,17 +37,19 @@ export function resolveChatPanelTitle(agentLabel: string | null | undefined): st } function useChatSessionTitle(panelId: string, sessionId: string | null) { - const agentLabel = useAppStore((state) => { + const agentProfileId = useAppStore((state) => { if (!sessionId) return null; const session = state.taskSessions.items[sessionId]; - if (!session?.agent_profile_id) return null; - const profile = state.agentProfiles.items.find( - (p: { id: string }) => p.id === session.agent_profile_id, - ); + return session?.agent_profile_id ?? null; + }); + const { agentProfiles } = useSettingsData(Boolean(agentProfileId)); + const agentLabel = useMemo(() => { + if (!agentProfileId) return null; + const profile = agentProfiles.find((p) => p.id === agentProfileId); if (!profile) return null; const parts = profile.label.split(" \u2022 "); return parts[1] || parts[0] || profile.label; - }); + }, [agentProfileId, agentProfiles]); useEffect(() => { setPanelTitle(panelId, resolveChatPanelTitle(agentLabel)); }, [panelId, agentLabel]); @@ -58,7 +62,7 @@ function ChatContent({ panelId, params }: { panelId: string; params: Record state.tasks.activeTaskId); const { openFile } = useFileEditors(); const isPassthrough = useAppStore((state) => - sessionId ? state.taskSessions.items[sessionId]?.is_passthrough === true : false, + sessionId ? isPassthroughSession(state.taskSessions.items[sessionId]) : false, ); useChatSessionTitle(panelId, sessionId); @@ -118,27 +122,19 @@ function DiffViewerContent({ ); } -function describeTaskRepositoriesForDebug(state: AppState, taskId: string | null) { +function describeTaskRepositoriesForDebug( + task: { repositoryId?: string; repositories?: Array<{ repository_id: string }> } | null, + taskId: string | null, +) { if (!taskId) return { source: "none", repositoryId: "-", repoCount: -1, repoIds: "-" }; - const task = state.kanban.tasks.find((item) => item.id === taskId); if (task) { return { - source: "kanban", + source: "taskQuery", repositoryId: task.repositoryId ?? "-", repoCount: task.repositories?.length ?? -1, repoIds: task.repositories?.map((repo) => repo.repository_id).join(",") || "-", }; } - for (const [workflowId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { - const snapshotTask = snapshot.tasks.find((item) => item.id === taskId); - if (!snapshotTask) continue; - return { - source: `kanbanMulti:${workflowId}`, - repositoryId: snapshotTask.repositoryId ?? "-", - repoCount: snapshotTask.repositories?.length ?? -1, - repoIds: snapshotTask.repositories?.map((repo) => repo.repository_id).join(",") || "-", - }; - } return { source: "missing", repositoryId: "-", repoCount: -1, repoIds: "-" }; } @@ -147,12 +143,14 @@ function ChangesContent({ panelId }: { panelId: string }) { const addFileDiffPanel = useDockviewStore((s) => s.addFileDiffPanel); const addCommitDetailPanel = useDockviewStore((s) => s.addCommitDetailPanel); const { openFile } = useFileEditors(); - const appStore = useAppStoreApi(); // Dynamic title with file count - use environment-stable sessionId so the // tab title doesn't re-fetch on same-environment session tab switches. const activeSessionId = useEnvironmentSessionId(); const totalCount = useSessionChangesCount(activeSessionId); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeUiSessionId = useAppStore((state) => state.tasks.activeSessionId); + const activeTask = useTaskById(activeTaskId); // Repo-less tasks have no git changes ever - auto-close the panel so users // don't see a permanently empty Changes tab. Gate on a confirmed `false`: @@ -163,9 +161,7 @@ function ChangesContent({ panelId }: { panelId: string }) { const dockApi = useDockviewStore.getState().api; const panel = dockApi?.getPanel(panelId); if (isDebug()) { - const state = appStore.getState(); - const activeTaskId = state.tasks.activeTaskId; - const repoDebug = describeTaskRepositoriesForDebug(state, activeTaskId); + const repoDebug = describeTaskRepositoriesForDebug(activeTask, activeTaskId); let action = "keep"; if (taskHasRepos === false) { action = panel ? "remove" : "remove-missing-panel"; @@ -175,7 +171,7 @@ function ChangesContent({ panelId }: { panelId: string }) { taskHasRepos: taskHasRepos === null ? "unknown" : String(taskHasRepos), action, activeTaskId: activeTaskId ?? "-", - sessionId: state.tasks.activeSessionId ?? "-", + sessionId: activeUiSessionId ?? "-", taskSource: repoDebug.source, repositoryId: repoDebug.repositoryId, repoCount: repoDebug.repoCount, @@ -185,7 +181,7 @@ function ChangesContent({ panelId }: { panelId: string }) { } if (taskHasRepos !== false) return; if (dockApi && panel) dockApi.removePanel(panel); - }, [taskHasRepos, panelId, appStore]); + }, [activeTask, activeTaskId, activeUiSessionId, taskHasRepos, panelId]); useEffect(() => { const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes"; diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 461cee286d..d118f24c9f 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -10,6 +10,7 @@ import { RIGHT_TOP_GROUP, } from "@/lib/state/layout-manager"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { wasPRPanelOffered, markPRPanelOffered } from "@/lib/local-storage"; import { sessionId as toSessionId } from "@/lib/types/ids"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; @@ -290,16 +291,10 @@ export function runAutoPRPanelEffect( export function useAutoPRPanel() { const taskId = useAppStore((s) => s.tasks.activeTaskId); const sessionId = useAppStore((s) => s.tasks.activeSessionId); - const hasPR = useAppStore((s) => { - const tid = s.tasks.activeTaskId; - return resolveAutoPRPanelState(tid ? s.taskPRs.byTaskId[tid] : undefined).hasPR; - }); + const { prs } = useTaskPR(taskId); + const { hasPR, defaultPRKey } = resolveAutoPRPanelState(prs); // Key of the PR the legacy unkeyed "pr-detail" panel renders — mirrors // PRDetailPanelComponent's fallback of the primary/first TaskPR. - const defaultPRKey = useAppStore((s) => { - const tid = s.tasks.activeTaskId; - return resolveAutoPRPanelState(tid ? s.taskPRs.byTaskId[tid] : undefined).defaultPRKey; - }); const hasApi = useDockviewStore((s) => !!s.api); const appStore = useAppStoreApi(); @@ -311,7 +306,7 @@ export function useAutoPRPanel() { const api = useDockviewStore.getState().api; if (!api) return; - // Re-read live task/session/PR state before mutating dockview — a + // Re-read live task/session state before mutating dockview — a // task or session switch during this two-frame delay must not stamp // the panel with a stale task's PR key (cubic-dev-ai review on PR // #1636). If the active task/session moved on, bail: the effect @@ -319,11 +314,10 @@ export function useAutoPRPanel() { // changed) will handle it correctly. const liveTasks = appStore.getState().tasks; if (liveTasks.activeTaskId !== taskId || liveTasks.activeSessionId !== sessionId) return; - const live = resolveAutoPRPanelState(appStore.getState().taskPRs.byTaskId[taskId]); runAutoPRPanelEffect(api, sessionId, { - hasPR: live.hasPR, - defaultPRKey: live.defaultPRKey, + hasPR, + defaultPRKey, isRestoringLayout: useDockviewStore.getState().isRestoringLayout, isMaximized: useDockviewStore.getState().preMaximizeLayout !== null, centerGroupId: useDockviewStore.getState().centerGroupId, diff --git a/apps/web/components/task/dockview-shared.tsx b/apps/web/components/task/dockview-shared.tsx index 311ff83172..1be96763f8 100644 --- a/apps/web/components/task/dockview-shared.tsx +++ b/apps/web/components/task/dockview-shared.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import { DockviewDefaultTab, type IDockviewPanelProps, @@ -11,7 +11,9 @@ import { useAppStore } from "@/components/state-provider"; import { useFileEditors } from "@/hooks/use-file-editors"; import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; import { useSessionCommits } from "@/hooks/domains/session/use-session-commits"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; +import { isPassthroughSession } from "@/lib/session/is-passthrough-session"; // Panel components (rendered via portals, not directly by dockview) import { TaskChatPanel } from "./task-chat-panel"; @@ -147,17 +149,19 @@ export { ContextMenuTab }; // in the PanelPortalHost and survive dockview layout switches. function useChatSessionTitle(panelId: string, sessionId: string | null, isSessionTab: boolean) { - const agentLabel = useAppStore((state) => { + const agentProfileId = useAppStore((state) => { if (!sessionId) return null; const session = state.taskSessions.items[sessionId]; - if (!session?.agent_profile_id) return null; - const profile = state.agentProfiles.items.find( - (p: { id: string }) => p.id === session.agent_profile_id, - ); + return session?.agent_profile_id ?? null; + }); + const { agentProfiles } = useSettingsData(Boolean(agentProfileId)); + const agentLabel = useMemo(() => { + if (!agentProfileId) return null; + const profile = agentProfiles.find((p) => p.id === agentProfileId); if (!profile) return null; const parts = profile.label.split(" \u2022 "); return parts[1] || parts[0] || profile.label; - }); + }, [agentProfileId, agentProfiles]); useEffect(() => { let label = "Agent"; if (isSessionTab && agentLabel) { @@ -179,7 +183,7 @@ function ChatContent({ panelId, params }: { panelId: string; params: Record - sessionId ? state.taskSessions.items[sessionId]?.is_passthrough === true : false, + sessionId ? isPassthroughSession(state.taskSessions.items[sessionId]) : false, ); useChatSessionTitle(panelId, sessionId, !!paramSessionId); diff --git a/apps/web/components/task/dockview-watermark.tsx b/apps/web/components/task/dockview-watermark.tsx index f77fe5f19b..26a085ca92 100644 --- a/apps/web/components/task/dockview-watermark.tsx +++ b/apps/web/components/task/dockview-watermark.tsx @@ -11,6 +11,7 @@ import { useEnvironmentId } from "@/hooks/use-environment-session-id"; import { useUserShells } from "@/hooks/domains/session/use-user-shells"; import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { createUserShell } from "@/lib/api/domains/user-shell-api"; +import { isPassthroughSession } from "@/lib/session/is-passthrough-session"; import { AddPanelMenuItems } from "./dockview-add-panel-items"; import { NewSessionDialog } from "./new-session-dialog"; import { useActiveSessionDevScript } from "./repository-scripts-menu"; @@ -89,7 +90,7 @@ function useWatermarkMenuState( const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const isPassthrough = useAppStore((s) => { if (!activeSessionId) return false; - return s.taskSessions.items[activeSessionId]?.is_passthrough === true; + return isPassthroughSession(s.taskSessions.items[activeSessionId]); }); const { prs } = useTaskPR(taskID); return useMemo( diff --git a/apps/web/components/task/executor-settings-button.test.tsx b/apps/web/components/task/executor-settings-button.test.tsx index 692305c198..b03e3ac49a 100644 --- a/apps/web/components/task/executor-settings-button.test.tsx +++ b/apps/web/components/task/executor-settings-button.test.tsx @@ -1,5 +1,6 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { SessionPrepareState } from "@/lib/state/slices/session-runtime/types"; // Constants declared before mocks so the mocked factories can reference them without @@ -27,7 +28,12 @@ let mockSessionState: string | null = null; let mockEnv: MockEnv | null = null; const renderButton = () => { - render(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); }; const hoverSettingsButton = () => diff --git a/apps/web/components/task/file-browser-apply-changes.test.ts b/apps/web/components/task/file-browser-apply-changes.test.ts index 9563143934..1fefe6a3f8 100644 --- a/apps/web/components/task/file-browser-apply-changes.test.ts +++ b/apps/web/components/task/file-browser-apply-changes.test.ts @@ -20,6 +20,7 @@ const SESSION_ID = "sess"; const REFRESH_OP = "refresh"; const THM_OLD = "thm/old.txt"; const THM_NEW = "thm/new.txt"; +const KANDEV_README = "kandev/README.md"; beforeEach(async () => { vi.resetModules(); @@ -157,7 +158,7 @@ describe("applyFileChanges — cross-repo subtree preservation", () => { path: "kandev", is_dir: true, size: 0, - children: [{ name: "README.md", path: "kandev/README.md", is_dir: false, size: 0 }], + children: [{ name: "README.md", path: KANDEV_README, is_dir: false, size: 0 }], }, { name: "thm", @@ -182,12 +183,72 @@ describe("applyFileChanges — cross-repo subtree preservation", () => { const next = reducer(prevTree); const kandevNode = next.children?.find((c) => c.path === "kandev"); // kandev's existing child must survive a refresh scoped to a different repo. - expect(kandevNode?.children?.map((c) => c.path)).toEqual(["kandev/README.md"]); + expect(kandevNode?.children?.map((c) => c.path)).toEqual([KANDEV_README]); const thmNode = next.children?.find((c) => c.path === "thm"); expect(thmNode?.children?.map((c) => c.path).sort()).toEqual([THM_NEW, THM_OLD]); }); }); +// Regression (#982): empty directory children from depth=1 root refreshes are +// placeholders and must not wipe the already-loaded subtree. +describe("applyFileChanges — root refresh placeholders", () => { + it("treats empty root-refresh directory children as placeholders", async () => { + requestFileTreeMock.mockImplementation((_c: unknown, _s: string, folder: string) => { + if (folder === "thm") { + return Promise.resolve({ root: thmChildrenAfter() }); + } + return Promise.resolve({ + root: { + name: "", + path: "", + is_dir: true, + size: 0, + children: [ + { name: "kandev", path: "kandev", is_dir: true, size: 0, children: [] }, + { name: "thm", path: "thm", is_dir: true, size: 0 }, + ], + }, + }); + }); + const prevTree: FileTreeNode = { + name: "", + path: "", + is_dir: true, + size: 0, + children: [ + { + name: "kandev", + path: "kandev", + is_dir: true, + size: 0, + children: [{ name: "README.md", path: KANDEV_README, is_dir: false, size: 0 }], + }, + { + name: "thm", + path: "thm", + is_dir: true, + size: 0, + children: [{ name: "old.txt", path: THM_OLD, is_dir: false, size: 0 }], + }, + ], + }; + const setTree = vi.fn(); + applyFileChanges({ + client: client(), + sessionId: SESSION_ID, + expandedPaths: new Set(["thm"]), + changes: [{ path: "", operation: REFRESH_OP, repository_name: "thm" }], + setTree, + setLoadState: vi.fn(), + }); + await new Promise((r) => setTimeout(r, 0)); + const reducer = setTree.mock.calls[0][0] as (prev: FileTreeNode) => FileTreeNode; + const next = reducer(prevTree); + const kandevNode = next.children?.find((c) => c.path === "kandev"); + expect(kandevNode?.children?.map((c) => c.path)).toEqual([KANDEV_README]); + }); +}); + function thmChildrenAfter(): FileTreeNode { return { name: "thm", diff --git a/apps/web/components/task/file-browser-parts.tsx b/apps/web/components/task/file-browser-parts.tsx index 3d6990e23d..c5648e4f9b 100644 --- a/apps/web/components/task/file-browser-parts.tsx +++ b/apps/web/components/task/file-browser-parts.tsx @@ -170,6 +170,7 @@ export function TreeNodeItem(props: TreeNodeRowProps) { data-testid="file-tree-node" data-path={node.path} data-is-dir={node.is_dir ? "true" : "false"} + data-expanded={node.is_dir ? String(isExpanded) : undefined} data-selected={isSelected ? "true" : "false"} aria-selected={isSelected} role="treeitem" @@ -241,7 +242,7 @@ export function SearchResultsList({ } return ( -
+
{searchResults.map((path) => { const name = path.split("/").pop() || path; const folder = path.includes("/") ? path.substring(0, path.lastIndexOf("/")) : ""; @@ -348,7 +349,7 @@ function FileTreeView(props: FileBrowserContentAreaProps) { const { tree, visibleRows, creatingInPath, onCreateFileSubmit, onCancelCreate } = props; if (!tree) return null; return ( -
+
{creatingInPath === "" && ( state.sessionWorktreesBySessionId.itemsBySessionId[sessionId]?.length ?? 0, - ); + const worktreeCount = useSessionWorktrees(sessionId).length; return environmentId ? `${environmentId}:${worktreeCount}` : undefined; } diff --git a/apps/web/components/task/file-tree-utils.ts b/apps/web/components/task/file-tree-utils.ts index 467d8f2194..e330077cc3 100644 --- a/apps/web/components/task/file-tree-utils.ts +++ b/apps/web/components/task/file-tree-utils.ts @@ -23,6 +23,9 @@ export function sortRootChildren(tree: FileTreeNode | null): FileTreeNode[] { */ export function mergeTreeNodes(existing: FileTreeNode, incoming: FileTreeNode): FileTreeNode { if (!incoming.children) return { ...existing, ...incoming, children: existing.children }; + if (incoming.is_dir && incoming.children.length === 0 && existing.children) { + return { ...existing, ...incoming, children: existing.children }; + } if (!existing.children) return incoming; const existingByPath = new Map(existing.children.map((c) => [c.path, c])); const mergedChildren = incoming.children.map((inChild) => { diff --git a/apps/web/components/task/handoff-profile-menu-items.test.ts b/apps/web/components/task/handoff-profile-menu-items.test.ts index c747a5ebcb..703976b4d6 100644 --- a/apps/web/components/task/handoff-profile-menu-items.test.ts +++ b/apps/web/components/task/handoff-profile-menu-items.test.ts @@ -1,5 +1,9 @@ +import { createElement, type ReactNode } from "react"; import { renderHook } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { ExecutorProfile } from "@/lib/types/http"; @@ -27,13 +31,6 @@ const mockUseTaskExecutorProfile = vi.fn( (_taskId: string, _enabled?: boolean) => mockExecutorProfile, ); -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (state: Record) => unknown) => - selector({ - agentProfiles: { items: mockProfiles }, - }), -})); - vi.mock("@/hooks/domains/session/use-task-executor-profile", () => ({ useTaskExecutorProfile: (taskId: string, enabled?: boolean) => mockUseTaskExecutorProfile(taskId, enabled), @@ -53,6 +50,33 @@ vi.mock("@/lib/agent-executor-compat", () => ({ import { useHandoffProfiles } from "./handoff-profile-menu-items"; +function wrapperForProfiles() { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.settings.executors(), { executors: [] }); + queryClient.setQueryData(qk.settings.availableAgents(), { agents: [], tools: [], total: 0 }); + queryClient.setQueryData(qk.settings.agents(), { + agents: + mockProfiles.length === 0 + ? [] + : [ + { + id: "agent-1", + name: "mock", + profiles: mockProfiles.map((profile) => ({ + id: profile.id, + agentId: profile.agent_id, + agentDisplayName: "Mock Agent", + name: profile.label.split(" \u2022 ")[1] ?? profile.label, + })), + }, + ], + total: mockProfiles.length > 0 ? 1 : 0, + }); + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + describe("useHandoffProfiles", () => { afterEach(() => { mockProfiles = [PROFILE_A, PROFILE_B]; @@ -63,7 +87,9 @@ describe("useHandoffProfiles", () => { }); it("returns all agent profiles with display labels", () => { - const { result } = renderHook(() => useHandoffProfiles("task-1")); + const { result } = renderHook(() => useHandoffProfiles("task-1"), { + wrapper: wrapperForProfiles(), + }); expect(result.current).toHaveLength(2); expect(result.current[0]).toMatchObject({ id: "profile-a", @@ -87,19 +113,23 @@ describe("useHandoffProfiles", () => { created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }; - const { result } = renderHook(() => useHandoffProfiles("task-1")); + const { result } = renderHook(() => useHandoffProfiles("task-1"), { + wrapper: wrapperForProfiles(), + }); expect(result.current.find((p) => p.id === "profile-a")?.disabled).toBe(false); expect(result.current.find((p) => p.id === "profile-b")?.disabled).toBe(true); }); it("returns empty list when no profiles configured", () => { mockProfiles = []; - const { result } = renderHook(() => useHandoffProfiles("task-1")); + const { result } = renderHook(() => useHandoffProfiles("task-1"), { + wrapper: wrapperForProfiles(), + }); expect(result.current).toEqual([]); }); it("passes the enabled flag to executor profile lookup", () => { - renderHook(() => useHandoffProfiles("task-1", false)); + renderHook(() => useHandoffProfiles("task-1", false), { wrapper: wrapperForProfiles() }); expect(mockUseTaskExecutorProfile).toHaveBeenCalledWith("task-1", false); }); }); diff --git a/apps/web/components/task/handoff-profile-menu-items.tsx b/apps/web/components/task/handoff-profile-menu-items.tsx index 91861f8b96..c6e015f472 100644 --- a/apps/web/components/task/handoff-profile-menu-items.tsx +++ b/apps/web/components/task/handoff-profile-menu-items.tsx @@ -14,8 +14,8 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, } from "@kandev/ui/dropdown-menu"; -import { useAppStore } from "@/components/state-provider"; import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useTaskExecutorProfile } from "@/hooks/domains/session/use-task-executor-profile"; import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat"; import type { AgentProfileOption } from "@/lib/state/slices"; @@ -38,7 +38,7 @@ function profileDisplayLabel(profile: AgentProfileOption): { label: string; agen } export function useHandoffProfiles(taskId: string, enabled = true): HandoffProfile[] { - const agentProfiles = useAppStore((s) => s.agentProfiles.items); + const { agentProfiles } = useSettingsData(enabled); const executorProfile = useTaskExecutorProfile(taskId, enabled); const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs(); diff --git a/apps/web/components/task/mobile/mobile-repo-pill.tsx b/apps/web/components/task/mobile/mobile-repo-pill.tsx index e20552fc79..7c2b8e1850 100644 --- a/apps/web/components/task/mobile/mobile-repo-pill.tsx +++ b/apps/web/components/task/mobile/mobile-repo-pill.tsx @@ -3,6 +3,8 @@ import { memo, useEffect, useMemo, useState } from "react"; import { IconFolder } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; +import { useTaskRepositories } from "@/hooks/domains/kanban/use-task-repositories"; +import { useCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { MobilePillButton } from "./mobile-pill-button"; import { MobilePickerSheet } from "./mobile-picker-sheet"; import { MobileReposSection, useTaskRepoCount } from "./mobile-repos-section"; @@ -23,24 +25,18 @@ function useIsCompactViewport(): boolean { } function useTaskActiveRepoName(taskId: string | null, workspaceId: string | null): string | null { - const workspaceRepos = useAppStore((s) => - workspaceId ? (s.repositories.itemsByWorkspaceId[workspaceId] ?? []) : [], - ); + const workspaceRepos = useCachedRepositories(workspaceId); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const activeRepoId = useAppStore((s) => activeSessionId ? (s.taskSessions.items[activeSessionId]?.repository_id ?? null) : null, ); - const taskRepos = useAppStore((s) => { - if (!taskId) return undefined; - const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return task?.repositories; - }); + const taskRepos = useTaskRepositories(taskId); return useMemo(() => { if (!activeRepoId) { // Fallback to the position-primary task repo when no session is active // yet. Match the picker's ordering (mobile-repos-section sorts by // position) so the pill label and the first sheet row agree. - const sorted = taskRepos ? [...taskRepos].sort((a, b) => a.position - b.position) : []; + const sorted = [...taskRepos].sort((a, b) => a.position - b.position); const fallback = sorted[0]?.repository_id; if (!fallback) return null; const repo = workspaceRepos.find((r) => r.id === fallback); diff --git a/apps/web/components/task/mobile/mobile-repos-section.test.tsx b/apps/web/components/task/mobile/mobile-repos-section.test.tsx new file mode 100644 index 0000000000..a054a7ec41 --- /dev/null +++ b/apps/web/components/task/mobile/mobile-repos-section.test.tsx @@ -0,0 +1,98 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import { repositoryId, taskId, workflowId, workspaceId } from "@/lib/types/ids"; +import type { Task } from "@/lib/types/http"; +import { useTaskRepoCount } from "./mobile-repos-section"; + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(), +})); + +const WORKSPACE_ID = workspaceId("workspace-1"); +const WORKFLOW_ID = workflowId("workflow-1"); +const TASK_ID = taskId("task-1"); +const CREATED_AT = "2026-06-24T00:00:00Z"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + const initialState = { + kanban: { + workflowId: null, + steps: [], + tasks: [], + isLoading: false, + }, + kanbanMulti: { snapshots: {} }, + } as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function task(): Task { + return { + id: TASK_ID, + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: "step-1", + position: 0, + title: "Multi repo task", + description: "", + state: "TODO", + priority: 0, + repositories: [ + { + id: "task-repo-front", + task_id: TASK_ID, + repository_id: repositoryId("repo-front"), + base_branch: "main", + position: 0, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + { + id: "task-repo-back", + task_id: TASK_ID, + repository_id: repositoryId("repo-back"), + base_branch: "release", + position: 1, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + ], + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +describe("useTaskRepoCount", () => { + afterEach(() => { + cleanup(); + }); + + it("uses task detail Query data when legacy kanban mirrors are empty", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.tasks.detail(TASK_ID), task()); + + const { result } = renderHook(() => useTaskRepoCount(TASK_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current).toBe(2); + }); +}); diff --git a/apps/web/components/task/mobile/mobile-repos-section.tsx b/apps/web/components/task/mobile/mobile-repos-section.tsx index ac00a5af94..60aec7611c 100644 --- a/apps/web/components/task/mobile/mobile-repos-section.tsx +++ b/apps/web/components/task/mobile/mobile-repos-section.tsx @@ -4,6 +4,8 @@ import { memo, useCallback, useMemo } from "react"; import { IconCheck, IconFolder, IconGitBranch } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { useCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import type { Repository, TaskSession } from "@/lib/types/http"; import type { KanbanState } from "@/lib/state/slices"; @@ -57,22 +59,13 @@ function buildRepoRows( } function useTaskRepoRows(taskId: string | null, workspaceId: string | null): RepoRow[] { - const taskRepositories = useAppStore((s) => { - if (!taskId) return undefined; - const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return task?.repositories; - }); - const workspaceRepos = useAppStore((s) => - workspaceId ? (s.repositories.itemsByWorkspaceId[workspaceId] ?? []) : [], - ); + const task = useTaskById(taskId); + const taskRepositories = task?.repositories; + const workspaceRepos = useCachedRepositories(workspaceId); const taskSessions = useAppStore((s) => taskId ? (s.taskSessionsByTask.itemsByTaskId[taskId] ?? []) : [], ); - const primarySessionId = useAppStore((s) => { - if (!taskId) return null; - const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return task?.primarySessionId ?? null; - }); + const primarySessionId = task?.primarySessionId ?? null; return useMemo( () => taskRepositories @@ -192,11 +185,6 @@ export const MobileReposSection = memo(function MobileReposSection({ /** Returns the count of repositories attached to the active task. */ export function useTaskRepoCount(taskId: string | null): number { - return ( - useAppStore((s) => { - if (!taskId) return 0; - const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return task?.repositories?.length ?? 0; - }) ?? 0 - ); + const task = useTaskById(taskId); + return task?.repositories?.length ?? 0; } diff --git a/apps/web/components/task/mobile/mobile-sessions-section.tsx b/apps/web/components/task/mobile/mobile-sessions-section.tsx index 232547a634..c79ca3eec4 100644 --- a/apps/web/components/task/mobile/mobile-sessions-section.tsx +++ b/apps/web/components/task/mobile/mobile-sessions-section.tsx @@ -22,6 +22,8 @@ import { } from "@kandev/ui/alert-dialog"; import { AgentLogo } from "@/components/agent-logo"; import { useAppStore } from "@/components/state-provider"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { useSessionActions, @@ -295,12 +297,9 @@ function SessionRowItem({ } function useSessionRows(taskId: string | null) { - const agentProfiles = useAppStore((s) => s.agentProfiles.items); - const primarySessionId = useAppStore((s) => { - if (!taskId) return null; - const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return task?.primarySessionId ?? null; - }); + const { agentProfiles } = useSettingsData(Boolean(taskId)); + const task = useTaskById(taskId); + const primarySessionId = task?.primarySessionId ?? null; const { sessions, isLoading } = useTaskSessions(taskId); const rows = useMemo( () => buildSessionRows(sessions, agentProfiles, primarySessionId), diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.tsx new file mode 100644 index 0000000000..46e8bf674c --- /dev/null +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.test.tsx @@ -0,0 +1,320 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import { repositoryId, sessionId, taskId, workflowId, workspaceId } from "@/lib/types/ids"; +import type { Repository, Task, Workflow, WorkflowSnapshot, Workspace } from "@/lib/types/http"; +import { useSheetActions, useSheetData } from "./session-task-switcher-sheet-hooks"; + +const mocks = vi.hoisted(() => ({ + fetchWorkflowSnapshot: vi.fn(), + launchSession: vi.fn(async (_request: unknown) => ({})), + listRepositories: vi.fn(), + listTaskSessions: vi.fn(), + listWorkflows: vi.fn(), + replaceTaskUrl: vi.fn(), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchWorkflowSnapshot: (...args: unknown[]) => mocks.fetchWorkflowSnapshot(...args), + listWorkflows: (...args: unknown[]) => mocks.listWorkflows(...args), +})); + +vi.mock("@/lib/api/domains/workspace-api", () => ({ + listRepositories: (...args: unknown[]) => mocks.listRepositories(...args), +})); + +vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(), + listSessionTurns: vi.fn(), + listTaskSessionMessages: vi.fn(), + listTaskSessions: (...args: unknown[]) => mocks.listTaskSessions(...args), + searchSessionMessages: vi.fn(), +})); + +vi.mock("@/lib/services/session-launch-service", () => ({ + launchSession: (request: unknown) => mocks.launchSession(request), +})); + +vi.mock("@/lib/links", () => ({ + replaceTaskUrl: (...args: unknown[]) => mocks.replaceTaskUrl(...args), +})); + +const WORKSPACE_ID = workspaceId("workspace-1"); +const OTHER_WORKSPACE_ID = workspaceId("workspace-2"); +const WORKFLOW_ID = workflowId("workflow-1"); +const OTHER_WORKFLOW_ID = workflowId("workflow-2"); +const STEP_ID = "step-1"; +const OTHER_STEP_ID = "step-2"; +const TASK_ID = taskId("task-1"); +const OTHER_TASK_ID = taskId("task-2"); +const SESSION_ID = sessionId("session-1"); +const CREATED_AT = "2026-06-24T00:00:00Z"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Infinity }, + mutations: { retry: false }, + }, + }); +} + +function seedQueryData(queryClient: QueryClient) { + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [ + workflow(WORKFLOW_ID, WORKSPACE_ID, "Build"), + ]); + queryClient.setQueryData( + qk.workflows.snapshot(WORKFLOW_ID), + snapshot(WORKFLOW_ID, WORKSPACE_ID, [ + task({ id: TASK_ID, title: "Query task", primarySessionId: SESSION_ID }), + ]), + ); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), [repository()]); +} + +function wrapperFor(queryClient: QueryClient) { + const initialState = { + workspaces: { + activeId: WORKSPACE_ID, + items: [workspace(WORKSPACE_ID, "Workspace"), workspace(OTHER_WORKSPACE_ID, "Other")], + }, + workflows: { activeId: WORKFLOW_ID }, + } as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function renderActions(queryClient: QueryClient, onOpenChange = vi.fn()) { + return renderHook( + () => ({ + actions: useSheetActions(WORKSPACE_ID, onOpenChange), + store: useAppStoreApi(), + }), + { wrapper: wrapperFor(queryClient) }, + ); +} + +function workflow(id: string, workspace: string, name: string): Workflow { + return { + id: workflowId(id), + workspace_id: workspaceId(workspace), + name, + sort_order: 0, + hidden: false, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function workspace(id: string, name: string): Workspace { + return { + id: workspaceId(id), + name, + owner_id: "owner-1", + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function repository(): Repository { + return { + id: repositoryId("repo-1"), + workspace_id: WORKSPACE_ID, + name: "kandev", + source_type: "local", + local_path: "/work/kandev", + provider: "github", + provider_repo_id: "repo-1", + provider_owner: "kdlbs", + provider_name: "kandev", + default_branch: "main", + worktree_branch_prefix: "", + pull_before_worktree: false, + setup_script: "", + cleanup_script: "", + dev_script: "", + copy_files: "", + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +type TaskFixture = { + id: string; + title: string; + workflow?: string; + workspace?: string; + step?: string; + primarySessionId?: string | null; +}; + +function task({ + id, + title, + workflow = WORKFLOW_ID, + workspace = WORKSPACE_ID, + step = STEP_ID, + primarySessionId = null, +}: TaskFixture): Task { + return { + id: taskId(id), + workspace_id: workspaceId(workspace), + workflow_id: workflowId(workflow), + workflow_step_id: step, + position: 0, + title, + description: "", + state: "TODO", + priority: 0, + repositories: [ + { + id: "task-repo-1", + repository_id: repositoryId("repo-1"), + base_branch: "main", + position: 0, + }, + ], + primary_session_id: primarySessionId ? sessionId(primarySessionId) : null, + primary_executor_type: "local_docker", + created_at: CREATED_AT, + updated_at: CREATED_AT, + } as Task; +} + +function snapshot(workflowValue: string, _workspace: string, tasks: Task[]): WorkflowSnapshot { + const stepId = workflowValue === OTHER_WORKFLOW_ID ? OTHER_STEP_ID : STEP_ID; + return { + workflow: + workflowValue === OTHER_WORKFLOW_ID + ? workflow(OTHER_WORKFLOW_ID, OTHER_WORKSPACE_ID, "Other Build") + : workflow(WORKFLOW_ID, WORKSPACE_ID, "Build"), + steps: [ + { + id: stepId, + workflow_id: workflowId(workflowValue), + name: workflowValue === OTHER_WORKFLOW_ID ? "Other Todo" : "Query Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks, + }; +} + +describe("session task switcher sheet hooks", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.listWorkflows.mockImplementation(async (workspace: string) => ({ + workflows: + workspace === OTHER_WORKSPACE_ID + ? [workflow(OTHER_WORKFLOW_ID, OTHER_WORKSPACE_ID, "Other Build")] + : [workflow(WORKFLOW_ID, WORKSPACE_ID, "Build")], + })); + mocks.fetchWorkflowSnapshot.mockImplementation(async (workflow: string) => + workflow === OTHER_WORKFLOW_ID + ? snapshot(OTHER_WORKFLOW_ID, OTHER_WORKSPACE_ID, [ + task({ + id: OTHER_TASK_ID, + title: "Other task", + workflow: OTHER_WORKFLOW_ID, + workspace: OTHER_WORKSPACE_ID, + step: OTHER_STEP_ID, + }), + ]) + : snapshot(WORKFLOW_ID, WORKSPACE_ID, [ + task({ id: TASK_ID, title: "Query task", primarySessionId: SESSION_ID }), + ]), + ); + mocks.listRepositories.mockResolvedValue({ repositories: [] }); + mocks.listTaskSessions.mockResolvedValue({ sessions: [] }); + window.history.replaceState({}, "", "/"); + }); + + afterEach(() => { + cleanup(); + }); + + it("builds sheet data from Query snapshots when legacy kanban mirrors are empty", () => { + const queryClient = createQueryClient(); + seedQueryData(queryClient); + + const { result } = renderHook(() => useSheetData(WORKSPACE_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current.dialogSteps).toEqual([ + expect.objectContaining({ id: STEP_ID, title: "Query Todo" }), + ]); + expect(result.current.tasksWithRepositories).toEqual([ + expect.objectContaining({ + id: TASK_ID, + title: "Query task", + workflowStepTitle: "Query Todo", + primarySessionId: SESSION_ID, + }), + ]); + }); + + it("selects a task primary session from Query snapshots when legacy task mirrors are empty", () => { + const queryClient = createQueryClient(); + seedQueryData(queryClient); + const onOpenChange = vi.fn(); + const { result } = renderActions(queryClient, onOpenChange); + + act(() => { + result.current.actions.handleSelectTask(TASK_ID); + }); + + expect(result.current.store.getState().tasks.activeTaskId).toBe(TASK_ID); + expect(result.current.store.getState().tasks.activeSessionId).toBe(SESSION_ID); + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(mocks.replaceTaskUrl).toHaveBeenCalledWith(TASK_ID); + }); + + it("writes created tasks to the workflow snapshot cache instead of the legacy kanban mirror", () => { + const queryClient = createQueryClient(); + seedQueryData(queryClient); + const { result } = renderActions(queryClient); + const created = task({ id: "task-new", title: "Created from mobile" }); + + act(() => { + result.current.actions.handleTaskCreated(created, "create", { + taskSessionId: "session-new", + }); + }); + + const cached = queryClient.getQueryData(qk.workflows.snapshot(WORKFLOW_ID)); + expect(cached?.tasks.map((item) => item.title)).toContain("Created from mobile"); + expect(cached?.tasks.find((item) => item.id === "task-new")?.primary_session_id).toBe( + "session-new", + ); + expect("kanban" in result.current.store.getState()).toBe(false); + }); + + it("switches workspace without a legacy kanban mirror", async () => { + const queryClient = createQueryClient(); + seedQueryData(queryClient); + const onOpenChange = vi.fn(); + const { result } = renderActions(queryClient, onOpenChange); + + await act(async () => { + await result.current.actions.handleWorkspaceChange(OTHER_WORKSPACE_ID); + }); + + expect(result.current.store.getState().workflows.activeId).toBe(OTHER_WORKFLOW_ID); + expect(result.current.store.getState().tasks.activeTaskId).toBe(OTHER_TASK_ID); + expect("kanban" in result.current.store.getState()).toBe(false); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index dba002903f..20b029c46b 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -1,12 +1,14 @@ "use client"; import { useCallback, useMemo, useState } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { replaceTaskUrl } from "@/lib/links"; -import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api"; import { launchSession } from "@/lib/services/session-launch-service"; import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks"; +import { useCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import { getSessionInfoForTask } from "@/lib/utils/session-info"; @@ -22,41 +24,22 @@ import { type TaskSessionState, type Repository, type Task, - type WorkflowSnapshot, type Message, } from "@/lib/types/http"; import type { KanbanState } from "@/lib/state/slices"; -import { findTaskInSnapshots } from "@/lib/kanban/find-task"; +import { workflowsQueryOptions, workflowSnapshotQueryOptions } from "@/lib/query/query-options"; +import { qk } from "@/lib/query/keys"; +import { + updateWorkflowSnapshotQuery, + workflowSnapshotQueryData, +} from "@/lib/query/workflow-snapshot-cache"; +import { workflowSnapshotToKanbanData } from "@/lib/kanban/snapshot"; import { repositorySlug } from "@/lib/repository-slug"; import { resolvePreferredSessionId } from "../task-select-helpers"; import { agentErrorMessageForTask } from "@/lib/task-agent-error"; -import { - agentErrorAcknowledgementSessionIds, - usePersistResolvedAgentErrorAcknowledgements, -} from "../use-agent-error-acknowledgements"; - -// Map workflow snapshot to kanban state on workspace switch. -function mapSnapshotToKanban(snapshot: WorkflowSnapshot, newWorkflowId: string) { - return { - workflowId: newWorkflowId, - isLoading: false, - steps: snapshot.steps.map((step) => ({ - id: step.id, - title: step.name, - color: step.color, - position: step.position, - events: step.events, - // Carry optional step capabilities forward so downstream UI doesn't see - // them as missing after a workspace switch (until a full reload). - allow_manual_move: step.allow_manual_move, - prompt: step.prompt, - is_start_step: step.is_start_step, - show_in_command_panel: step.show_in_command_panel, - agent_profile_id: step.agent_profile_id, - })), - tasks: snapshot.tasks.map(toKanbanTask), - }; -} +import { sessionId as toSessionId } from "@/lib/types/ids"; +import { usePersistTaskAgentErrorAcknowledgements } from "../use-agent-error-acknowledgements"; +import type { SidebarLinkTarget } from "../task-session-sidebar-link-actions"; function sortByUpdatedAtDesc(items: T[]): T[] { return [...items].sort((a, b) => { @@ -142,6 +125,24 @@ function pendingFlagsForTask( }; } +function buildLinkTargetMap( + tasks: Array, +): Map { + return new Map( + tasks.map((task) => [ + task.id, + { + id: task.id, + title: task.title, + repositoryId: task.repositoryId, + issueUrl: task.issueUrl, + issueNumber: task.issueNumber, + repositories: task.repositories, + }, + ]), + ); +} + export function useSheetData(workspaceId: string | null) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); @@ -159,28 +160,23 @@ export function useSheetData(workspaceId: string | null) { workflows, isLoading: tasksLoading, } = useWorkspaceSidebarTasks(workspaceId); - const steps = useAppStore((state) => state.kanban.steps); - const workspaces = useAppStore((state) => state.workspaces.items); - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const { items: workspaces } = useWorkspaces(); + const repositories = useCachedRepositories(workspaceId); const selectedTaskId = useMemo(() => { if (activeSessionId) return sessionsById[activeSessionId]?.task_id ?? activeTaskId; return activeTaskId; }, [activeSessionId, activeTaskId, sessionsById]); - const acknowledgementSessionIds = useMemo( - () => agentErrorAcknowledgementSessionIds(allTasks, sessionsByTaskId), - [allTasks, sessionsByTaskId], - ); - usePersistResolvedAgentErrorAcknowledgements({ + usePersistTaskAgentErrorAcknowledgements({ + tasks: allTasks, + sessionsByTaskId, sessionsById, - sessionIds: acknowledgementSessionIds, messagesBySession, dismissedAgentErrors, }); const tasksWithRepositories = useMemo(() => { - const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : []; const ctx: SheetItemCtx = { repositoryPathsById: new Map( repositories.map((repo: Repository) => [repo.id, repositorySlug(repo)]), @@ -197,11 +193,10 @@ export function useSheetData(workspaceId: string | null) { }; return allTasks.map((task) => toSheetItem(task, ctx)); }, [ - repositoriesByWorkspace, + repositories, allTasks, allSteps, workflows, - workspaceId, sessionsById, sessionsByTaskId, gitStatusByEnvId, @@ -210,16 +205,17 @@ export function useSheetData(workspaceId: string | null) { dismissedAgentErrors, acknowledgedAgentErrors, ]); + const linkTaskById = useMemo(() => buildLinkTargetMap(allTasks), [allTasks]); const dialogSteps = useMemo( () => - steps.map((step: KanbanState["steps"][number]) => ({ + allSteps.map((step: KanbanState["steps"][number]) => ({ id: step.id, title: step.title, color: step.color, events: step.events, })), - [steps], + [allSteps], ); return { @@ -231,6 +227,7 @@ export function useSheetData(workspaceId: string | null) { // Skeleton while the first snapshot fetch is in flight — otherwise shows "No tasks yet." even when tasks exist. tasksLoading, tasksWithRepositories, + linkTaskById, dialogSteps, }; } @@ -238,6 +235,7 @@ export function useSheetData(workspaceId: string | null) { type SheetNavOptions = { workspaceId: string | null; store: ReturnType; + queryClient: QueryClient; loadTaskSessionsForTask: ( taskId: string, ) => Promise>; @@ -247,38 +245,31 @@ type SheetNavOptions = { }; async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { - const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts; - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } })); + const { + store, + queryClient, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + onOpenChange, + } = opts; try { - const workflowsResponse = await listWorkflows(newWorkspaceId, { - cache: "no-store", - includeHidden: true, + const newWorkspaceWorkflows = await queryClient.fetchQuery({ + ...workflowsQueryOptions(newWorkspaceId, { includeHidden: true }), + staleTime: 0, }); - const newWorkspaceWorkflows = workflowsResponse.workflows ?? []; const firstWorkflow = newWorkspaceWorkflows.find((w) => !w.hidden); - if (!firstWorkflow) { - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); - return; - } - const snapshot = await fetchWorkflowSnapshot(firstWorkflow.id); + if (!firstWorkflow) return; + const snapshot = await queryClient.fetchQuery({ + ...workflowSnapshotQueryOptions(firstWorkflow.id), + staleTime: 0, + }); store.setState((state) => ({ ...state, workflows: { ...state.workflows, - items: [ - ...state.workflows.items.filter( - (w: { workspaceId: string }) => w.workspaceId !== newWorkspaceId, - ), - ...newWorkspaceWorkflows.map((w) => ({ - id: w.id, - workspaceId: w.workspace_id, - name: w.name, - hidden: w.hidden, - })), - ], activeId: firstWorkflow.id, }, - kanban: mapSnapshotToKanban(snapshot, firstWorkflow.id), })); const mostRecentTask = sortByUpdatedAtDesc(snapshot.tasks)[0]; if (mostRecentTask) { @@ -294,109 +285,103 @@ async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) { onOpenChange(false); } catch (error) { console.error("Failed to switch workspace:", error); - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); } } -function mapTaskRepositories( - repositories: Task["repositories"], -): KanbanState["tasks"][number]["repositories"] { - return repositories?.map((r) => ({ - id: r.id, - repository_id: r.repository_id, - base_branch: r.base_branch, - checkout_branch: r.checkout_branch, - position: r.position, - })); +type TaskSuccessMeta = { taskSessionId?: string | null; willNavigate?: boolean }; + +function firstPresent(...values: Array): T | undefined { + for (const value of values) { + if (value !== null && value !== undefined) return value; + } + return undefined; } -function mergeSessionFields( +function mergeSnapshotSessionFields( task: Task, - existing: KanbanState["tasks"][number] | undefined, + existing: Task | undefined, taskSessionId: string | null, ) { + const metaSessionId = taskSessionId ? toSessionId(taskSessionId) : undefined; return { - primarySessionId: resolvePrimarySessionId(task, existing, taskSessionId), - primarySessionState: resolvePrimarySessionState(task, existing), - primarySessionPendingAction: resolvePrimarySessionPendingAction(task, existing), - sessionCount: resolveSessionCount(task, existing, taskSessionId), - reviewStatus: resolveReviewStatus(task, existing), + primary_session_id: firstPresent( + metaSessionId, + task.primary_session_id, + existing?.primary_session_id, + ), + primary_session_state: firstPresent( + task.primary_session_state, + existing?.primary_session_state, + ), + session_count: firstPresent( + task.session_count, + existing?.session_count, + taskSessionId ? 1 : undefined, + ), + review_status: firstPresent(task.review_status, existing?.review_status), }; } -function resolvePrimarySessionId( - task: Task, - existing: KanbanState["tasks"][number] | undefined, - taskSessionId: string | null, -) { - return taskSessionId ?? task.primary_session_id ?? existing?.primarySessionId ?? undefined; -} - -function resolvePrimarySessionState( - task: Task, - existing: KanbanState["tasks"][number] | undefined, -) { - return task.primary_session_state ?? existing?.primarySessionState ?? undefined; -} - -function resolvePrimarySessionPendingAction( +/** + * Build the workflow-snapshot representation of a task for an upsert. Session- + * derived fields (primary_session_id, session_count, etc.) fall through new + * DTO → existing entry → meta.taskSessionId so edits don't wipe sessions and + * "create with session" still sets the primary correctly. + */ +function buildSnapshotTaskUpsert( task: Task, - existing: KanbanState["tasks"][number] | undefined, -) { - if ("primary_session_pending_action" in task) { - return task.primary_session_pending_action ?? undefined; - } - return existing?.primarySessionPendingAction ?? undefined; + existing: Task | undefined, + meta: TaskSuccessMeta | undefined, +): Task { + const taskSessionId = meta?.taskSessionId ?? null; + return { + ...task, + ...mergeSnapshotSessionFields(task, existing, taskSessionId), + }; } -function resolveSessionCount( +function upsertTaskInWorkflowSnapshot( + queryClient: QueryClient, task: Task, - existing: KanbanState["tasks"][number] | undefined, - taskSessionId: string | null, -) { - return task.session_count ?? existing?.sessionCount ?? (taskSessionId ? 1 : undefined); + meta?: TaskSuccessMeta, +): void { + let nextTask: Task | null = null; + updateWorkflowSnapshotQuery(queryClient, task.workflow_id, (snapshot) => { + const existing = snapshot.tasks.find((item) => item.id === task.id); + nextTask = buildSnapshotTaskUpsert(task, existing, meta); + return { + ...snapshot, + tasks: existing + ? snapshot.tasks.map((item) => (item.id === task.id ? nextTask! : item)) + : [...snapshot.tasks, nextTask], + }; + }); + if (!nextTask) return; + queryClient.setQueryData(qk.tasks.detail(task.id), (current: unknown) => { + if (!current) return nextTask; + return { ...(current as Task), ...nextTask }; + }); } -function resolveReviewStatus(task: Task, existing: KanbanState["tasks"][number] | undefined) { - return task.review_status ?? existing?.reviewStatus ?? undefined; -} +function findTaskInCachedQueryData( + queryClient: QueryClient, + taskId: string, +): KanbanState["tasks"][number] | null { + const detail = queryClient.getQueryData(qk.tasks.detail(taskId)); + if (detail) return toKanbanTask(detail); -/** - * Build the kanban-store representation of a task for an upsert. Session- - * derived fields (primarySessionId, sessionCount, etc.) fall through new - * DTO → existing entry → meta.taskSessionId — that way an "edit" call doesn't - * wipe sessions the existing entry carried, and "create with session" still - * sets the primary correctly. - */ -function buildKanbanTaskUpsert( - task: Task, - existing: KanbanState["tasks"][number] | undefined, - meta: { taskSessionId?: string | null } | undefined, -): KanbanState["tasks"][number] { - const taskSessionId = meta?.taskSessionId ?? null; - return { - id: task.id, - parentTaskId: task.parent_id ?? undefined, - workflowStepId: task.workflow_step_id, - title: task.title, - description: task.description, - position: task.position ?? 0, - state: task.state, - repositoryId: task.repositories?.[0]?.repository_id ?? undefined, - repositories: mapTaskRepositories(task.repositories), - updatedAt: task.updated_at, - ...mergeSessionFields(task, existing, taskSessionId), - primaryExecutorId: task.primary_executor_id ?? undefined, - primaryExecutorType: task.primary_executor_type ?? undefined, - primaryExecutorName: task.primary_executor_name ?? undefined, - isRemoteExecutor: task.is_remote_executor ?? false, - }; + for (const snapshot of workflowSnapshotQueryData(queryClient)) { + const found = workflowSnapshotToKanbanData(snapshot).tasks.find((task) => task.id === taskId); + if (found) return found; + } + return null; } function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { const { workspaceId, store, + queryClient, loadTaskSessionsForTask, setActiveSession, setActiveTask, @@ -409,6 +394,7 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { await switchWorkspace(newWorkspaceId, { workspaceId, store, + queryClient, loadTaskSessionsForTask, setActiveSession, setActiveTask, @@ -417,31 +403,20 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { }, // Spread the individual fields rather than the `opts` object so callers // re-passing a fresh literal each render don't defeat memoization. - [workspaceId, store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange], + [ + workspaceId, + store, + queryClient, + loadTaskSessionsForTask, + setActiveSession, + setActiveTask, + onOpenChange, + ], ); const handleTaskCreated = useCallback( - (task: Task, _mode: "create" | "edit", meta?: { taskSessionId?: string | null }) => { - store.setState((state) => { - if (state.kanban.workflowId !== task.workflow_id) return state; - const existing = state.kanban.tasks.find( - (item: KanbanState["tasks"][number]) => item.id === task.id, - ); - const nextTask = buildKanbanTaskUpsert(task, existing, meta); - return { - ...state, - kanban: { - ...state.kanban, - tasks: state.kanban.tasks.some( - (item: KanbanState["tasks"][number]) => item.id === task.id, - ) - ? state.kanban.tasks.map((item: KanbanState["tasks"][number]) => - item.id === task.id ? nextTask : item, - ) - : [...state.kanban.tasks, nextTask], - }, - }; - }); + (task: Task, _mode: "create" | "edit", meta?: TaskSuccessMeta) => { + upsertTaskInWorkflowSnapshot(queryClient, task, meta); setActiveTask(task.id); if (meta?.taskSessionId) { setActiveSession(task.id, meta.taskSessionId); @@ -449,7 +424,7 @@ function useWorkspaceAndTaskCreatedActions(opts: SheetNavOptions) { replaceTaskUrl(task.id); onOpenChange(false); }, - [store, setActiveTask, setActiveSession, onOpenChange], + [queryClient, setActiveTask, setActiveSession, onOpenChange], ); return { handleWorkspaceChange, handleTaskCreated }; @@ -498,6 +473,7 @@ async function selectTaskWithoutPrimarySession(taskId: string, opts: SelectTaskO function useSheetDeleteActions( store: ReturnType, + queryClient: QueryClient, removeTaskFromBoard: ReturnType["removeTaskFromBoard"], ) { const { deleteTaskById } = useTaskActions(); @@ -510,15 +486,14 @@ function useSheetDeleteActions( const handleDeleteTask = useCallback( (taskId: string) => { - const state = store.getState(); - const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + const task = findTaskInCachedQueryData(queryClient, taskId); setDeletingTask({ id: taskId, title: task?.title ?? "this task", executorType: task?.primaryExecutorType, }); }, - [store], + [queryClient], ); const handleDeleteConfirm = useCallback( @@ -556,17 +531,18 @@ function useSheetDeleteActions( } export function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) { + const queryClient = useQueryClient(); const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); const store = useAppStoreApi(); const archiveAndSwitch = useArchiveAndSwitchTask(); const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store }); - const deleteActions = useSheetDeleteActions(store, removeTaskFromBoard); + const deleteActions = useSheetDeleteActions(store, queryClient, removeTaskFromBoard); const handleSelectTask = useCallback( (taskId: string) => { const state = store.getState(); - const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + const task = findTaskInCachedQueryData(queryClient, taskId); if (task?.primarySessionId) { const targetSessionId = resolvePreferredSessionId({ taskId, @@ -588,7 +564,7 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open: onOpenChange, }); }, - [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, onOpenChange], + [loadTaskSessionsForTask, setActiveSession, setActiveTask, store, queryClient, onOpenChange], ); const [archivingTask, setArchivingTask] = useState<{ @@ -600,15 +576,14 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open: const handleArchiveTask = useCallback( (taskId: string) => { - const state = store.getState(); - const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + const task = findTaskInCachedQueryData(queryClient, taskId); setArchivingTask({ id: taskId, title: task?.title ?? "this task", executorType: task?.primaryExecutorType, }); }, - [store], + [queryClient], ); const handleArchiveConfirm = useCallback( @@ -630,6 +605,7 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open: const { handleWorkspaceChange, handleTaskCreated } = useWorkspaceAndTaskCreatedActions({ workspaceId, store, + queryClient, loadTaskSessionsForTask, setActiveSession, setActiveTask, diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx index dafd1389d8..63c6b9a898 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx @@ -1,9 +1,11 @@ import { fireEvent, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ToastProvider } from "@/components/toast-provider"; import { MobileTaskList } from "@/components/task/mobile/session-task-switcher-sheet"; import type { TaskSwitcherItem } from "@/components/task/task-switcher"; +import { makeQueryClient } from "@/lib/query/client"; const mocks = vi.hoisted(() => ({ toggleSidebarGroupCollapsed: vi.fn(), @@ -65,20 +67,23 @@ describe("MobileTaskList", () => { }); it("toggles workflow-step groups from the mobile sidebar", () => { + const queryClient = makeQueryClient(); render( - - - , + + + + + , ); const header = screen.getByTestId("sidebar-group-header"); diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 9f21f46b93..5dd8628e8b 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -10,7 +10,7 @@ import { SidebarFilterBar } from "../sidebar-filter/sidebar-filter-bar"; import type { StepDef } from "../task-switcher-context-menu"; import type { TaskMoveWorkflow } from "../task-move-context-menu"; import { applyView } from "@/lib/sidebar/apply-view"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAppStore } from "@/components/state-provider"; import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view"; import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; @@ -19,7 +19,10 @@ import { TaskCreateDialog } from "@/components/task-create-dialog"; import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog"; import { TaskDeleteConfirmDialog } from "../task-delete-confirm-dialog"; import { SidebarLinkDialogs } from "../task-session-sidebar-dialogs"; -import { useSidebarLinkActions } from "../task-session-sidebar-link-actions"; +import { + useSidebarLinkActions, + type SidebarLinkTarget, +} from "../task-session-sidebar-link-actions"; import { useSidebarTaskLinking } from "../task-session-sidebar-task-linking"; import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks"; @@ -30,9 +33,11 @@ type SessionTaskSwitcherSheetProps = { workflowId: string | null; }; -function useMobileTaskLinking(workspaceId: string | null) { - const store = useAppStoreApi(); - const actions = useSidebarLinkActions(store); +function useMobileTaskLinking( + workspaceId: string | null, + linkTaskById: ReadonlyMap, +) { + const actions = useSidebarLinkActions(linkTaskById); const taskListHandlers = useSidebarTaskLinking(workspaceId, actions); const { repositories } = useRepositories(workspaceId); @@ -140,7 +145,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ const [dialogOpen, setDialogOpen] = useState(false); const data = useSheetData(workspaceId); const actions = useSheetActions(workspaceId, onOpenChange); - const linking = useMobileTaskLinking(workspaceId); + const linking = useMobileTaskLinking(workspaceId, data.linkTaskById); return ( diff --git a/apps/web/components/task/mode-selector.test.tsx b/apps/web/components/task/mode-selector.test.tsx new file mode 100644 index 0000000000..4a71779355 --- /dev/null +++ b/apps/web/components/task/mode-selector.test.tsx @@ -0,0 +1,80 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { afterEach, describe, expect, it } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import type { TaskSession } from "@/lib/types/http"; +import { ModeSelector } from "./mode-selector"; + +const SESSION_ID = "session-1"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function seedSettingsQueries(queryClient: QueryClient) { + queryClient.setQueryData(qk.settings.agents(), { agents: [] }); + queryClient.setQueryData(qk.settings.availableAgents(), { agents: [], tools: [] }); +} + +function renderModeSelector(queryClient: QueryClient, initialState?: Partial) { + seedSettingsQueries(queryClient); + + return render( + + + + + + + , + ); +} + +describe("ModeSelector", () => { + afterEach(() => { + cleanup(); + }); + + it("renders live session modes from the Query cache", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.sessionRuntime.mode(SESSION_ID), { + currentModeId: "plan", + availableModes: [ + { id: "plan", name: "Plan" }, + { id: "build", name: "Build" }, + ], + }); + + renderModeSelector(queryClient); + + expect(screen.getByTestId("session-mode-selector").textContent).toContain("Plan"); + }); + + it("falls back to the session snapshot mode when no live mode has arrived", () => { + const queryClient = createQueryClient(); + const session = { + id: SESSION_ID, + task_id: "task-1", + state: "RUNNING", + started_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:01Z", + agent_profile_snapshot: { mode: "review_mode" }, + } as unknown as TaskSession; + + renderModeSelector(queryClient, { + taskSessions: { items: { [SESSION_ID]: session } }, + }); + + expect(screen.getByTestId("session-mode-selector").textContent).toContain("Review Mode"); + }); +}); diff --git a/apps/web/components/task/mode-selector.tsx b/apps/web/components/task/mode-selector.tsx index 3910277312..862fea00f7 100644 --- a/apps/web/components/task/mode-selector.tsx +++ b/apps/web/components/task/mode-selector.tsx @@ -1,6 +1,7 @@ "use client"; import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconCheck, IconChevronDown } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { @@ -15,6 +16,7 @@ import { useAppStore } from "@/components/state-provider"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { setSessionMode } from "@/lib/api/domains/session-api"; +import { sessionModeQueryOptions } from "@/lib/query/query-options"; import { cn } from "@/lib/utils"; import type { Agent, AgentProfile, AvailableAgent } from "@/lib/types/http"; @@ -92,12 +94,11 @@ function buildModeState( } function useModeSelectorState(sessionId: string | null) { - useSettingsData(true); + const settingsCatalog = useSettingsData(true); - const liveModeState = useAppStore((state) => - sessionId ? state.sessionMode.bySessionId[sessionId] : undefined, - ); - const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const modeQuery = useQuery(sessionModeQueryOptions(sessionId ?? "")); + const liveModeState = modeQuery.data; + const settingsAgents = settingsCatalog.settingsAgents; const taskSessions = useAppStore((state) => state.taskSessions.items); const { items: availableAgents } = useAvailableAgents(); diff --git a/apps/web/components/task/model-selector.tsx b/apps/web/components/task/model-selector.tsx index 43685b2821..28071d9b28 100644 --- a/apps/web/components/task/model-selector.tsx +++ b/apps/web/components/task/model-selector.tsx @@ -1,6 +1,7 @@ "use client"; import { memo, useCallback, useMemo, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { configOptionToModelOptions, @@ -15,6 +16,8 @@ import { useToast } from "@/components/toast-provider"; import { useAvailableAgents } from "@/hooks/domains/settings/use-available-agents"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { setSessionConfigOption, setSessionModel } from "@/lib/api/domains/session-api"; +import { qk } from "@/lib/query/keys"; +import { sessionModelsQueryOptions } from "@/lib/query/query-options"; import type { Agent, AgentProfile, AvailableAgent } from "@/lib/types/http"; import type { ConfigOptionEntry, @@ -144,6 +147,7 @@ function useModelChangeHandlers( configOptions: SelectConfigOption[], sessionModelsData: SessionModelsEntry | undefined, ) { + const queryClient = useQueryClient(); const activeModels = useAppStore((state) => state.activeModel.bySessionId); const setActiveModel = useAppStore((state) => state.setActiveModel); const setSessionModels = useAppStore((state) => state.setSessionModels); @@ -160,8 +164,13 @@ function useModelChangeHandlers( currentModelId: nextCurrentModelId(sessionModelsData, configId, value), configOptions: updateConfigOptionValue(sessionModelsData.configOptions, configId, value), }); + queryClient.setQueryData(qk.sessionRuntime.models(sid), { + ...sessionModelsData, + currentModelId: nextCurrentModelId(sessionModelsData, configId, value), + configOptions: updateConfigOptionValue(sessionModelsData.configOptions, configId, value), + }); }, - [sessionModelsData, setSessionModels], + [queryClient, sessionModelsData, setSessionModels], ); const onFail = useCallback( @@ -175,14 +184,17 @@ function useModelChangeHandlers( if (latestReqId.current[sid] !== reqId) return; console.error("[ModelSelector] model change failed:", err); setActiveModel(sid, previousActive); - if (previousModels) setSessionModels(sid, previousModels); + if (previousModels) { + setSessionModels(sid, previousModels); + queryClient.setQueryData(qk.sessionRuntime.models(sid), previousModels); + } toast({ title: "Failed to change model", description: describeError(err), variant: "error", }); }, - [setActiveModel, setSessionModels, toast], + [queryClient, setActiveModel, setSessionModels, toast], ); const nextReqId = useCallback((sid: string) => { @@ -228,19 +240,38 @@ function useModelChangeHandlers( return { handleModelChange, handleConfigChange }; } +function useTaskSessionForModel(sessionId: string | null) { + return useAppStore((state) => { + if (!sessionId) return null; + return state.taskSessions.items[sessionId] ?? null; + }); +} + +function useActiveModelForSession(sessionId: string | null) { + return useAppStore((state) => { + if (!sessionId) return null; + return state.activeModel.bySessionId[sessionId] || null; + }); +} + +function useSessionModelsData(sessionId: string | null) { + const sessionModelsQuery = useQuery(sessionModelsQueryOptions(sessionId ?? "")); + const storeSessionModelsData = useAppStore((state) => + sessionId ? state.sessionModels.bySessionId[sessionId] : undefined, + ); + return sessionModelsQuery.data ?? storeSessionModelsData; +} + /** Resolves available models, config options and current model from store state. */ function useModelSelectorState(sessionId: string | null) { - useSettingsData(true); + const settingsCatalog = useSettingsData(true); - const settingsAgents = useAppStore((state) => state.settingsAgents.items); - const taskSessions = useAppStore((state) => state.taskSessions.items); - const activeModels = useAppStore((state) => state.activeModel.bySessionId); + const settingsAgents = settingsCatalog.settingsAgents; const { items: availableAgents } = useAvailableAgents(); - const sessionModelsData = useAppStore((state) => - sessionId ? state.sessionModels.bySessionId[sessionId] : undefined, - ); + const session = useTaskSessionForModel(sessionId); + const activeModel = useActiveModelForSession(sessionId); + const sessionModelsData = useSessionModelsData(sessionId); - const session = sessionId ? (taskSessions[sessionId] ?? null) : null; const snapshotModel = resolveSnapshotModel(session?.agent_profile_snapshot); const profileModel = useMemo( () => resolveProfileModel(session?.agent_profile_id, settingsAgents as Agent[]), @@ -259,7 +290,6 @@ function useModelSelectorState(sessionId: string | null) { availableAgents, }); - const activeModel = sessionId ? activeModels[sessionId] || null : null; const acpCurrentModel = sessionModelsData?.currentModelId || null; const currentModel = resolveCurrentModel( activeModel, diff --git a/apps/web/components/task/new-session-dialog.test.tsx b/apps/web/components/task/new-session-dialog.test.tsx new file mode 100644 index 0000000000..4c684d7f01 --- /dev/null +++ b/apps/web/components/task/new-session-dialog.test.tsx @@ -0,0 +1,172 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { taskId, workflowId, workspaceId, type Task } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type MockState = { + kanban: { tasks: KanbanState["tasks"] }; + tasks: { activeSessionId: string | null }; + taskSessions: { items: Record }; + messages: { bySession: Record> }; + setActiveSession: ReturnType; +}; + +let mockState: MockState; + +vi.mock("@kandev/ui/dialog", () => ({ + Dialog: ({ children }: { children: ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +vi.mock("@kandev/ui/button", () => ({ + Button: ({ children, ...props }: { children: ReactNode }) => ( + + ), +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/components/task-create-dialog-selectors", () => ({ + AgentSelector: () => null, +})); + +vi.mock("@/components/task-create-dialog-options", () => ({ + useAgentProfileOptions: () => [{ value: "agent-1", label: "Agent" }], +})); + +vi.mock("@/hooks/use-summarize-session", () => ({ + useSummarizeSession: () => ({ summarize: vi.fn(), isSummarizing: false }), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock("@/hooks/use-task-sessions", () => ({ + useTaskSessions: () => ({ sessions: [], loadSessions: vi.fn() }), +})); + +vi.mock("@/hooks/domains/settings/use-remote-auth-specs", () => ({ + useRemoteAuthSpecs: () => ({ specs: [], loaded: true }), +})); + +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: () => ({ + agentProfiles: [{ id: "agent-1", label: "Agent", agent_name: "codex" }], + executors: [{ id: "executor-1", name: "Executor" }], + }), +})); + +vi.mock("@/hooks/domains/session/use-task-executor-profile", () => ({ + useTaskExecutorProfile: () => null, +})); + +vi.mock("@/lib/agent-executor-compat", () => ({ + isAgentConfiguredOnExecutor: () => true, +})); + +vi.mock("@/hooks/use-is-utility-configured", () => ({ + useIsUtilityConfigured: () => false, +})); + +vi.mock("@/hooks/use-utility-agent-generator", () => ({ + useUtilityAgentGenerator: () => ({ enhancePrompt: vi.fn(), isEnhancingPrompt: false }), +})); + +vi.mock("@/components/task/session-dialog-shared", () => ({ + EnvironmentBadges: () => null, + ContextSelect: () => null, + toContextItems: () => [], + useDialogAttachments: () => ({ + attachments: [], + isDragging: false, + fileInputRef: { current: null }, + handleRemoveAttachment: vi.fn(), + handlePaste: vi.fn(), + handleDragOver: vi.fn(), + handleDragLeave: vi.fn(), + handleDrop: vi.fn(), + handleAttachClick: vi.fn(), + handleFileInputChange: vi.fn(), + }), +})); + +vi.mock("@/components/task/new-session-form-prompt", () => ({ + SessionPromptField: () => null, +})); + +vi.mock("@/components/task/new-session-form-actions", () => ({ + useSessionContextChange: () => vi.fn(), + useSessionLaunchSubmit: () => + vi.fn((event?: { preventDefault?: () => void }) => event?.preventDefault?.()), +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: { + getState: () => ({ api: null, centerGroupId: "center" }), + }, +})); + +vi.mock("@/lib/state/dockview-panel-actions", () => ({ + addSessionPanel: vi.fn(), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), +})); + +import { NewSessionDialog } from "./new-session-dialog"; + +function makeTask(): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-1", + position: 1, + title: "Query task title", + description: "", + state: "CREATED", + priority: 0, + repositories: [], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("NewSessionDialog", () => { + beforeEach(() => { + mockState = { + kanban: { tasks: [] }, + tasks: { activeSessionId: null }, + taskSessions: { items: {} }, + messages: { bySession: {} }, + setActiveSession: vi.fn(), + }; + }); + + it("uses the task title from task detail Query when kanban tasks are empty", () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + + render(, { + wrapper: wrapper(client), + }); + + expect(screen.getByText("Query task title")).toBeTruthy(); + }); +}); diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index d89e0cbab2..96fee119e4 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -14,7 +14,10 @@ import { useAgentProfileOptions } from "@/components/task-create-dialog-options" import { useSummarizeSession } from "@/hooks/use-summarize-session"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useTaskExecutorProfile } from "@/hooks/domains/session/use-task-executor-profile"; +import { useSessionWorktrees } from "@/hooks/domains/session/use-session-worktrees"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat"; import type { AgentProfileOption } from "@/lib/state/slices"; import type { ExecutorProfile } from "@/lib/types/http"; @@ -46,24 +49,15 @@ function agentProfileDisplayLabel(profile: AgentProfileOption): string { } function useNewSessionDialogState(taskId: string) { - const taskTitle = useAppStore((state) => { - const task = state.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return task?.title ?? "Task"; - }); - const agentProfiles = useAppStore((state) => state.agentProfiles.items); + const { agentProfiles, executors } = useSettingsData(true); + const task = useTaskById(taskId); + const taskTitle = task?.title ?? "Task"; const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const currentSession = useAppStore((state) => { return activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null; }); - const worktreeBranch = useAppStore((state) => { - if (!activeSessionId) return null; - const wtIds = state.sessionWorktreesBySessionId.itemsBySessionId[activeSessionId]; - if (wtIds?.length) { - const wt = state.worktrees.items[wtIds[0]]; - if (wt?.branch) return wt.branch; - } - return currentSession?.worktree_branch ?? null; - }); + const worktrees = useSessionWorktrees(activeSessionId); + const worktreeBranch = worktrees[0]?.branch ?? currentSession?.worktree_branch ?? null; const initialPrompt = useAppStore((state) => { if (!activeSessionId) return null; const msgs = state.messages.bySession[activeSessionId]; @@ -71,13 +65,11 @@ function useNewSessionDialogState(taskId: string) { const first = msgs.find((m: { author_type?: string }) => m.author_type === "user"); return first ? ((first as { content?: string }).content ?? null) : null; }); - const executorLabel = useAppStore((state) => { + const executorLabel = useMemo(() => { if (!currentSession?.executor_id) return null; - const executor = state.executors.items.find( - (e: { id: string }) => e.id === currentSession.executor_id, - ); + const executor = executors.find((e: { id: string }) => e.id === currentSession.executor_id); return executor?.name ?? null; - }); + }, [currentSession?.executor_id, executors]); const sessionProfileId = currentSession?.agent_profile_id ?? ""; const profileIsValid = agentProfiles.some((p: { id: string }) => p.id === sessionProfileId); @@ -113,7 +105,7 @@ function activateNewSession( function useSessionOptions(taskId: string) { const { sessions, loadSessions } = useTaskSessions(taskId); - const agentProfiles = useAppStore((s) => s.agentProfiles.items); + const { agentProfiles } = useSettingsData(true); useEffect(() => { loadSessions(true); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/apps/web/components/task/new-subtask-dialog.test.tsx b/apps/web/components/task/new-subtask-dialog.test.tsx new file mode 100644 index 0000000000..1ef06b41f0 --- /dev/null +++ b/apps/web/components/task/new-subtask-dialog.test.tsx @@ -0,0 +1,181 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { taskId, workflowId, workspaceId, type Task } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type MockState = { + kanban: { workflowId: string | null; tasks: KanbanState["tasks"] }; + workspaces: { activeId: string | null }; + tasks: { activeSessionId: string | null }; + taskSessions: { items: Record }; + messages: { bySession: Record> }; +}; + +const capturedSubmitWorkflowIds: Array = []; +let mockState: MockState; + +vi.mock("@kandev/ui/dialog", () => ({ + Dialog: ({ children }: { children: ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: ReactNode }) =>

{children}

, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/components/task-create-dialog-options", () => ({ + useAgentProfileOptions: () => [], + useExecutorProfileOptions: () => [], +})); + +vi.mock("@/components/task-create-dialog-handlers", () => ({ + useDialogHandlers: () => ({}), +})); + +vi.mock("@/components/task-create-dialog-effects", () => ({ + useDiscoverReposEffect: vi.fn(), + useGitHubUrlErrorEffect: vi.fn(), +})); + +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: () => ({ agentProfiles: [], executors: [] }), +})); + +vi.mock("@/hooks/domains/workspace/use-repositories", () => ({ + useRepositories: () => ({ repositories: [] }), +})); + +vi.mock("@/hooks/use-is-utility-configured", () => ({ + useIsUtilityConfigured: () => false, +})); + +vi.mock("@/hooks/use-summarize-session", () => ({ + useSummarizeSession: () => ({ summarize: vi.fn(), isSummarizing: false }), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock("@/hooks/use-task-sessions", () => ({ + useTaskSessions: () => ({ sessions: [], loadSessions: vi.fn() }), +})); + +vi.mock("@/lib/local-storage", () => ({ + getLocalStorage: () => null, +})); + +vi.mock("@/lib/settings/constants", () => ({ + STORAGE_KEYS: { LAST_EXECUTOR_PROFILE_ID: "last-executor-profile-id" }, +})); + +vi.mock("@/components/task/new-subtask-form-state", () => ({ + defaultSubtaskWorkspaceMode: (branch: string | null) => + branch ? "inherit_parent" : "new_workspace", + useSubtaskFormState: () => ({ + repositories: [], + setRepositories: vi.fn(), + agentProfileId: "", + setAgentProfileId: vi.fn(), + executorProfileId: "", + setExecutorProfileId: vi.fn(), + useRemote: false, + remoteRepos: [], + prInfoByUrl: {}, + discoveredRepositories: [], + }), +})); + +vi.mock("@/components/task/new-subtask-form-parts", () => ({ + PromptZone: () => null, + SubtaskFormBody: (props: { title: string }) => ( +
{props.title}
+ ), +})); + +vi.mock("@/components/task/session-context-summary", () => ({ + applySummarizeSessionResult: vi.fn(), +})); + +vi.mock("@/components/task/use-subtask-submit", () => ({ + useSubtaskPromptZone: () => ({ + promptRef: { current: null }, + attachments: { attachments: [] }, + contextItems: [], + handleEnhancePrompt: vi.fn(), + isEnhancingPrompt: false, + resolvePrompt: () => "prompt", + }), + useSubtaskSubmit: (opts: { workflowId: string | null }) => { + capturedSubmitWorkflowIds.push(opts.workflowId); + return { handleSubmit: vi.fn() }; + }, +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), + getSubtaskCount: vi.fn(() => Promise.resolve({ count: 3 })), +})); + +import { NewSubtaskDialog } from "./new-subtask-dialog"; + +function makeTask(): Task { + return { + id: taskId("task-parent"), + workspace_id: workspaceId("ws-query"), + workflow_id: workflowId("wf-query"), + workflow_step_id: "step-1", + position: 1, + title: "Parent task", + description: "", + state: "CREATED", + priority: 0, + repositories: [], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("NewSubtaskDialog", () => { + beforeEach(() => { + capturedSubmitWorkflowIds.length = 0; + mockState = { + kanban: { workflowId: null, tasks: [] }, + workspaces: { activeId: "ws-active" }, + tasks: { activeSessionId: null }, + taskSessions: { items: {} }, + messages: { bySession: {} }, + }; + }); + + it("uses Query task detail and subtask count when legacy kanban mirrors are empty", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-parent"), makeTask()); + + render( + , + { wrapper: wrapper(client) }, + ); + + await waitFor(() => + expect(screen.getByTestId("subtask-form-title").textContent).toBe("Parent task / Subtask 4"), + ); + expect(capturedSubmitWorkflowIds.at(-1)).toBe("wf-query"); + }); +}); diff --git a/apps/web/components/task/new-subtask-dialog.tsx b/apps/web/components/task/new-subtask-dialog.tsx index 455b6c2641..a2bad5882f 100644 --- a/apps/web/components/task/new-subtask-dialog.tsx +++ b/apps/web/components/task/new-subtask-dialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; @@ -15,12 +16,15 @@ import { useGitHubUrlErrorEffect, } from "@/components/task-create-dialog-effects"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useSessionWorktrees } from "@/hooks/domains/session/use-session-worktrees"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured"; import { useSummarizeSession, type SummarizeSessionResult } from "@/hooks/use-summarize-session"; import { useTaskSessions } from "@/hooks/use-task-sessions"; +import { useSubtaskCount } from "@/hooks/use-subtask-count"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; +import { taskQueryOptions } from "@/lib/query/query-options"; import type { ExecutorProfile, ExecutorType, Repository } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; import { @@ -39,26 +43,23 @@ type NewSubtaskDialogProps = { parentTaskTitle: string; }; -function useSubtaskDialogState() { - const agentProfiles = useAppStore((s) => s.agentProfiles.items); +function useSubtaskDialogState(parentTaskId: string) { + const { agentProfiles, executors } = useSettingsData(true); const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); - const workspaceId = useAppStore((s) => s.workspaces.activeId); - const workflowId = useAppStore((s) => s.kanban.workflowId); - const executors = useAppStore((s) => s.executors.items); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const parentTaskQuery = useQuery({ + ...taskQueryOptions(parentTaskId), + enabled: Boolean(parentTaskId), + }); + const workspaceId = parentTaskQuery.data?.workspace_id ?? activeWorkspaceId; + const workflowId = parentTaskQuery.data?.workflow_id ?? null; const currentSession = useAppStore((s) => activeSessionId ? (s.taskSessions.items[activeSessionId] ?? null) : null, ); - const worktreeBranch = useAppStore((s) => { - if (!activeSessionId) return null; - const wtIds = s.sessionWorktreesBySessionId.itemsBySessionId[activeSessionId]; - if (wtIds?.length) { - const wt = s.worktrees.items[wtIds[0]]; - if (wt?.branch) return wt.branch; - } - return currentSession?.worktree_branch ?? null; - }); + const worktrees = useSessionWorktrees(activeSessionId); + const worktreeBranch = worktrees[0]?.branch ?? currentSession?.worktree_branch ?? null; const initialPrompt = useAppStore((s) => { if (!activeSessionId) return null; @@ -81,7 +82,7 @@ function useSubtaskDialogState() { function useSessionOptions(taskId: string) { const { sessions, loadSessions } = useTaskSessions(taskId); - const agentProfiles = useAppStore((s) => s.agentProfiles.items); + const { agentProfiles } = useSettingsData(true); useEffect(() => { loadSessions(true); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -126,6 +127,20 @@ function useAutoSelectExecutorProfile( }, [allProfiles, executorProfileId, setExecutorProfileId]); } +function useSyncedDefaultTitle(defaultTitle: string): [string, (value: string) => void] { + const [title, setTitle] = useState(defaultTitle); + const previousDefaultTitleRef = useRef(defaultTitle); + + useEffect(() => { + const previousDefaultTitle = previousDefaultTitleRef.current; + if (previousDefaultTitle === defaultTitle) return; + previousDefaultTitleRef.current = defaultTitle; + setTitle((current) => (current === previousDefaultTitle ? defaultTitle : current)); + }, [defaultTitle]); + + return [title, setTitle]; +} + /** * Seeds the chip row with the parent task's repo + branch when the form * mounts. Form parent passes `key={open}` so each dialog open remounts the @@ -236,7 +251,7 @@ function NewSubtaskForm({ const isUtilityConfigured = useIsUtilityConfigured(); const { summarize, isSummarizing } = useSummarizeSession(); const [isCreating, setIsCreating] = useState(false); - const [title, setTitle] = useState(defaultTitle); + const [title, setTitle] = useSyncedDefaultTitle(defaultTitle); const [hasPrompt, setHasPrompt] = useState(false); const [contextValue, setContextValue] = useState("blank"); const [workspaceMode, setWorkspaceMode] = useState(() => @@ -340,16 +355,14 @@ export function NewSubtaskDialog({ currentSession, worktreeBranch, initialPrompt, - } = useSubtaskDialogState(); + } = useSubtaskDialogState(parentTaskId); // Ensure executor/agent data is loaded when dialog opens useSettingsData(open); // Load workspace repositories so the subtask repo override picker has options. const { repositories: availableRepositories } = useRepositories(workspaceId, open); - const siblingCount = useAppStore( - (s) => s.kanban.tasks.filter((t) => t.parentTaskId === parentTaskId).length, - ); + const siblingCount = useSubtaskCount(open, parentTaskId); const defaultTitle = useMemo( () => `${parentTaskTitle} / Subtask ${siblingCount + 1}`, diff --git a/apps/web/components/task/passthrough-chat-composer.test.ts b/apps/web/components/task/passthrough-chat-composer.test.ts index 45b4f2e213..819c42d4d7 100644 --- a/apps/web/components/task/passthrough-chat-composer.test.ts +++ b/apps/web/components/task/passthrough-chat-composer.test.ts @@ -1,7 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { DiffComment } from "@/lib/diff/types"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; +import { taskPlanQueryOptions } from "@/lib/query/query-options"; +import type { TaskPlan } from "@/lib/types/http"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import { buildContextFilesMeta, buildPassthroughFinalMessage, @@ -13,7 +17,13 @@ import { const mockGetTaskPlan = vi.hoisted(() => vi.fn()); vi.mock("@/lib/api/domains/plan-api", () => ({ + createTaskPlan: vi.fn(), + deleteTaskPlan: vi.fn(), + getPlanRevision: vi.fn(), getTaskPlan: mockGetTaskPlan, + listPlanRevisions: vi.fn(), + revertPlanRevision: vi.fn(), + updateTaskPlan: vi.fn(), })); const SESSION_ID = "session-1"; @@ -59,6 +69,31 @@ function panelState(overrides: Record = {}) { } as never; } +function queryClientWithPlan(content: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const plan = { + id: "plan-1", + task_id: TASK_ID, + title: "Plan", + content, + created_by: "agent", + created_at: "2026-06-23T00:00:00Z", + updated_at: "2026-06-23T00:00:00Z", + } as TaskPlan; + queryClient.setQueryData(taskPlanQueryOptions(TASK_ID).queryKey, plan); + return queryClient; +} + +function workflowSnapshot(overrides: Partial = {}): WorkflowSnapshotData { + return { + workflowId: "workflow-1", + workflowName: "Main flow", + steps: [], + tasks: [], + ...overrides, + }; +} + beforeEach(() => { mockGetTaskPlan.mockReset(); }); @@ -103,12 +138,11 @@ describe("passthrough chat composer metadata helpers", () => { }), inlineMentions: [file("src/inline.ts", "inline.ts"), file("prompt:prompt-1", "Review")], inlineTaskMentions: [inlineTask], - getState: () => - ({ - kanban: { steps: [{ id: "step-1", title: "Review" }] }, - kanbanMulti: { snapshots: {} }, - taskPlans: { byTaskId: {} }, - }) as never, + workflowSnapshots: { + "workflow-1": workflowSnapshot({ + steps: [{ id: "step-1", title: "Review", color: "", position: 0 }], + }), + }, }); expect(result.contextFilesMeta).toEqual([ @@ -126,23 +160,32 @@ describe("passthrough chat composer metadata helpers", () => { }); describe("passthrough chat composer plan context", () => { + it("expands selected plan context from the Query cache when the store has no plan", async () => { + const queryClient = queryClientWithPlan("## Query Plan\n\n1. Prefer cached query data"); + + const result = await buildPassthroughFinalMessage({ + taskId: TASK_ID, + content: "@Plan", + pendingComments: [], + panelState: panelState(), + inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], + queryClient, + }); + + expect(result.content).toContain("## Query Plan"); + expect(result.content).toContain("Prefer cached query data"); + expect(mockGetTaskPlan).not.toHaveBeenCalled(); + }); + it("expands selected plan context and strips the literal @Plan mention", async () => { + const queryClient = queryClientWithPlan("## Plan\n\n1. Ship passthrough fixes"); const result = await buildPassthroughFinalMessage({ taskId: TASK_ID, content: "@Plan", pendingComments: [], panelState: panelState(), inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], - getState: () => - ({ - kanban: { steps: [] }, - kanbanMulti: { snapshots: {} }, - taskPlans: { - byTaskId: { - [TASK_ID]: { content: "## Plan\n\n1. Ship passthrough fixes" }, - }, - }, - }) as never, + queryClient, }); expect(result.content).toContain(""); @@ -153,22 +196,14 @@ describe("passthrough chat composer plan context", () => { }); it("strips a selected plan mention from the middle of a message without double spacing", async () => { + const queryClient = queryClientWithPlan("## Plan\n\n1. Ship passthrough fixes"); const result = await buildPassthroughFinalMessage({ taskId: TASK_ID, content: "Please use @Plan now", pendingComments: [], panelState: panelState(), inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], - getState: () => - ({ - kanban: { steps: [] }, - kanbanMulti: { snapshots: {} }, - taskPlans: { - byTaskId: { - [TASK_ID]: { content: "## Plan\n\n1. Ship passthrough fixes" }, - }, - }, - }) as never, + queryClient, }); expect(result.content).toContain("Please use now"); @@ -186,12 +221,6 @@ describe("passthrough chat composer plan context", () => { pendingComments: [], panelState: panelState(), inlineMentions: [file(PLAN_CONTEXT_PATH, "Plan")], - getState: () => - ({ - kanban: { steps: [] }, - kanbanMulti: { snapshots: {} }, - taskPlans: { byTaskId: {} }, - }) as never, }), ).rejects.toThrow("plan lookup failed"); }); diff --git a/apps/web/components/task/passthrough-chat-composer.tsx b/apps/web/components/task/passthrough-chat-composer.tsx index 5607227651..cd3db51856 100644 --- a/apps/web/components/task/passthrough-chat-composer.tsx +++ b/apps/web/components/task/passthrough-chat-composer.tsx @@ -1,9 +1,11 @@ "use client"; import { useCallback, type RefObject } from "react"; +import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/components/toast-provider"; -import { useAppStoreApi } from "@/components/state-provider"; +import { useAppStore } from "@/components/state-provider"; import { useCommentsStore } from "@/lib/state/slices/comments/comments-store"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { formatReviewCommentsAsMarkdown } from "@/lib/state/slices/comments/format"; import { buildSubmitMessage } from "./chat/chat-input-area"; import { @@ -17,10 +19,14 @@ import type { ContextFile } from "@/lib/state/context-files-store"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; import { buildContextFilesContext, buildTaskMentionsContext } from "@/hooks/use-message-handler"; import { getWebSocketClient } from "@/lib/ws/connection"; -import { getTaskPlan } from "@/lib/api/domains/plan-api"; -import type { AppState } from "@/lib/state/store"; +import { taskPlanQueryOptions } from "@/lib/query/query-options"; +import type { TaskPlan } from "@/lib/types/http"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; const PLAN_CONTEXT_PATH = "plan:context"; +const standalonePlanQueryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); export type PassthroughSubmitHandler = ( content: string, @@ -159,15 +165,12 @@ export function buildPassthroughPlanContext(planContent: string | undefined | nu ); } -function cachedTaskPlanContent(taskId: string, state: AppState) { - return state.taskPlans.byTaskId[taskId]?.content; -} - -async function loadTaskPlanContent(taskId: string | null, getState: () => AppState) { +async function loadTaskPlanContent(taskId: string | null, queryClient: QueryClient) { if (!taskId) return ""; - const cached = cachedTaskPlanContent(taskId, getState()); - if (cached !== undefined) return cached; - const plan = await getTaskPlan(taskId); + const options = taskPlanQueryOptions(taskId); + const cached = queryClient.getQueryData(options.queryKey); + if (cached !== undefined) return cached?.content ?? ""; + const plan = await queryClient.fetchQuery(options); return plan?.content ?? ""; } @@ -179,7 +182,8 @@ export async function buildPassthroughFinalMessage({ panelState, inlineMentions, inlineTaskMentions, - getState, + workflowSnapshots = {}, + queryClient = standalonePlanQueryClient, }: { taskId: string | null; content: string; @@ -188,7 +192,8 @@ export async function buildPassthroughFinalMessage({ panelState: ReturnType; inlineMentions?: ContextFile[]; inlineTaskMentions?: TaskMentionData[]; - getState: ReturnType["getState"]; + workflowSnapshots?: Record; + queryClient?: QueryClient; }): Promise { const { formatted, commentsToSend } = formatPassthroughBaseMessage( content, @@ -200,11 +205,11 @@ export async function buildPassthroughFinalMessage({ const visibleContent = stripSelectedPlanMentions(formatted, allContextFiles); const contextFilesContext = buildContextFilesContext(allContextFiles, panelState.prompts); const planContext = hasPlanContext(allContextFiles) - ? buildPassthroughPlanContext(await loadTaskPlanContent(taskId, getState)) + ? buildPassthroughPlanContext(await loadTaskPlanContent(taskId, queryClient)) : ""; const taskMentionsContext = inlineTaskMentions && inlineTaskMentions.length > 0 - ? buildTaskMentionsContext(inlineTaskMentions, getState()) + ? buildTaskMentionsContext(inlineTaskMentions, workflowSnapshots) : ""; return { content: visibleContent + contextFilesContext + planContext + taskMentionsContext, @@ -283,7 +288,9 @@ export function useSendPassthroughMessage({ }) { const { toast } = useToast(); const markCommentsSent = useCommentsStore((s) => s.markCommentsSent); - const storeApi = useAppStoreApi(); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const { snapshots } = useAllWorkflowSnapshots(activeWorkspaceId); + const queryClient = useQueryClient(); return useCallback( async ( @@ -306,7 +313,8 @@ export function useSendPassthroughMessage({ panelState, inlineMentions, inlineTaskMentions, - getState: storeApi.getState, + workflowSnapshots: snapshots, + queryClient, }); await requestPassthroughMessage({ taskId, sessionId, message, attachments }); if (message.commentsToSend.length > 0) { @@ -320,6 +328,16 @@ export function useSendPassthroughMessage({ throw error; } }, - [taskId, sessionId, toast, pendingComments, panelState, storeApi, markCommentsSent, onSent], + [ + taskId, + sessionId, + toast, + pendingComments, + panelState, + snapshots, + queryClient, + markCommentsSent, + onSent, + ], ); } diff --git a/apps/web/components/task/passthrough-toolbar.test.tsx b/apps/web/components/task/passthrough-toolbar.test.tsx index 26c9d9f2de..448272cfc5 100644 --- a/apps/web/components/task/passthrough-toolbar.test.tsx +++ b/apps/web/components/task/passthrough-toolbar.test.tsx @@ -1,5 +1,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; // --- Constants declared before vi.mock so factories can reference them --- const TASK_ID = "task-1"; @@ -60,12 +61,10 @@ vi.mock("@/components/state-provider", () => ({ : {}, }, userSettings: { keyboardShortcuts: mockKeyboardShortcuts, chatSubmitKey: "enter" }, + workspaces: { activeId: "workspace-1" }, }), useAppStoreApi: () => ({ - getState: () => ({ - kanban: { steps: [] }, - kanbanMulti: { snapshots: {} }, - }), + getState: () => ({}), }), })); @@ -80,6 +79,10 @@ vi.mock("@/hooks/domains/kanban/use-plan-actions", () => ({ }), })); +vi.mock("@/hooks/domains/kanban/use-all-workflow-snapshots", () => ({ + useAllWorkflowSnapshots: () => ({ snapshots: {} }), +})); + vi.mock("@/hooks/domains/comments/use-diff-comments", () => ({ usePendingDiffCommentsByFile: () => mockPendingByFile, })); @@ -235,7 +238,12 @@ function makeDiffComment(id: string): import("@/lib/state/slices/comments").Diff } function renderToolbar() { - return render(); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); } async function openComposer() { diff --git a/apps/web/components/task/plan-tab.test.tsx b/apps/web/components/task/plan-tab.test.tsx index 75878e1045..b8a1d35a7d 100644 --- a/apps/web/components/task/plan-tab.test.tsx +++ b/apps/web/components/task/plan-tab.test.tsx @@ -1,7 +1,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { TaskPlan } from "@/lib/types/http"; +const mockHydrateTaskPlanLastSeen = vi.fn(); const mockMarkTaskPlanSeen = vi.fn(); let mockActiveTaskId: string | null = "task-1"; @@ -13,12 +18,12 @@ vi.mock("@/components/state-provider", () => ({ selector({ tasks: { activeTaskId: mockActiveTaskId }, taskPlans: { - byTaskId: mockActiveTaskId && mockPlan ? { [mockActiveTaskId]: mockPlan } : {}, lastSeenUpdatedAtByTaskId: mockActiveTaskId && mockLastSeen !== undefined ? { [mockActiveTaskId]: mockLastSeen } : {}, }, + hydrateTaskPlanLastSeen: mockHydrateTaskPlanLastSeen, markTaskPlanSeen: mockMarkTaskPlanSeen, }), })); @@ -73,6 +78,23 @@ function makeApi(isActive: boolean): TabApi { }; } +function renderPlanTab(isActive: boolean) { + const client = makeQueryClient(); + if (mockActiveTaskId) { + client.setQueryData(qk.taskPlan.detail(mockActiveTaskId), mockPlan); + } + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); + return { + client, + ...render( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + , + { wrapper }, + ), + }; +} + describe("PlanTab", () => { beforeEach(() => { vi.clearAllMocks(); @@ -82,68 +104,52 @@ describe("PlanTab", () => { }); it("renders the indicator when an agent-authored plan is unseen", () => { - const { container } = render( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - , - ); + const { container } = renderPlanTab(false); expect(container.querySelector('[data-testid="plan-tab-indicator"]')).toBeTruthy(); }); it("does not render the indicator when the plan is user-authored", () => { mockPlan = { ...agentPlan(), created_by: "user" }; - const { container } = render( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - , - ); + const { container } = renderPlanTab(false); expect(container.querySelector('[data-testid="plan-tab-indicator"]')).toBeNull(); }); it("does not render the indicator when lastSeen matches plan.updated_at", () => { mockLastSeen = TS; - const { container } = render( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - , - ); + const { container } = renderPlanTab(false); expect(container.querySelector('[data-testid="plan-tab-indicator"]')).toBeNull(); }); it("marks seen on initial render when api.isActive is true", () => { - render( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - , - ); - expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1"); + renderPlanTab(true); + expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1", TS); }); it("marks seen synchronously when an update lands while the tab is already active", () => { // Initial render with the tab active and plan already seen mockLastSeen = TS; - const { rerender } = render( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - , - ); + const { client, rerender } = renderPlanTab(true); mockMarkTaskPlanSeen.mockClear(); // Plan updates while the tab is still active — re-render with new updated_at. mockPlan = agentPlan(TS_LATER); + client.setQueryData(qk.taskPlan.detail("task-1"), mockPlan); rerender( // eslint-disable-next-line @typescript-eslint/no-explicit-any , ); // useLayoutEffect must have fired markTaskPlanSeen — keeps the dot from flashing. - expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1"); + expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1", TS_LATER); }); it("does not mark seen on update when the tab is not active", () => { mockLastSeen = TS; - const { rerender } = render( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - , - ); + const { client, rerender } = renderPlanTab(false); mockMarkTaskPlanSeen.mockClear(); mockPlan = agentPlan(TS_LATER); + client.setQueryData(qk.taskPlan.detail("task-1"), mockPlan); rerender( // eslint-disable-next-line @typescript-eslint/no-explicit-any , diff --git a/apps/web/components/task/plan-tab.tsx b/apps/web/components/task/plan-tab.tsx index 3ef1081220..acfd1ddcab 100644 --- a/apps/web/components/task/plan-tab.tsx +++ b/apps/web/components/task/plan-tab.tsx @@ -1,8 +1,10 @@ "use client"; import { useEffect, useLayoutEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { useAppStore } from "@/components/state-provider"; +import { taskPlanQueryOptions } from "@/lib/query/query-options"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; /** @@ -15,19 +17,28 @@ export function PlanTab(props: IDockviewPanelHeaderProps) { const onDoubleClick = useTabMaximizeOnDoubleClick(api); const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); - const plan = useAppStore((s) => (activeTaskId ? s.taskPlans.byTaskId[activeTaskId] : null)); + const planQuery = useQuery({ + ...taskPlanQueryOptions(activeTaskId ?? "", false), + enabled: false, + }); + const plan = planQuery.data ?? null; + const hydrateTaskPlanLastSeen = useAppStore((s) => s.hydrateTaskPlanLastSeen); const lastSeen = useAppStore((s) => activeTaskId ? s.taskPlans.lastSeenUpdatedAtByTaskId[activeTaskId] : undefined, ); const markTaskPlanSeen = useAppStore((s) => s.markTaskPlanSeen); + useEffect(() => { + if (activeTaskId) hydrateTaskPlanLastSeen(activeTaskId); + }, [activeTaskId, hydrateTaskPlanLastSeen]); + // Clear the indicator when the tab becomes active. useEffect(() => { const disposable = api.onDidActiveChange((event) => { - if (event.isActive && activeTaskId) markTaskPlanSeen(activeTaskId); + if (event.isActive && activeTaskId) markTaskPlanSeen(activeTaskId, plan?.updated_at); }); return () => disposable.dispose(); - }, [api, activeTaskId, markTaskPlanSeen]); + }, [api, activeTaskId, markTaskPlanSeen, plan]); // If the tab is already active when the plan changes (user is viewing it), // treat updates as immediately seen. Use useLayoutEffect so the seen-mark @@ -35,7 +46,7 @@ export function PlanTab(props: IDockviewPanelHeaderProps) { // the WS update render and the seen-mark render. const planUpdatedAt = plan?.updated_at; useLayoutEffect(() => { - if (api.isActive && activeTaskId) markTaskPlanSeen(activeTaskId); + if (api.isActive && activeTaskId) markTaskPlanSeen(activeTaskId, planUpdatedAt); }, [api, activeTaskId, markTaskPlanSeen, planUpdatedAt]); const hasUnseen = plan?.created_by === "agent" && lastSeen !== plan.updated_at; diff --git a/apps/web/components/task/preview-session-tabs.tsx b/apps/web/components/task/preview-session-tabs.tsx index bd6b02234e..753875ff8c 100644 --- a/apps/web/components/task/preview-session-tabs.tsx +++ b/apps/web/components/task/preview-session-tabs.tsx @@ -5,9 +5,9 @@ import { AgentLogo } from "@/components/agent-logo"; import { GridSpinner } from "@/components/grid-spinner"; import { PanelLoadingState } from "@/components/panel-loading-state"; import { SessionTabs, type SessionTab } from "@/components/session-tabs"; -import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { useSessionResumption } from "@/hooks/domains/session/use-session-resumption"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session"; import type { AgentProfileOption } from "@/lib/state/slices"; @@ -48,7 +48,7 @@ export function PreviewSessionTabs({ onSessionChange, }: PreviewSessionTabsProps) { const { sessions, isLoaded } = useTaskSessions(taskId); - const agentProfiles = useAppStore((state) => state.agentProfiles.items); + const { agentProfiles } = useSettingsData(true); const sortedSessions = useMemo(() => sortSessions(sessions), [sessions]); const agentLabelsById = useMemo(() => buildAgentLabelsById(agentProfiles), [agentProfiles]); diff --git a/apps/web/components/task/recent-task-switcher-hooks.ts b/apps/web/components/task/recent-task-switcher-hooks.ts index b0a30ff1f2..1ff31d2617 100644 --- a/apps/web/components/task/recent-task-switcher-hooks.ts +++ b/apps/web/components/task/recent-task-switcher-hooks.ts @@ -11,6 +11,9 @@ import { } from "react"; import { useRouter } from "@/lib/routing/client-router"; import { useAppStore } from "@/components/state-provider"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { useRepositoriesByWorkspace } from "@/hooks/domains/workspace/use-repository-cache"; +import { useAllCachedWorkflows } from "@/hooks/use-workflow-cache"; import { useCommandPanelOpen } from "@/lib/commands/command-registry"; import { useRegisterCommands } from "@/hooks/use-register-commands"; import type { KeyboardShortcut } from "@/lib/keyboard/constants"; @@ -106,12 +109,9 @@ function useRecentTaskEntries() { function useRecentTaskBuildContext(): RecentTaskBuildContext { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); - const kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId); - const kanbanTasks = useAppStore((state) => state.kanban.tasks); - const kanbanSteps = useAppStore((state) => state.kanban.steps); - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); - const workflows = useAppStore((state) => state.workflows.items); - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const { snapshots } = useAllWorkflowSnapshots(activeWorkspaceId); + const workflows = useAllCachedWorkflows(); + const repositoriesByWorkspace = useRepositoriesByWorkspace(); const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId); const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId); const environmentIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); @@ -120,9 +120,6 @@ function useRecentTaskBuildContext(): RecentTaskBuildContext { () => ({ activeTaskId, activeWorkspaceId, - kanbanWorkflowId, - kanbanTasks, - kanbanSteps, snapshots, workflows, repositoriesByWorkspace, @@ -133,9 +130,6 @@ function useRecentTaskBuildContext(): RecentTaskBuildContext { [ activeTaskId, activeWorkspaceId, - kanbanWorkflowId, - kanbanTasks, - kanbanSteps, snapshots, workflows, repositoriesByWorkspace, diff --git a/apps/web/components/task/recent-task-switcher-model.test.ts b/apps/web/components/task/recent-task-switcher-model.test.ts index c2d4a472df..6c467fcf55 100644 --- a/apps/web/components/task/recent-task-switcher-model.test.ts +++ b/apps/web/components/task/recent-task-switcher-model.test.ts @@ -77,22 +77,25 @@ function buildContext(): RecentTaskBuildContext { return { activeTaskId: CURRENT_TASK_ID, - kanbanWorkflowId: WF_MAIN, - kanbanTasks: [ - { - id: CURRENT_TASK_ID, - workflowStepId: "step-1", - title: "Current live title", - position: 0, - state: "IN_PROGRESS", - repositoryId: REPOSITORY_ID, - primarySessionId: `${CURRENT_TASK_ID}-session`, - createdAt: visitedAt, - updatedAt: visitedAt, - }, - ], - kanbanSteps: [{ id: "step-1", title: "Working", color: "bg-blue-500", position: 0 }], snapshots: { + [WF_MAIN]: { + workflowId: WF_MAIN, + workflowName: "Main Flow", + steps: [{ id: "step-1", title: "Working", color: "bg-blue-500", position: 0 }], + tasks: [ + { + id: CURRENT_TASK_ID, + workflowStepId: "step-1", + title: "Current live title", + position: 0, + state: "IN_PROGRESS", + repositoryId: REPOSITORY_ID, + primarySessionId: `${CURRENT_TASK_ID}-session`, + createdAt: visitedAt, + updatedAt: visitedAt, + }, + ], + }, [WF_REVIEW]: { workflowId: WF_REVIEW, workflowName: "Review Flow", @@ -119,7 +122,7 @@ function buildContext(): RecentTaskBuildContext { sessionsByTaskId: { [CURRENT_TASK_ID]: [session(CURRENT_TASK_ID, "RUNNING")] }, gitStatusByEnvId: {}, environmentIdBySessionId: {}, - }; + } as RecentTaskBuildContext; } describe("recent task switcher model", () => { diff --git a/apps/web/components/task/recent-task-switcher-model.ts b/apps/web/components/task/recent-task-switcher-model.ts index e8adf8844b..490241222d 100644 --- a/apps/web/components/task/recent-task-switcher-model.ts +++ b/apps/web/components/task/recent-task-switcher-model.ts @@ -1,17 +1,14 @@ import type { RecentTaskEntry } from "@/lib/recent-tasks"; -import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import type { Repository, TaskSession, TaskSessionState, TaskState } from "@/lib/types/http"; import { getRepositoryDisplayName } from "@/lib/utils"; import { getSessionInfoForTask } from "@/lib/utils/session-info"; -type KanbanTask = KanbanState["tasks"][number]; +type SnapshotTask = WorkflowSnapshotData["tasks"][number]; export type RecentTaskBuildContext = { activeTaskId: string | null; activeWorkspaceId?: string | null; - kanbanWorkflowId: string | null; - kanbanTasks: KanbanTask[]; - kanbanSteps: KanbanState["steps"]; snapshots: Record; workflows: Array<{ id: string; workspaceId: string; name: string }>; repositoriesByWorkspace: Record; @@ -47,7 +44,7 @@ export type RecentTaskDisplayItem = { }; type LiveTask = { - task: KanbanTask; + task: SnapshotTask; workflowId?: string; workflowName?: string; }; @@ -59,7 +56,7 @@ type RecentTaskDisplayMaps = { type DisplayResolution = { live: LiveTask | null; - task: KanbanTask | undefined; + task: SnapshotTask | undefined; taskState: TaskState | undefined; sessionState: TaskSessionState | undefined; workflowId: string | undefined; @@ -73,19 +70,14 @@ function getWorkflowName(workflowId: string | undefined, ctx: RecentTaskBuildCon } function findLiveTask(taskId: string, ctx: RecentTaskBuildContext): LiveTask | null { - const kanbanTask = ctx.kanbanTasks.find((task) => task.id === taskId); - if (kanbanTask) { - return { - task: kanbanTask, - workflowId: ctx.kanbanWorkflowId ?? undefined, - workflowName: getWorkflowName(ctx.kanbanWorkflowId ?? undefined, ctx), - }; - } - for (const snapshot of Object.values(ctx.snapshots)) { const task = snapshot.tasks.find((item) => item.id === taskId); if (task) { - return { task, workflowId: snapshot.workflowId, workflowName: snapshot.workflowName }; + return { + task, + workflowId: snapshot.workflowId, + workflowName: snapshot.workflowName || getWorkflowName(snapshot.workflowId, ctx), + }; } } return null; @@ -93,7 +85,6 @@ function findLiveTask(taskId: string, ctx: RecentTaskBuildContext): LiveTask | n function buildStepTitleMap(ctx: RecentTaskBuildContext): Map { const map = new Map(); - for (const step of ctx.kanbanSteps) map.set(step.id, step.title); for (const snapshot of Object.values(ctx.snapshots)) { for (const step of snapshot.steps) map.set(step.id, step.title); } @@ -120,7 +111,7 @@ function formatRepository(repository: Repository | undefined): string | undefine function getResolvedSessionState( taskId: string, - liveTask: KanbanTask | undefined, + liveTask: SnapshotTask | undefined, entry: RecentTaskEntry, ctx: RecentTaskBuildContext, ): TaskSessionState | undefined { @@ -187,7 +178,7 @@ function resolveDisplay(entry: RecentTaskEntry, ctx: RecentTaskBuildContext): Di function resolveRepositoryPath( entry: RecentTaskEntry, - task: KanbanTask | undefined, + task: SnapshotTask | undefined, maps: RecentTaskDisplayMaps, ): string | undefined { return ( @@ -212,7 +203,7 @@ function resolveWorkflowName( function resolveWorkflowStepTitle( entry: RecentTaskEntry, - task: KanbanTask | undefined, + task: SnapshotTask | undefined, maps: RecentTaskDisplayMaps, ): string | undefined { return maps.stepTitleById.get(task?.workflowStepId ?? "") ?? entry.workflowStepTitle ?? undefined; diff --git a/apps/web/components/task/repository-scripts-menu.tsx b/apps/web/components/task/repository-scripts-menu.tsx index 4b60acbdb3..d2f308dab4 100644 --- a/apps/web/components/task/repository-scripts-menu.tsx +++ b/apps/web/components/task/repository-scripts-menu.tsx @@ -7,6 +7,7 @@ import { DropdownMenuSeparator, } from "@kandev/ui/dropdown-menu"; import { useAppStore } from "@/components/state-provider"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-scripts"; /** @@ -15,15 +16,13 @@ import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-s * truthiness; callers that need the command itself can use it directly. */ export function useActiveSessionDevScript(): string { - return useAppStore((state) => { + const repositoryId = useAppStore((state) => { const sessionId = state.tasks.activeSessionId; - if (!sessionId) return ""; - const repoId = state.taskSessions.items[sessionId]?.repository_id; - if (!repoId) return ""; - const allRepos = Object.values(state.repositories.itemsByWorkspaceId).flat(); - const repo = allRepos.find((r) => r.id === repoId); - return repo?.dev_script?.trim() ?? ""; + return sessionId ? (state.taskSessions.items[sessionId]?.repository_id ?? null) : null; }); + const repositories = useAllCachedRepositories(); + const repository = repositories.find((item) => item.id === repositoryId); + return repository?.dev_script?.trim() ?? ""; } /** diff --git a/apps/web/components/task/selector-trigger-class.test.tsx b/apps/web/components/task/selector-trigger-class.test.tsx index c3af19b027..702cdbab27 100644 --- a/apps/web/components/task/selector-trigger-class.test.tsx +++ b/apps/web/components/task/selector-trigger-class.test.tsx @@ -1,9 +1,13 @@ -import { render, screen } from "@testing-library/react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@kandev/ui/tooltip"; -import { describe, expect, it, vi } from "vitest"; +import type { ReactElement } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ModelSelector } from "@/components/task/model-selector"; import { ModeSelector } from "@/components/task/mode-selector"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; const mocks = vi.hoisted(() => { const appState = { @@ -59,7 +63,18 @@ vi.mock("@/hooks/domains/settings/use-available-agents", () => ({ })); vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ - useSettingsData: vi.fn(), + useSettingsData: () => ({ + settingsAgents: [], + agentProfiles: [], + executors: [], + availableAgents: [], + availableTools: [], + settingsData: { + agentsLoaded: true, + capabilitiesLoaded: true, + executorsLoaded: true, + }, + }), })); vi.mock("@/lib/api/domains/session-api", () => ({ @@ -68,9 +83,26 @@ vi.mock("@/lib/api/domains/session-api", () => ({ setSessionModel: vi.fn(), })); +function renderWithQuery(ui: ReactElement) { + const queryClient = makeQueryClient(); + queryClient.setQueryData( + qk.sessionRuntime.models("session-1"), + mocks.appState.sessionModels.bySessionId["session-1"], + ); + queryClient.setQueryData( + qk.sessionRuntime.mode("session-1"), + mocks.appState.sessionMode.bySessionId["session-1"], + ); + return render({ui}); +} + describe("task selector trigger styling", () => { + afterEach(() => { + cleanup(); + }); + it("forwards custom trigger classes to the model selector trigger", () => { - render(); + renderWithQuery(); expect(screen.getByRole("button", { name: "Session model settings" }).className).toContain( "max-w-model", @@ -78,7 +110,7 @@ describe("task selector trigger styling", () => { }); it("forwards custom trigger classes to the mode selector trigger", () => { - render( + renderWithQuery( , diff --git a/apps/web/components/task/session-reopen-menu.test.tsx b/apps/web/components/task/session-reopen-menu.test.tsx new file mode 100644 index 0000000000..2cda8fdaa8 --- /dev/null +++ b/apps/web/components/task/session-reopen-menu.test.tsx @@ -0,0 +1,117 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { + sessionId, + taskId, + workflowId, + workspaceId, + type Task, + type TaskSession, +} from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type MockState = { + kanban: { tasks: KanbanState["tasks"] }; +}; + +const mockUseTaskSessions = vi.fn(); +let mockState: MockState; +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +vi.mock("@tabler/icons-react", () => ({ + IconMessagePlus: () => , + IconStar: () => , +})); + +vi.mock("@kandev/ui/dropdown-menu", () => ({ + DropdownMenuItem: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuLabel: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/hooks/use-task-sessions", () => ({ + useTaskSessions: (...args: unknown[]) => mockUseTaskSessions(...args), +})); + +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: () => ({ agentProfiles: [] }), +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + useDockviewStore: (selector: (state: { api: null; centerGroupId: string }) => unknown) => + selector({ api: null, centerGroupId: "center" }), +})); + +vi.mock("@/components/agent-logo", () => ({ + AgentLogo: () => null, +})); + +vi.mock("@/lib/ui/state-icons", () => ({ + getSessionStateIcon: () => , +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), +})); + +import { SessionReopenMenuItems } from "./session-reopen-menu"; + +function makeTask(): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-1", + position: 1, + title: "Query task", + description: "", + state: "CREATED", + priority: 0, + primary_session_id: sessionId("session-primary"), + repositories: [], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function makeSession(id: string): TaskSession { + return { + id: sessionId(id), + task_id: taskId("task-1"), + state: "COMPLETED", + started_at: id === "session-primary" ? "2026-01-01T00:01:00Z" : TIMESTAMP, + updated_at: TIMESTAMP, + is_primary: false, + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("SessionReopenMenuItems", () => { + beforeEach(() => { + mockState = { kanban: { tasks: [] } }; + mockUseTaskSessions.mockReturnValue({ + sessions: [makeSession("session-other"), makeSession("session-primary")], + }); + }); + + it("marks the primary session from task detail Query when kanban tasks are empty", () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + + render(, { wrapper: wrapper(client) }); + + expect(screen.getAllByTestId("primary-star")).toHaveLength(1); + }); +}); diff --git a/apps/web/components/task/session-reopen-menu.tsx b/apps/web/components/task/session-reopen-menu.tsx index 462077d245..60d0486085 100644 --- a/apps/web/components/task/session-reopen-menu.tsx +++ b/apps/web/components/task/session-reopen-menu.tsx @@ -7,13 +7,14 @@ import { DropdownMenuLabel, DropdownMenuSeparator, } from "@kandev/ui/dropdown-menu"; -import { useAppStore } from "@/components/state-provider"; import { useDockviewStore } from "@/lib/state/dockview-store"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { addSessionPanel } from "@/lib/state/dockview-panel-actions"; import { getSessionStateIcon } from "@/lib/ui/state-icons"; import { AgentLogo } from "@/components/agent-logo"; import { markSessionTabUserActivationIntent } from "@/components/task/session-tab-activation-intent"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; import type { TaskSession } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; @@ -50,11 +51,9 @@ export function SessionReopenMenuItems({ const { sessions } = useTaskSessions(taskId); const api = useDockviewStore((s) => s.api); const centerGroupId = useDockviewStore((s) => s.centerGroupId); - const agentProfiles = useAppStore((s) => s.agentProfiles.items); - const primarySessionId = useAppStore((s) => { - const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return task?.primarySessionId ?? null; - }); + const { agentProfiles } = useSettingsData(true); + const task = useTaskById(taskId); + const primarySessionId = task?.primarySessionId ?? null; const profilesById = useMemo( () => Object.fromEntries(agentProfiles.map((p: AgentProfileOption) => [p.id, p])), diff --git a/apps/web/components/task/session-tab.test.tsx b/apps/web/components/task/session-tab.test.tsx new file mode 100644 index 0000000000..61702f4354 --- /dev/null +++ b/apps/web/components/task/session-tab.test.tsx @@ -0,0 +1,182 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { + sessionId, + taskId, + workflowId, + workspaceId, + type Task, + type TaskSession, +} from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type MockState = { + tasks: { activeTaskId: string | null }; + kanban: { tasks: KanbanState["tasks"] }; + taskSessions: { items: Record }; + taskSessionsByTask: { itemsByTaskId: Record }; + sessionModels: { bySessionId: Record }; + activeModel: { bySessionId: Record }; +}; + +let mockState: MockState; + +vi.mock("@tabler/icons-react", () => ({ + IconStar: () => , +})); + +vi.mock("dockview-react", () => ({ + DockviewDefaultTab: () => , +})); + +vi.mock("@kandev/ui/context-menu", () => ({ + ContextMenu: ({ children }: { children: ReactNode }) =>
{children}
, + ContextMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + ContextMenuItem: ({ children }: { children: ReactNode }) =>
{children}
, + ContextMenuSeparator: () =>
, + ContextMenuTrigger: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("@kandev/ui/alert-dialog", () => ({ + AlertDialog: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogAction: ({ children }: { children: ReactNode }) => , + AlertDialogCancel: ({ children }: { children: ReactNode }) => , + AlertDialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogDescription: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, + AlertDialogTitle: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/hooks/domains/session/use-session-actions", () => ({ + useSessionActions: () => ({ + setPrimary: vi.fn(), + stop: vi.fn(), + resume: vi.fn(), + remove: vi.fn(), + }), + isSessionStoppable: () => false, + isSessionDeletable: () => false, + isSessionResumable: () => false, +})); + +vi.mock("@/components/task/share/share-button", () => ({ + shareableSessionStateClient: () => false, +})); + +vi.mock("@/components/task/share/share-dialog", () => ({ + ShareDialog: () => null, +})); + +vi.mock("@/components/task/handoff-profile-menu-items", () => ({ + HandoffContextMenuSub: () => null, +})); + +vi.mock("@/components/task/new-session-dialog", () => ({ + NewSessionDialog: () => null, +})); + +vi.mock("@/components/model-config-selector", () => ({ + usableConfigOptions: () => [], +})); + +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: () => ({ agentProfiles: [] }), +})); + +vi.mock("@/components/agent-logo", () => ({ + AgentLogo: () => null, +})); + +vi.mock("@/components/grid-spinner", () => ({ + GridSpinner: () => null, +})); + +vi.mock("@/components/task/use-tab-maximize", () => ({ + useTabMaximizeOnDoubleClick: () => vi.fn(), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), +})); + +import { SessionTab } from "./session-tab"; + +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +function makeTask(): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-1", + position: 1, + title: "Query task", + description: "", + state: "CREATED", + priority: 0, + primary_session_id: sessionId("session-primary"), + repositories: [], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function makeSession(id: string): TaskSession { + return { + id: sessionId(id), + task_id: taskId("task-1"), + state: "COMPLETED", + started_at: id === "session-primary" ? TIMESTAMP : "2026-01-01T00:01:00Z", + updated_at: TIMESTAMP, + is_primary: false, + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("SessionTab", () => { + beforeEach(() => { + const primary = makeSession("session-primary"); + const other = makeSession("session-other"); + mockState = { + tasks: { activeTaskId: "task-1" }, + kanban: { tasks: [] }, + taskSessions: { items: { "session-primary": primary, "session-other": other } }, + taskSessionsByTask: { itemsByTaskId: { "task-1": [primary, other] } }, + sessionModels: { bySessionId: {} }, + activeModel: { bySessionId: {} }, + }; + }); + + it("marks the primary session from task detail Query when kanban tasks are empty", () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + const props = { + api: { + id: "session:session-primary", + title: "", + isActive: true, + group: { id: "group-1" }, + onDidActiveChange: () => ({ dispose: vi.fn() }), + setTitle: vi.fn(), + }, + containerApi: { getPanel: vi.fn(), removePanel: vi.fn() }, + } as unknown as Parameters[0]; + + render(, { wrapper: wrapper(client) }); + + expect(screen.getAllByTestId("primary-star")).toHaveLength(1); + }); +}); diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index 469195583e..b06226419c 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -3,11 +3,13 @@ import { useCallback, useEffect, + useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, } from "react"; +import { useQuery } from "@tanstack/react-query"; import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { IconStar } from "@tabler/icons-react"; import { AgentLogo } from "@/components/agent-logo"; @@ -41,6 +43,9 @@ import { ShareDialog } from "@/components/task/share/share-dialog"; import { HandoffContextMenuSub } from "@/components/task/handoff-profile-menu-items"; import { NewSessionDialog, type HandoffPreset } from "@/components/task/new-session-dialog"; import { usableConfigOptions } from "@/components/model-config-selector"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { sessionModelsQueryOptions } from "@/lib/query/query-options"; import type { TaskSessionState } from "@/lib/types/http"; import { markSessionTabUserActivationIntent, @@ -50,36 +55,51 @@ import { isSessionActive } from "./session-sort"; import { resolveSessionTabTitle } from "./session-tab-title"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; +function resolveIsPrimarySession( + sessionId: string | undefined, + primarySessionId: string | null | undefined, + fallbackIsPrimary: boolean, +): boolean { + if (!sessionId) return false; + if (primarySessionId) return primarySessionId === sessionId; + return fallbackIsPrimary; +} + function useSessionTabState(sessionId: string | undefined) { - const isPrimary = useAppStore((state) => { - const activeTaskId = state.tasks.activeTaskId; - if (!activeTaskId || !sessionId) return false; - const task = state.kanban.tasks.find((t: { id: string }) => t.id === activeTaskId); - if (task?.primarySessionId) return task.primarySessionId === sessionId; - return state.taskSessions.items[sessionId]?.is_primary === true; - }); + const sessionModelsQuery = useQuery(sessionModelsQueryOptions(sessionId ?? "")); + const taskId = useAppStore((state) => state.tasks.activeTaskId); + const task = useTaskById(taskId); + const fallbackIsPrimary = useAppStore((state) => + sessionId ? state.taskSessions.items[sessionId]?.is_primary === true : false, + ); + const isPrimary = resolveIsPrimarySession(sessionId, task?.primarySessionId, fallbackIsPrimary); const sessionState = useAppStore((state) => { if (!sessionId) return null; return state.taskSessions.items[sessionId]?.state ?? null; }) as TaskSessionState | null; - const taskId = useAppStore((state) => state.tasks.activeTaskId); - const tabTitle = useAppStore((state) => { + const sessionForTitle = useAppStore((state) => { if (!sessionId) return null; - const session = state.taskSessions.items[sessionId]; - const sessionModels = state.sessionModels.bySessionId[sessionId]; - const activeModelId = state.activeModel.bySessionId[sessionId] || null; - const agentLabel = (() => { - if (!session?.agent_profile_id) return null; - const profile = state.agentProfiles.items.find( - (p: { id: string }) => p.id === session.agent_profile_id, - ); - if (!profile) return null; - const parts = profile.label.split(" \u2022 "); - return parts[1] || parts[0] || profile.label; - })(); + return state.taskSessions.items[sessionId] ?? null; + }); + const storeSessionModels = useAppStore((state) => + sessionId ? state.sessionModels.bySessionId[sessionId] : undefined, + ); + const sessionModels = sessionModelsQuery.data ?? storeSessionModels; + const activeModelId = useAppStore((state) => + sessionId ? state.activeModel.bySessionId[sessionId] || null : null, + ); + const { agentProfiles } = useSettingsData(Boolean(sessionForTitle?.agent_profile_id)); + const agentLabel = useMemo(() => { + if (!sessionForTitle?.agent_profile_id) return null; + const profile = agentProfiles.find((p) => p.id === sessionForTitle.agent_profile_id); + if (!profile) return null; + const parts = profile.label.split(" \u2022 "); + return parts[1] || parts[0] || profile.label; + }, [agentProfiles, sessionForTitle?.agent_profile_id]); + const tabTitle = useMemo(() => { const snapshotModel = - typeof session?.agent_profile_snapshot?.model === "string" - ? session.agent_profile_snapshot.model + typeof sessionForTitle?.agent_profile_snapshot?.model === "string" + ? sessionForTitle.agent_profile_snapshot.model : null; return resolveSessionTabTitle({ agentLabel, @@ -95,16 +115,14 @@ function useSessionTabState(sessionId: string | undefined) { })) ?? [], configOptions: usableConfigOptions(sessionModels?.configOptions), }); - }); - const agentName = useAppStore((state) => { - if (!sessionId) return null; - const session = state.taskSessions.items[sessionId]; - if (!session?.agent_profile_id) return null; + }, [activeModelId, agentLabel, sessionForTitle?.agent_profile_snapshot, sessionModels]); + const agentName = useMemo(() => { + if (!sessionForTitle?.agent_profile_id) return null; return ( - state.agentProfiles.items.find((p: { id: string }) => p.id === session.agent_profile_id) + agentProfiles.find((profile) => profile.id === sessionForTitle.agent_profile_id) ?.agent_name ?? null ); - }); + }, [agentProfiles, sessionForTitle?.agent_profile_id]); const sessionNumber = useAppStore((state) => { if (!sessionId) return null; const activeTaskId = state.tasks.activeTaskId; diff --git a/apps/web/components/task/sessions-dropdown.test.tsx b/apps/web/components/task/sessions-dropdown.test.tsx new file mode 100644 index 0000000000..b9393c2a20 --- /dev/null +++ b/apps/web/components/task/sessions-dropdown.test.tsx @@ -0,0 +1,143 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { + sessionId, + taskId, + workflowId, + workspaceId, + type Task, + type TaskSession, +} from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type MockState = { + tasks: { activeTaskId: string | null; activeSessionId: string | null }; + kanban: { tasks: KanbanState["tasks"] }; + taskSessions: { items: Record }; + environmentIdBySessionId: Record; + setActiveSession: ReturnType; +}; + +const mockUseTaskSessions = vi.fn(); +let mockState: MockState; + +vi.mock("@tabler/icons-react", () => ({ + IconStack2: () => , + IconPlus: () => , + IconStar: () => , + IconPlayerPlayFilled: () => , + IconTrash: () => , +})); + +vi.mock("@kandev/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuContent: ({ children }: { children: ReactNode }) =>
{children}
, + DropdownMenuSeparator: () =>
, + DropdownMenuTrigger: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("@kandev/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipContent: ({ children }: { children: ReactNode }) =>
{children}
, + TooltipTrigger: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), + useAppStoreApi: () => ({ getState: () => mockState }), +})); + +vi.mock("@/hooks/use-task-sessions", () => ({ + useTaskSessions: (...args: unknown[]) => mockUseTaskSessions(...args), +})); + +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: () => ({ agentProfiles: [] }), +})); + +vi.mock("@/lib/state/dockview-store", () => ({ + performLayoutSwitch: vi.fn(), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => null, +})); + +vi.mock("@/lib/ui/state-icons", () => ({ + getSessionStateIcon: () => , +})); + +vi.mock("@/components/task-create-dialog", () => ({ + TaskCreateDialog: () => null, +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), +})); + +import { SessionsDropdown } from "./sessions-dropdown"; + +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +function makeTask(): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-1", + position: 1, + title: "Query task", + description: "", + state: "CREATED", + priority: 0, + primary_session_id: sessionId("session-primary"), + repositories: [], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function makeSession(id: string): TaskSession { + return { + id: sessionId(id), + task_id: taskId("task-1"), + state: "COMPLETED", + started_at: TIMESTAMP, + updated_at: TIMESTAMP, + is_primary: false, + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("SessionsDropdown", () => { + beforeEach(() => { + mockState = { + tasks: { activeTaskId: "task-1", activeSessionId: null }, + kanban: { tasks: [] }, + taskSessions: { items: {} }, + environmentIdBySessionId: {}, + setActiveSession: vi.fn(), + }; + mockUseTaskSessions.mockReturnValue({ + sessions: [makeSession("session-other"), makeSession("session-primary")], + loadSessions: vi.fn(), + }); + }); + + it("marks the primary session from task detail Query when kanban tasks are empty", () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + + render(, { wrapper: wrapper(client) }); + + expect(screen.getAllByTestId("primary-star")).toHaveLength(1); + }); +}); diff --git a/apps/web/components/task/sessions-dropdown.tsx b/apps/web/components/task/sessions-dropdown.tsx index 34b0339493..b3e07c1097 100644 --- a/apps/web/components/task/sessions-dropdown.tsx +++ b/apps/web/components/task/sessions-dropdown.tsx @@ -20,12 +20,14 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { TaskCreateDialog } from "../task-create-dialog"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useTaskSessions } from "@/hooks/use-task-sessions"; import { performLayoutSwitch } from "@/lib/state/dockview-store"; import type { TaskSession, TaskSessionState } from "@/lib/types/http"; import { getSessionStateIcon } from "@/lib/ui/state-icons"; import { getWebSocketClient } from "@/lib/ws/connection"; import { buildAgentLabelsById, resolveAgentLabelFor, sortSessions } from "./session-sort"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; type SessionStatus = "running" | "waiting_input" | "complete" | "failed" | "cancelled"; @@ -173,7 +175,7 @@ function useSessionLifecycleActions( } function useSessionsDropdownState(taskId: string | null) { - const agentProfiles = useAppStore((state) => state.agentProfiles.items); + const { agentProfiles } = useSettingsData(Boolean(taskId)); const { sessions, loadSessions } = useTaskSessions(taskId); const currentTime = useRunningSessionsClock(sessions); @@ -195,13 +197,8 @@ export const SessionsDropdown = memo(function SessionsDropdown({ }: SessionsDropdownProps) { const [showNewSessionDialog, setShowNewSessionDialog] = useState(false); const [open, setOpen] = useState(false); - const storePrimarySessionId = useAppStore((state) => { - const activeTaskId = state.tasks.activeTaskId; - if (!activeTaskId) return null; - const task = state.kanban.tasks.find((t: { id: string }) => t.id === activeTaskId); - return task?.primarySessionId ?? null; - }); - const primarySessionId = primarySessionIdProp ?? storePrimarySessionId; + const task = useTaskById(taskId); + const primarySessionId = primarySessionIdProp ?? task?.primarySessionId ?? null; const { sortedSessions, currentTime, loadSessions, resolveAgentLabel } = useSessionsDropdownState(taskId); const { handleSelectSession } = useSessionSelectionHandlers(taskId); diff --git a/apps/web/components/task/sidebar-filter/use-filter-value-options.test.ts b/apps/web/components/task/sidebar-filter/use-filter-value-options.test.ts index 1b0735f803..b97784cee4 100644 --- a/apps/web/components/task/sidebar-filter/use-filter-value-options.test.ts +++ b/apps/web/components/task/sidebar-filter/use-filter-value-options.test.ts @@ -1,8 +1,19 @@ import { describe, it, expect } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; -import type { Repository } from "@/lib/types/http"; +import type { AppState } from "@/lib/state/store"; +import type { Repository, Workflow, WorkflowSnapshot } from "@/lib/types/http"; +import { taskId, workflowId, workspaceId } from "@/lib/types/ids"; import { repositorySlug } from "@/lib/repository-slug"; -import { repositoryOptions, workflowStepOptions } from "./use-filter-value-options"; +import { + repositoryOptions, + useFilterValueOptions, + workflowStepOptions, +} from "./use-filter-value-options"; function repo(overrides: Partial): Repository { return { @@ -111,3 +122,98 @@ describe("workflowStepOptions", () => { expect(options[0]?.group).toBe("Alpha"); }); }); + +const WORKSPACE_ID = workspaceId("workspace-1"); +const WORKFLOW_ID = workflowId("workflow-1"); +const STEP_ID = "query-step"; +const ISO_TIME = "2026-06-24T00:00:00Z"; + +function queryClientWithWorkflowSnapshot() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + const workflow: Workflow = { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Query Workflow", + sort_order: 0, + hidden: false, + created_at: ISO_TIME, + updated_at: ISO_TIME, + }; + const rawSnapshot: WorkflowSnapshot = { + workflow, + steps: [ + { + id: STEP_ID, + workflow_id: WORKFLOW_ID, + name: "Query Step", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks: [ + { + id: taskId("task-1"), + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: STEP_ID, + position: 0, + title: "Query task", + description: "", + state: "TODO", + priority: 0, + primary_executor_type: "local_docker", + repositories: [], + created_at: ISO_TIME, + updated_at: ISO_TIME, + }, + ], + }; + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [workflow]); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), rawSnapshot); + return queryClient; +} + +function wrapperFor(queryClient: QueryClient) { + const initialState = { + workspaces: { activeId: WORKSPACE_ID }, + kanbanMulti: { snapshots: {} }, + } as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return createElement( + QueryClientProvider, + { client: queryClient }, + createElement(StateProvider, { initialState, children }), + ); + }; +} + +describe("useFilterValueOptions", () => { + it("uses workflow snapshot Query caches when the legacy kanbanMulti mirror is empty", () => { + const queryClient = queryClientWithWorkflowSnapshot(); + + const workflow = renderHook(() => useFilterValueOptions("workflow"), { + wrapper: wrapperFor(queryClient), + }); + const workflowStep = renderHook(() => useFilterValueOptions("workflowStep"), { + wrapper: wrapperFor(queryClient), + }); + const executorType = renderHook(() => useFilterValueOptions("executorType"), { + wrapper: wrapperFor(queryClient), + }); + + expect(workflow.result.current).toEqual([{ value: WORKFLOW_ID, label: "Query Workflow" }]); + expect(workflowStep.result.current).toEqual([ + { + value: STEP_ID, + label: "Query Step", + color: "bg-blue-500", + group: "Query Workflow", + }, + ]); + expect(executorType.result.current).toEqual([{ value: "local_docker", label: "Local Docker" }]); + }); +}); diff --git a/apps/web/components/task/sidebar-filter/use-filter-value-options.ts b/apps/web/components/task/sidebar-filter/use-filter-value-options.ts index 2cceddf631..25be075616 100644 --- a/apps/web/components/task/sidebar-filter/use-filter-value-options.ts +++ b/apps/web/components/task/sidebar-filter/use-filter-value-options.ts @@ -2,14 +2,17 @@ import { useMemo } from "react"; import { useAppStore } from "@/components/state-provider"; -import type { AppState } from "@/lib/state/store"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { useRepositoriesByWorkspace } from "@/hooks/domains/workspace/use-repository-cache"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import type { FilterDimension } from "@/lib/state/slices/ui/sidebar-view-types"; import { getExecutorLabel } from "@/lib/executor-icons"; import { repositorySlug } from "@/lib/repository-slug"; +import type { Repository } from "@/lib/types/http"; type Option = { value: string; label: string; color?: string; group?: string }; -type Snapshots = AppState["kanbanMulti"]["snapshots"]; -type ReposByWorkspace = AppState["repositories"]["itemsByWorkspaceId"]; +type Snapshots = Record; +type ReposByWorkspace = Record; function workflowOptions(snapshots: Snapshots): Option[] { return Object.entries(snapshots).map(([id, snap]) => ({ @@ -55,8 +58,9 @@ export function repositoryOptions(repositoriesByWorkspace: ReposByWorkspace): Op } export function useFilterValueOptions(dimension: FilterDimension): Option[] { - const snapshots = useAppStore((s) => s.kanbanMulti.snapshots); - const repositoriesByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const { snapshots } = useAllWorkflowSnapshots(activeWorkspaceId); + const repositoriesByWorkspace = useRepositoriesByWorkspace(); return useMemo(() => { if (dimension === "workflow") return workflowOptions(snapshots); diff --git a/apps/web/components/task/simple/chat-activity-tabs.test.tsx b/apps/web/components/task/simple/chat-activity-tabs.test.tsx index 60fd9430e3..1595283fc8 100644 --- a/apps/web/components/task/simple/chat-activity-tabs.test.tsx +++ b/apps/web/components/task/simple/chat-activity-tabs.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, cleanup } from "@testing-library/react"; import type { ReactNode } from "react"; import { StateProvider } from "@/components/state-provider"; @@ -33,8 +34,21 @@ const T_10 = "2026-05-01T10:00:00Z"; const T_11 = "2026-05-01T11:00:00Z"; const T_12 = "2026-05-01T12:00:00Z"; +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + function wrap(node: ReactNode) { - return {node}; + return ( + + {node} + + ); } function makeTask(partial: Partial = {}): Task { diff --git a/apps/web/components/task/simple/chat-activity-tabs.tsx b/apps/web/components/task/simple/chat-activity-tabs.tsx index 3bfbf2d601..0775d9f68f 100644 --- a/apps/web/components/task/simple/chat-activity-tabs.tsx +++ b/apps/web/components/task/simple/chat-activity-tabs.tsx @@ -2,7 +2,6 @@ import { useMemo } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@kandev/ui/tabs"; -import { useAppStore } from "@/components/state-provider"; import { agentTint } from "@/app/office/components/agent-avatar"; import { TaskChat } from "./task-chat"; import { TaskActivity } from "./task-activity"; @@ -21,6 +20,7 @@ import type { TaskSession, TimelineEvent, } from "@/app/office/tasks/[id]/types"; +import { useActiveOfficeAgents } from "./use-office-reference-data"; // TAB_TRIGGER_BASE adds a 1px ring to the active tab on top of // shadcn's default bg/text active styling, so the selected tab is @@ -32,13 +32,12 @@ const TAB_TRIGGER_BASE = function AgentTabTrigger({ group }: { group: SessionGroup }) { const live = isGroupLive(group); const agentProfileId = group.representative.agentProfileId; - // Resolve the agent's display name + role from the office store so a + // Resolve the agent's display name + role from the office query so a // rename flows through automatically and we never fall back to the // UUID that lands in `session.agentName` when the session's profile // snapshot is empty. - const resolved = useAppStore((s) => - agentProfileId ? s.office.agentProfiles.find((a) => a.id === agentProfileId) : undefined, - ); + const agents = useActiveOfficeAgents(); + const resolved = agentProfileId ? agents.find((a) => a.id === agentProfileId) : undefined; const label = resolved?.name || group.representative.agentName || "Agent"; // Apply the per-agent tint only when the tab is active, so the // selected state reads clearly. Inactive tabs inherit shadcn's muted diff --git a/apps/web/components/task/simple/components/agents-multi-picker.test.tsx b/apps/web/components/task/simple/components/agents-multi-picker.test.tsx index fd226f7815..8adcc82338 100644 --- a/apps/web/components/task/simple/components/agents-multi-picker.test.tsx +++ b/apps/web/components/task/simple/components/agents-multi-picker.test.tsx @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { cleanup, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { useEffect } from "react"; @@ -7,6 +8,7 @@ import { StateProvider, useAppStore } from "@/components/state-provider"; import { TaskOptimisticContextProvider } from "@/hooks/use-optimistic-task-mutation"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import { agentProfileId as toAgentProfileId, workspaceId as toWorkspaceId } from "@/lib/types/ids"; +import { qk } from "@/lib/query/keys"; import { ApproversPicker } from "./approvers-picker"; import { buildDecisionLookup } from "./agents-multi-picker"; import type { Task, TaskDecision } from "@/app/office/tasks/[id]/types"; @@ -45,9 +47,18 @@ function makeAgent(id: string, name: string): AgentProfile { }; } -function SeedAgents({ agents }: { agents: AgentProfile[] }) { - const setAgents = useAppStore((s) => s.setOfficeAgentProfiles); - useEffect(() => setAgents(agents), [setAgents, agents]); +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function SeedWorkspace() { + const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); + useEffect(() => setActiveWorkspace("ws-1"), [setActiveWorkspace]); return null; } @@ -61,13 +72,17 @@ function Wrapper({ agents: AgentProfile[]; }) { const ctx = { task, applyPatch: () => {}, restore: () => {} }; + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.office.agents("ws-1"), { agents }); return ( - - - - {children} - - + + + + + {children} + + + ); } diff --git a/apps/web/components/task/simple/components/agents-multi-picker.tsx b/apps/web/components/task/simple/components/agents-multi-picker.tsx index a3ae576ccb..58024802ee 100644 --- a/apps/web/components/task/simple/components/agents-multi-picker.tsx +++ b/apps/web/components/task/simple/components/agents-multi-picker.tsx @@ -3,12 +3,12 @@ import { useMemo } from "react"; import { IconCheck, IconCircleDashed, IconX } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; import { useOptimisticTaskMutation } from "@/hooks/use-optimistic-task-mutation"; import { formatRelativeTime } from "@/lib/utils"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import type { Task, TaskDecision } from "@/app/office/tasks/[id]/types"; import { MultiSelectPopover, type MultiSelectItem } from "./multi-select-popover"; +import { useActiveOfficeAgents } from "../use-office-reference-data"; type AgentItem = MultiSelectItem & { icon: string; name: string }; @@ -113,7 +113,7 @@ export function AgentsMultiPicker({ apiRemove, decisionsByAgent, }: AgentsMultiPickerProps) { - const agents = useAppStore((s) => s.office.agentProfiles); + const agents = useActiveOfficeAgents(); const mutate = useOptimisticTaskMutation(); const items = useMemo(() => buildAgentItems(agents), [agents]); diff --git a/apps/web/components/task/simple/components/assignee-picker.tsx b/apps/web/components/task/simple/components/assignee-picker.tsx index 16c7b24a06..ae5ebe6596 100644 --- a/apps/web/components/task/simple/components/assignee-picker.tsx +++ b/apps/web/components/task/simple/components/assignee-picker.tsx @@ -1,13 +1,12 @@ "use client"; -import { useEffect, useMemo } from "react"; +import { useMemo } from "react"; import { Combobox, type ComboboxOption } from "@/components/combobox"; -import { useAppStore } from "@/components/state-provider"; import { updateTask } from "@/lib/api/domains/office-extended-api"; -import { listAgentProfiles } from "@/lib/api/domains/office-api"; import { useOptimisticTaskMutation } from "@/hooks/use-optimistic-task-mutation"; import { AgentAvatar } from "@/app/office/components/agent-avatar"; import type { Task } from "@/app/office/tasks/[id]/types"; +import { useActiveOfficeAgents } from "../use-office-reference-data"; type AssigneePickerProps = { task: Task; @@ -16,30 +15,9 @@ type AssigneePickerProps = { const NO_ASSIGNEE = "__none__"; export function AssigneePicker({ task }: AssigneePickerProps) { - const agents = useAppStore((s) => s.office.agentProfiles); - const setOfficeAgentProfiles = useAppStore((s) => s.setOfficeAgentProfiles); - const workspaceId = useAppStore((s) => s.workspaces.activeId); + const agents = useActiveOfficeAgents(); const mutate = useOptimisticTaskMutation(); - // The store is hydrated from /office/inbox and /office/agents pages but - // not from individual task pages, so navigating directly to a task - // leaves agentProfiles empty. Lazy-fetch on mount when missing — same - // pattern the parent/blockers pickers use against searchTasks. - useEffect(() => { - if (!workspaceId || agents.length > 0) return; - let cancelled = false; - listAgentProfiles(workspaceId) - .then((res) => { - if (!cancelled && res.agents) setOfficeAgentProfiles(res.agents); - }) - .catch(() => { - /* swallow: picker just shows No assignee */ - }); - return () => { - cancelled = true; - }; - }, [workspaceId, agents.length, setOfficeAgentProfiles]); - const options = useMemo(() => { const noOpt: ComboboxOption = { value: NO_ASSIGNEE, diff --git a/apps/web/components/task/simple/components/blockers-picker.test.tsx b/apps/web/components/task/simple/components/blockers-picker.test.tsx index 3df09fb1fa..c3e7a3779a 100644 --- a/apps/web/components/task/simple/components/blockers-picker.test.tsx +++ b/apps/web/components/task/simple/components/blockers-picker.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { useEffect, type ReactNode } from "react"; import { StateProvider, useAppStore } from "@/components/state-provider"; import { TaskOptimisticContextProvider } from "@/hooks/use-optimistic-task-mutation"; @@ -11,7 +12,7 @@ import type { Task } from "@/app/office/tasks/[id]/types"; // Hoisted mocks so the API module is replaced before the component imports it. const addBlockerMock = vi.hoisted(() => vi.fn()); const removeBlockerMock = vi.hoisted(() => vi.fn()); -const searchTasksMock = vi.hoisted(() => +const listTasksMock = vi.hoisted(() => vi.fn().mockResolvedValue({ tasks: [{ id: "t-2", identifier: "TASK-2", title: "Beta", workspaceId: "ws-1" }], }), @@ -26,10 +27,13 @@ vi.mock("@/lib/api/domains/office-extended-api", async () => { ...actual, addTaskBlocker: addBlockerMock, removeTaskBlocker: removeBlockerMock, - searchTasks: searchTasksMock, }; }); +vi.mock("@/lib/api/domains/office-tasks-api", () => ({ + listTasks: listTasksMock, +})); + vi.mock("sonner", async () => { const actual = await vi.importActual("sonner"); return { @@ -62,13 +66,20 @@ const baseTask: Task = { updatedAt: "2026-05-01T00:00:00Z", }; -// SeedTasks pre-populates the office store so the picker has a candidate -// without needing to hit the searchTasks fallback fetch. -function SeedTasks({ tasks }: { tasks: OfficeTask[] }) { - const setTasks = useAppStore((s) => s.setTasks); +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + +function SeedWorkspace() { + const setActiveWorkspace = useAppStore((s) => s.setActiveWorkspace); useEffect(() => { - setTasks(tasks); - }, [setTasks, tasks]); + setActiveWorkspace("ws-1"); + }, [setActiveWorkspace]); return null; } @@ -84,11 +95,19 @@ function Wrapper({ candidates: OfficeTask[]; }) { const ctx = { task: baseTask, applyPatch, restore }; + const queryClient = createQueryClient(); + listTasksMock.mockResolvedValue({ + tasks: candidates, + next_cursor: "", + next_id: "", + }); return ( - - - {children} - + + + + {children} + + ); } diff --git a/apps/web/components/task/simple/components/blockers-picker.tsx b/apps/web/components/task/simple/components/blockers-picker.tsx index cfca27bdd2..c2bd015bdf 100644 --- a/apps/web/components/task/simple/components/blockers-picker.tsx +++ b/apps/web/components/task/simple/components/blockers-picker.tsx @@ -1,15 +1,13 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useMemo } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; import { IconX } from "@tabler/icons-react"; import { useAppStore } from "@/components/state-provider"; -import { - addTaskBlocker, - removeTaskBlocker, - searchTasks, -} from "@/lib/api/domains/office-extended-api"; +import { addTaskBlocker, removeTaskBlocker } from "@/lib/api/domains/office-extended-api"; import { ApiError } from "@/lib/api/client"; import { useOptimisticTaskMutation } from "@/hooks/use-optimistic-task-mutation"; +import { officeTasksInfiniteQueryOptions } from "@/lib/query/query-options"; import type { OfficeTask } from "@/lib/state/slices/office/types"; import type { Task } from "@/app/office/tasks/[id]/types"; import { MultiSelectPopover, type MultiSelectItem } from "./multi-select-popover"; @@ -76,27 +74,19 @@ function buildItems(candidates: OfficeTask[], currentTaskId: string): BlockerIte } export function BlockersPicker({ task }: BlockersPickerProps) { - const storeTasks = useAppStore((s) => s.office.tasks.items); const workspaceId = useAppStore((s) => s.workspaces.activeId); - const [fetched, setFetched] = useState([]); const mutate = useOptimisticTaskMutation(); - - useEffect(() => { - if (!workspaceId || storeTasks.length > 0) return; - let cancelled = false; - searchTasks(workspaceId, "", 50) - .then((res) => { - if (!cancelled) setFetched(res.tasks ?? []); - }) - .catch(() => { - if (!cancelled) setFetched([]); - }); - return () => { - cancelled = true; - }; - }, [workspaceId, storeTasks.length]); - - const candidates = storeTasks.length > 0 ? storeTasks : fetched; + const tasksQuery = useInfiniteQuery( + officeTasksInfiniteQueryOptions(workspaceId ?? "", { + limit: 50, + sort: "updated_at", + order: "desc", + }), + ); + const candidates = useMemo( + () => tasksQuery.data?.pages.flatMap((page) => page.tasks ?? []) ?? [], + [tasksQuery.data], + ); const items = useMemo(() => buildItems(candidates, task.id), [candidates, task.id]); const handleAdd = async (id: string) => { @@ -155,8 +145,6 @@ export function BlockersPicker({ task }: BlockersPickerProps) { ); - void workspaceId; - return ( s.office.tasks.items); const workspaceId = useAppStore((s) => s.workspaces.activeId); - const [fetched, setFetched] = useState([]); const mutate = useOptimisticTaskMutation(); - - // If the store doesn't already have tasks for the workspace, lazily fetch. - useEffect(() => { - if (!workspaceId || storeTasks.length > 0) return; - let cancelled = false; - searchTasks(workspaceId, "", 50) - .then((res) => { - if (!cancelled) setFetched(res.tasks ?? []); - }) - .catch(() => { - if (!cancelled) setFetched([]); - }); - return () => { - cancelled = true; - }; - }, [workspaceId, storeTasks.length]); - - const candidates = storeTasks.length > 0 ? storeTasks : fetched; + const tasksQuery = useInfiniteQuery( + officeTasksInfiniteQueryOptions(workspaceId ?? "", { + limit: 50, + sort: "updated_at", + order: "desc", + }), + ); + const candidates = useMemo( + () => tasksQuery.data?.pages.flatMap((page) => page.tasks ?? []) ?? [], + [tasksQuery.data], + ); const options = useMemo(() => buildOptions(candidates, task.id), [candidates, task.id]); diff --git a/apps/web/components/task/simple/components/pending-approval-badge.test.tsx b/apps/web/components/task/simple/components/pending-approval-badge.test.tsx index ec00517b0d..7829b8a42e 100644 --- a/apps/web/components/task/simple/components/pending-approval-badge.test.tsx +++ b/apps/web/components/task/simple/components/pending-approval-badge.test.tsx @@ -1,9 +1,10 @@ import { afterEach, describe, expect, it } from "vitest"; import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { useEffect } from "react"; import { TooltipProvider } from "@kandev/ui/tooltip"; -import { StateProvider, useAppStore } from "@/components/state-provider"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; import type { AgentProfile } from "@/lib/state/slices/office/types"; import { agentProfileId as toAgentProfileId, workspaceId as toWorkspaceId } from "@/lib/types/ids"; import { @@ -36,12 +37,6 @@ const baseTask: Task = { updatedAt: TS, }; -function SeedAgents({ agents }: { agents: AgentProfile[] }) { - const setAgents = useAppStore((s) => s.setOfficeAgentProfiles); - useEffect(() => setAgents(agents), [setAgents, agents]); - return null; -} - function makeAgent(id: string, name: string): AgentProfile { return { id: toAgentProfileId(id), @@ -73,13 +68,20 @@ function makeAgent(id: string, name: string): AgentProfile { } function Wrapper({ children, agents }: { children: ReactNode; agents: AgentProfile[] }) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + queryClient.setQueryData(qk.office.agents("ws-1"), { agents }); + return ( - - - - {children} - - + + + {children} + + ); } diff --git a/apps/web/components/task/simple/components/pending-approval-badge.tsx b/apps/web/components/task/simple/components/pending-approval-badge.tsx index 68dc199cf9..837063c666 100644 --- a/apps/web/components/task/simple/components/pending-approval-badge.tsx +++ b/apps/web/components/task/simple/components/pending-approval-badge.tsx @@ -3,8 +3,8 @@ import { useMemo } from "react"; import { Badge } from "@kandev/ui/badge"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; -import { useAppStore } from "@/components/state-provider"; import type { Task, TaskDecision } from "@/app/office/tasks/[id]/types"; +import { useActiveOfficeAgents } from "../use-office-reference-data"; // computePendingApprovers returns the names of approvers who have not // recorded an active "approved" decision. Used to render the gated @@ -40,7 +40,7 @@ type PendingApprovalBadgeProps = { }; export function PendingApprovalBadge({ task }: PendingApprovalBadgeProps) { - const agents = useAppStore((s) => s.office.agentProfiles); + const agents = useActiveOfficeAgents(); const agentLookup = useMemo(() => { const map: Record = {}; for (const a of agents) map[a.id] = a.name; diff --git a/apps/web/components/task/simple/components/project-picker.tsx b/apps/web/components/task/simple/components/project-picker.tsx index 8e08fe3f2a..abfa979ccf 100644 --- a/apps/web/components/task/simple/components/project-picker.tsx +++ b/apps/web/components/task/simple/components/project-picker.tsx @@ -2,10 +2,10 @@ import { useMemo } from "react"; import { Combobox, type ComboboxOption } from "@/components/combobox"; -import { useAppStore } from "@/components/state-provider"; import { updateTask } from "@/lib/api/domains/office-extended-api"; import { useOptimisticTaskMutation } from "@/hooks/use-optimistic-task-mutation"; import type { Task } from "@/app/office/tasks/[id]/types"; +import { useActiveOfficeProjects } from "../use-office-reference-data"; type ProjectPickerProps = { task: Task; @@ -14,7 +14,7 @@ type ProjectPickerProps = { const NO_PROJECT = "__none__"; export function ProjectPicker({ task }: ProjectPickerProps) { - const projects = useAppStore((s) => s.office.projects); + const projects = useActiveOfficeProjects(); const mutate = useOptimisticTaskMutation(); const options = useMemo(() => { diff --git a/apps/web/components/task/simple/components/run-error-entry.tsx b/apps/web/components/task/simple/components/run-error-entry.tsx index b25c970c8e..22ac0b97da 100644 --- a/apps/web/components/task/simple/components/run-error-entry.tsx +++ b/apps/web/components/task/simple/components/run-error-entry.tsx @@ -9,11 +9,11 @@ import { } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; -import { useAppStore } from "@/components/state-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; import { formatRelativeTime } from "@/lib/utils"; import { AgentAvatar } from "@/app/office/components/agent-avatar"; import type { RunError } from "@/app/office/tasks/[id]/types"; +import { useActiveOfficeAgents } from "../use-office-reference-data"; type RunErrorEntryProps = { taskId: string; @@ -29,11 +29,10 @@ type RunErrorEntryProps = { * request so the recovery semantics are unchanged. */ export function RunErrorEntry({ taskId, error }: RunErrorEntryProps) { - const agentName = useAppStore((s) => - error.agentProfileId - ? (s.office.agentProfiles.find((a) => a.id === error.agentProfileId)?.name ?? "Agent") - : "Agent", - ); + const agents = useActiveOfficeAgents(); + const agentName = error.agentProfileId + ? (agents.find((a) => a.id === error.agentProfileId)?.name ?? "Agent") + : "Agent"; const [showDetails, setShowDetails] = useState(false); const handleRecover = async (action: "resume" | "fresh_start") => { diff --git a/apps/web/components/task/simple/components/session-timeline-entry.test.tsx b/apps/web/components/task/simple/components/session-timeline-entry.test.tsx index ed7b4af9cb..3a73cdd7ea 100644 --- a/apps/web/components/task/simple/components/session-timeline-entry.test.tsx +++ b/apps/web/components/task/simple/components/session-timeline-entry.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, cleanup, fireEvent } from "@testing-library/react"; import type { ReactNode } from "react"; import { StateProvider } from "@/components/state-provider"; @@ -92,11 +93,22 @@ function makeSession(overrides: Partial = {}): TaskSession { }; } +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + function wrap(node: ReactNode) { return ( - - {node} - + + + {node} + + ); } diff --git a/apps/web/components/task/simple/components/session-timeline-entry.tsx b/apps/web/components/task/simple/components/session-timeline-entry.tsx index b6c913108e..b4c8cfe4f5 100644 --- a/apps/web/components/task/simple/components/session-timeline-entry.tsx +++ b/apps/web/components/task/simple/components/session-timeline-entry.tsx @@ -8,6 +8,7 @@ import { selectCommandCount } from "@/lib/state/slices/session/selectors"; import { AdvancedChatPanel } from "@/app/office/tasks/[id]/advanced-panels/chat-panel"; import { useActiveSessionRef } from "./active-session-ref-context"; import type { TaskSession } from "@/app/office/tasks/[id]/types"; +import { useActiveOfficeAgents } from "../use-office-reference-data"; const COLLAPSE_KEY_PREFIX = "office.session.collapsed."; @@ -246,15 +247,14 @@ export const SessionTimelineEntry = forwardRef - agentProfileId - ? s.office.agentProfiles.find((a) => a.id === agentProfileId)?.name - : undefined, - ); + const agents = useActiveOfficeAgents(); + const resolvedAgentName = agentProfileId + ? agents.find((a) => a.id === agentProfileId)?.name + : undefined; const displayAgentName = resolvedAgentName || (session.agentName !== agentProfileId ? session.agentName : null) || diff --git a/apps/web/components/task/simple/task-activity.tsx b/apps/web/components/task/simple/task-activity.tsx index ba2e97710a..8605f5327a 100644 --- a/apps/web/components/task/simple/task-activity.tsx +++ b/apps/web/components/task/simple/task-activity.tsx @@ -1,9 +1,9 @@ "use client"; -import { useAppStore } from "@/components/state-provider"; import { formatRelativeTime } from "@/lib/utils"; import { AgentAvatar } from "@/app/office/components/agent-avatar"; import type { TaskActivityEntry } from "@/app/office/tasks/[id]/types"; +import { useActiveOfficeAgents } from "./use-office-reference-data"; type TaskActivityProps = { taskId: string; @@ -11,11 +11,11 @@ type TaskActivityProps = { }; function ActivityRow({ entry }: { entry: TaskActivityEntry }) { - const agentName = useAppStore((s) => + const agents = useActiveOfficeAgents(); + const agentName = entry.actorType === "agent" - ? (s.office.agentProfiles.find((a) => a.id === entry.actorId)?.name ?? "Agent") - : "", - ); + ? (agents.find((a) => a.id === entry.actorId)?.name ?? "Agent") + : ""; let actorName = "System"; if (entry.actorType === "user") actorName = "You"; else if (entry.actorType === "agent") actorName = agentName; diff --git a/apps/web/components/task/simple/task-chat.test.tsx b/apps/web/components/task/simple/task-chat.test.tsx index 9c3be87bfd..a1915e397e 100644 --- a/apps/web/components/task/simple/task-chat.test.tsx +++ b/apps/web/components/task/simple/task-chat.test.tsx @@ -1,4 +1,5 @@ import { describe, it, expect, afterEach, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen, cleanup, fireEvent, within } from "@testing-library/react"; import type { ReactNode } from "react"; import { StateProvider } from "@/components/state-provider"; @@ -115,11 +116,22 @@ function makeSession(id: string, startedAt: string, state: TaskSession["state"]) }; } +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + function wrap(node: ReactNode) { return ( - - {node} - + + + {node} + + ); } diff --git a/apps/web/components/task/simple/task-chat.tsx b/apps/web/components/task/simple/task-chat.tsx index de908c665e..804f41420b 100644 --- a/apps/web/components/task/simple/task-chat.tsx +++ b/apps/web/components/task/simple/task-chat.tsx @@ -32,6 +32,7 @@ import { mergeChatEntries, type ChatEntry, } from "./chat-entries"; +import { useActiveOfficeAgents } from "./use-office-reference-data"; const MAX_INLINE_SESSIONS = 50; const AUTOSCROLL_THRESHOLD_PX = 80; @@ -85,17 +86,7 @@ function CommentEntry({ hasLaterAgentReply?: boolean; }) { const isAgent = comment.authorType === "agent"; - // Resolve the agent name from the office agents store so renames - // flow through automatically. Backend session-bridged comments don't - // carry a name; the mapper leaves authorName empty for agents. - const resolvedAgentName = useAppStore((s) => - isAgent - ? (s.office.agentProfiles.find((a) => a.id === comment.authorId)?.name ?? - comment.authorName ?? - "Agent") - : "", - ); - const displayName = isAgent ? resolvedAgentName : "You"; + const displayName = useCommentDisplayName(comment); return (
a.id === comment.authorId)?.name ?? comment.authorName ?? "Agent"; +} + // formatDecisionLine renders the human-readable summary for a single // task decision entry in the timeline. Approve rows read like // "CEO approved this task"; request-changes rows append the comment. diff --git a/apps/web/components/task/simple/use-office-reference-data.ts b/apps/web/components/task/simple/use-office-reference-data.ts new file mode 100644 index 0000000000..600cbf8a8d --- /dev/null +++ b/apps/web/components/task/simple/use-office-reference-data.ts @@ -0,0 +1,14 @@ +"use client"; + +import { useAppStore } from "@/components/state-provider"; +import { useOfficeAgentsData, useOfficeProjectsData } from "@/hooks/domains/office/use-office-data"; + +export function useActiveOfficeAgents() { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + return useOfficeAgentsData(workspaceId).data?.agents ?? []; +} + +export function useActiveOfficeProjects() { + const workspaceId = useAppStore((s) => s.workspaces.activeId); + return useOfficeProjectsData(workspaceId).data?.projects ?? []; +} diff --git a/apps/web/components/task/task-archive-confirm-dialog.test.tsx b/apps/web/components/task/task-archive-confirm-dialog.test.tsx index bb718696cf..603873fa3d 100644 --- a/apps/web/components/task/task-archive-confirm-dialog.test.tsx +++ b/apps/web/components/task/task-archive-confirm-dialog.test.tsx @@ -1,14 +1,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; const mockGetSubtaskCount = vi.fn(); -vi.mock("@/lib/api", () => ({ +vi.mock("@/lib/api/domains/kanban-api", () => ({ getSubtaskCount: (...args: unknown[]) => mockGetSubtaskCount(...args), })); import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog"; +function renderWithQuery(node: ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render({node}); +} + beforeEach(() => { mockGetSubtaskCount.mockReset(); mockGetSubtaskCount.mockResolvedValue({ count: 0 }); @@ -18,7 +27,7 @@ afterEach(cleanup); describe("TaskArchiveConfirmDialog cleanup copy", () => { it("renders local-executor reassurance about untouched repo", () => { - render( + renderWithQuery( {}} @@ -32,7 +41,7 @@ describe("TaskArchiveConfirmDialog cleanup copy", () => { }); it("renders worktree-executor copy about worktree + branch removal", () => { - render( + renderWithQuery( {}} @@ -46,7 +55,7 @@ describe("TaskArchiveConfirmDialog cleanup copy", () => { }); it("warns about sandbox destruction for sprites executor", () => { - render( + renderWithQuery( {}} @@ -61,7 +70,7 @@ describe("TaskArchiveConfirmDialog cleanup copy", () => { }); it("describes Docker container removal for local_docker", () => { - render( + renderWithQuery( {}} @@ -75,7 +84,7 @@ describe("TaskArchiveConfirmDialog cleanup copy", () => { }); it("renders grouped copy for bulk archive", () => { - render( + renderWithQuery( {}} @@ -91,7 +100,7 @@ describe("TaskArchiveConfirmDialog cleanup copy", () => { }); it("no longer renders the old hardcoded worktree line for non-worktree executors", () => { - render( + renderWithQuery( {}} diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index 02dc7da233..243af10ec7 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -89,10 +89,10 @@ function useSessionAgentProfile(sessionId: string | null | undefined) { const profileId = useAppStore((state) => sessionId ? (state.taskSessions.items[sessionId]?.agent_profile_id ?? null) : null, ); - return useAppStore((state) => - profileId - ? (state.agentProfiles.items.find((p: { id: string }) => p.id === profileId) ?? null) - : null, + const { agentProfiles } = useSettingsData(Boolean(profileId)); + return useMemo( + () => (profileId ? (agentProfiles.find((profile) => profile.id === profileId) ?? null) : null), + [agentProfiles, profileId], ); } diff --git a/apps/web/components/task/task-delete-confirm-dialog.test.tsx b/apps/web/components/task/task-delete-confirm-dialog.test.tsx index 68c2f2bf0a..d2c29389e0 100644 --- a/apps/web/components/task/task-delete-confirm-dialog.test.tsx +++ b/apps/web/components/task/task-delete-confirm-dialog.test.tsx @@ -1,14 +1,23 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; const mockGetSubtaskCount = vi.fn(); -vi.mock("@/lib/api", () => ({ +vi.mock("@/lib/api/domains/kanban-api", () => ({ getSubtaskCount: (...args: unknown[]) => mockGetSubtaskCount(...args), })); import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog"; +function renderWithQuery(node: ReactNode) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render({node}); +} + beforeEach(() => { mockGetSubtaskCount.mockReset(); }); @@ -19,7 +28,7 @@ describe("TaskDeleteConfirmDialog", () => { it("hides the cascade checkbox when the task has no subtasks", async () => { mockGetSubtaskCount.mockResolvedValue({ count: 0 }); const onConfirm = vi.fn(); - render( + renderWithQuery( {}} @@ -29,7 +38,11 @@ describe("TaskDeleteConfirmDialog", () => { confirmTestId="confirm" />, ); - await waitFor(() => expect(mockGetSubtaskCount).toHaveBeenCalledWith("task-1")); + await waitFor(() => + expect(mockGetSubtaskCount).toHaveBeenCalledWith("task-1", { + init: { signal: expect.any(AbortSignal) }, + }), + ); expect(screen.queryByTestId("delete-cascade-checkbox")).toBeNull(); fireEvent.click(screen.getByTestId("confirm")); @@ -39,7 +52,7 @@ describe("TaskDeleteConfirmDialog", () => { it("shows the cascade checkbox when the task has subtasks; defaults to unchecked", async () => { mockGetSubtaskCount.mockResolvedValue({ count: 3 }); const onConfirm = vi.fn(); - render( + renderWithQuery( {}} @@ -59,7 +72,7 @@ describe("TaskDeleteConfirmDialog", () => { it("propagates cascade=true when the user ticks the checkbox", async () => { mockGetSubtaskCount.mockResolvedValue({ count: 2 }); const onConfirm = vi.fn(); - render( + renderWithQuery( {}} @@ -79,7 +92,7 @@ describe("TaskDeleteConfirmDialog", () => { mockGetSubtaskCount.mockImplementation((id: string) => Promise.resolve({ count: id === "a" ? 2 : 5 }), ); - render( + renderWithQuery( {}} @@ -96,7 +109,7 @@ describe("TaskDeleteConfirmDialog", () => { describe("TaskDeleteConfirmDialog executor cleanup copy", () => { it("local reassures repo is untouched", async () => { mockGetSubtaskCount.mockResolvedValue({ count: 0 }); - render( + renderWithQuery( {}} @@ -112,7 +125,7 @@ describe("TaskDeleteConfirmDialog executor cleanup copy", () => { it("worktree describes worktree+branch removal", async () => { mockGetSubtaskCount.mockResolvedValue({ count: 0 }); - render( + renderWithQuery( {}} @@ -127,7 +140,7 @@ describe("TaskDeleteConfirmDialog executor cleanup copy", () => { it("groups bulk delete copy by executor type", async () => { mockGetSubtaskCount.mockResolvedValue({ count: 0 }); - render( + renderWithQuery( {}} @@ -144,7 +157,7 @@ describe("TaskDeleteConfirmDialog executor cleanup copy", () => { it("falls back to a generic message when no executorType is provided", async () => { mockGetSubtaskCount.mockResolvedValue({ count: 0 }); - render( + renderWithQuery( {}} diff --git a/apps/web/components/task/task-external-link-dialog.test.tsx b/apps/web/components/task/task-external-link-dialog.test.tsx index 856aa521b2..340df849d7 100644 --- a/apps/web/components/task/task-external-link-dialog.test.tsx +++ b/apps/web/components/task/task-external-link-dialog.test.tsx @@ -1,4 +1,5 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; @@ -66,24 +67,37 @@ function mockSentryInstances(state: SentryAvailability["state"], healthy: Sentry }); } +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); +} + afterEach(() => { cleanup(); vi.clearAllMocks(); }); function renderDialog(provider: "jira" | "linear" | "sentry" = "jira") { + const queryClient = createQueryClient(); + return render( - - - - - , + + + + + + + , ); } @@ -243,17 +257,19 @@ describe("TaskExternalLinkDialog — Sentry sole-instance swap", () => { // new instance rather than link against the stale inst-1. mockSentryInstances("single", [instance("inst-2", "Beta")]); view.rerender( - - - - - , + + + + + + + , ); fireEvent.change(screen.getByTestId(INPUT_TEST_ID), { target: { value: "PROJ-2" } }); diff --git a/apps/web/components/task/task-external-link-dialog.tsx b/apps/web/components/task/task-external-link-dialog.tsx index 39e33a4305..29b1cfbb32 100644 --- a/apps/web/components/task/task-external-link-dialog.tsx +++ b/apps/web/components/task/task-external-link-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@kandev/ui/button"; import { Dialog, @@ -14,7 +15,6 @@ import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { useToast } from "@/components/toast-provider"; -import { useAppStoreApi } from "@/components/state-provider"; import { getJiraTicket } from "@/lib/api/domains/jira-api"; import { getLinearIssue } from "@/lib/api/domains/linear-api"; import { getSentryIssue } from "@/lib/api/domains/sentry-api"; @@ -23,7 +23,7 @@ import { JIRA_KEY_RE } from "@/components/jira/jira-ticket-common"; import { LINEAR_KEY_RE } from "@/components/linear/linear-issue-common"; import { extractSentryShortId } from "@/components/sentry/sentry-issue-common"; import { useSentryInstances } from "@/hooks/domains/sentry/use-sentry-availability"; -import { findTaskInSnapshots } from "@/lib/kanban/find-task"; +import { workflowSnapshotQueryData } from "@/lib/query/workflow-snapshot-cache"; import type { SentryIssue } from "@/lib/types/sentry"; import { buildLinkedIssueTitle } from "./task-external-link-utils"; @@ -184,7 +184,7 @@ function useExternalLinkForm( onOpenChange: (open: boolean) => void, ) { const { toast } = useToast(); - const store = useAppStoreApi(); + const queryClient = useQueryClient(); const [input, setInput] = useState(""); const [instanceId, setInstanceId] = useState(""); const [submitting, setSubmitting] = useState(false); @@ -213,12 +213,9 @@ function useExternalLinkForm( setError(null); try { const result = await config.fetch(key, workspaceId, instanceId || undefined); - const state = store.getState(); - const latestTask = findTaskInSnapshots( - task.id, - state.kanbanMulti.snapshots, - state.kanban.tasks, - ); + const latestTask = workflowSnapshotQueryData(queryClient) + .flatMap((snapshot) => snapshot.tasks) + .find((snapshotTask) => snapshotTask.id === task.id); const linkedKey = config.resolveLinkedKey?.(key, result) ?? key; await updateTask(task.id, { title: buildLinkedIssueTitle(latestTask?.title ?? task.title, linkedKey), diff --git a/apps/web/components/task/task-item.test.tsx b/apps/web/components/task/task-item.test.tsx index de3926b93e..b027591335 100644 --- a/apps/web/components/task/task-item.test.tsx +++ b/apps/web/components/task/task-item.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ComponentProps } from "react"; import { StateProvider } from "@/components/state-provider"; import { TaskItem } from "./task-item"; @@ -18,12 +19,15 @@ const SLOW_SPIN_CLASS = "[animation-duration:2s]"; afterEach(() => cleanup()); function renderTaskItem(props: Partial> = {}) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( - - - - - , + + + + + + + , ); } diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 19b53d3131..2137620b89 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -1,6 +1,7 @@ "use client"; import { memo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { IconAlertCircle, IconChevronDown, @@ -14,11 +15,12 @@ import { } from "@tabler/icons-react"; import { PRTaskIcon } from "@/components/github/pr-task-icon"; import { IssueTaskIcon } from "@/components/github/issue-task-icon"; -import { useAppStore } from "@/components/state-provider"; +import { useTaskPR } from "@/hooks/domains/github/use-task-pr"; import { cn } from "@/lib/utils"; import { computeRowIndent, resolveRowDepth } from "@/lib/sidebar/row-indent"; import { isDebugUI } from "@/lib/config"; import { useTaskColor } from "@/hooks/use-task-color"; +import { sessionPollModeQueryOptions } from "@/lib/query/query-options"; import { TASK_COLOR_BAR_CLASS, type TaskColor } from "@/lib/task-colors"; import type { TaskState, TaskSessionState } from "@/lib/types/http"; import { shouldUseQuestionTaskIcon, shouldUsePermissionTaskIcon } from "@/lib/ui/state-icons"; @@ -238,11 +240,8 @@ function TaskItemStatsRow({ prInfo?: { number: number; state: string }; primarySessionId?: string | null; }) { - const pollMode = useAppStore((s) => - isDebugUI() && primarySessionId - ? (s.sessionPollMode.bySessionId[primarySessionId] ?? null) - : null, - ); + const pollModeQuery = useQuery(sessionPollModeQueryOptions(primarySessionId ?? "")); + const pollMode = isDebugUI() ? pollModeQuery.data : null; if (!updatedAt && !prInfo && !pollMode) return null; @@ -287,7 +286,7 @@ function DiffStatsRight({ diffStats, menuOpen }: { diffStats: DiffStats; menuOpe ); } -/** Shows PR icon from store (real data) or from prInfo prop (prototype/mock). */ +/** Shows PR icon from Query data or from prInfo prop (prototype/mock). */ function TaskPRIcon({ taskId, prInfo, @@ -295,8 +294,8 @@ function TaskPRIcon({ taskId?: string; prInfo?: { number: number; state: string }; }) { - const hasStorePR = useAppStore((s) => !!taskId && (s.taskPRs.byTaskId[taskId]?.length ?? 0) > 0); - if (hasStorePR) return ; + const { prs } = useTaskPR(taskId ?? null); + if (taskId && prs.length > 0) return ; if (!prInfo) return null; const state = prInfo.state.toLowerCase(); let color = "text-muted-foreground"; diff --git a/apps/web/components/task/task-page-content-helpers.ts b/apps/web/components/task/task-page-content-helpers.ts index 1a0c4b3611..d1b9a34a5c 100644 --- a/apps/web/components/task/task-page-content-helpers.ts +++ b/apps/web/components/task/task-page-content-helpers.ts @@ -119,11 +119,13 @@ export function hasResolvedTaskDetails(params: { effectiveTaskId: string | null; taskDetailsId?: string | null; initialTaskId?: string | null; + snapshotTaskId?: string | null; }) { if (!params.effectiveTaskId) return false; return ( params.taskDetailsId === params.effectiveTaskId || - params.initialTaskId === params.effectiveTaskId + params.initialTaskId === params.effectiveTaskId || + params.snapshotTaskId === params.effectiveTaskId ); } diff --git a/apps/web/components/task/task-page-content.test.tsx b/apps/web/components/task/task-page-content.test.tsx new file mode 100644 index 0000000000..47d79e62bd --- /dev/null +++ b/apps/web/components/task/task-page-content.test.tsx @@ -0,0 +1,338 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { + taskId, + workflowId, + workspaceId, + type Task, + type WorkflowSnapshot, + type WorkflowStep, +} from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +const mockFetchTask = vi.fn(); +const mockUseTasks = vi.fn(); +const TASK_ID = "task-1"; +const SESSION_ID = "session-1"; +const WORKSPACE_ID = "ws-1"; +const WORKFLOW_ID = "wf-1"; +const SECOND_TASK_ID = "task-2"; +const SECOND_WORKFLOW_ID = "wf-2"; +const INITIAL_TASK_TITLE = "Initial task"; +const QUERY_STEP_ID = "query-step"; +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +type MockState = { + kanban: { + tasks: KanbanState["tasks"]; + steps: KanbanState["steps"]; + }; + tasks: { + activeTaskId: string | null; + }; + taskSessions: { + items: Record; + }; + previewPanel: { + openBySessionId: Record; + stageBySessionId: Record; + urlBySessionId: Record; + }; + processes: { + devProcessBySessionId: Record; + processesById: Record; + }; +}; + +function defaultMockState(): MockState { + return { + kanban: { tasks: [], steps: [] }, + tasks: { activeTaskId: null }, + taskSessions: { items: {} }, + previewPanel: { openBySessionId: {}, stageBySessionId: {}, urlBySessionId: {} }, + processes: { devProcessBySessionId: {}, processesById: {} }, + }; +} + +let mockState: MockState = defaultMockState(); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/hooks/use-tasks", () => ({ + useTasks: (...args: unknown[]) => mockUseTasks(...args), +})); + +vi.mock("@/components/task/task-page-inner", () => ({ + TaskPageInner: () => null, +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: (...args: unknown[]) => mockFetchTask(...args), + fetchWorkflowSnapshot: vi.fn(), + getSubtaskCount: vi.fn(), + listTasksByWorkspace: vi.fn(), + listWorkflows: vi.fn(), +})); + +vi.mock("@/lib/api/domains/workflow-api", () => ({ + listWorkflowSteps: vi.fn(), +})); + +import { useSessionPanelState, useTaskDetails, useWorkflowStepsMapped } from "./task-page-content"; + +function makeTask( + id: string, + title: string, + workflow = WORKFLOW_ID, + overrides: Partial = {}, +): Task { + return { + id: taskId(id), + workspace_id: workspaceId(WORKSPACE_ID), + workflow_id: workflowId(workflow), + workflow_step_id: "step-1", + position: 1, + title, + description: "", + state: "CREATED", + priority: 0, + repositories: [], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + ...overrides, + }; +} + +function makeStep(id: string, name: string, overrides: Partial = {}): WorkflowStep { + return { + id, + workflow_id: workflowId(WORKFLOW_ID), + name, + position: 1, + color: "bg-blue-500", + events: { on_enter: [{ type: "enable_plan_mode" }] }, + allow_manual_move: true, + prompt: "Do the thing", + is_start_step: false, + show_in_command_panel: true, + agent_profile_id: "agent-1", + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + ...overrides, + }; +} + +function makeWorkflowSnapshot(tasks: Task[], workflow = WORKFLOW_ID): WorkflowSnapshot { + return { + workflow: { + id: workflowId(workflow), + workspace_id: workspaceId(WORKSPACE_ID), + name: "Workflow", + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }, + steps: [], + tasks, + }; +} + +function makeKanbanTask( + id: string, + overrides: Partial = {}, +): KanbanState["tasks"][number] { + return { + id, + workflowStepId: "legacy-step", + title: "Legacy task", + description: "legacy", + position: 1, + state: "CREATED", + updatedAt: TIMESTAMP, + ...overrides, + }; +} + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function wrapper(client = makeQueryClient()) { + return function TestWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + +describe("useWorkflowStepsMapped", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchTask.mockReturnValue(new Promise(() => {})); + mockState = defaultMockState(); + }); + + it("maps workflow steps from the workflow-step Query cache when kanban steps are empty", () => { + const client = makeQueryClient(); + mockState = { + ...defaultMockState(), + tasks: { activeTaskId: TASK_ID }, + }; + client.setQueryData(qk.tasks.detail(TASK_ID), makeTask(TASK_ID, "Query task")); + client.setQueryData(qk.workflows.steps(WORKFLOW_ID), [makeStep("step-query", "Query step")]); + + const { result } = renderHook(() => useWorkflowStepsMapped(), { + wrapper: wrapper(client), + }); + + expect(result.current).toEqual([ + { + id: "step-query", + name: "Query step", + color: "bg-blue-500", + position: 1, + events: { on_enter: [{ type: "enable_plan_mode" }] }, + allow_manual_move: true, + prompt: "Do the thing", + is_start_step: false, + agent_profile_id: "agent-1", + }, + ]); + }); +}); + +describe("useSessionPanelState", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchTask.mockReturnValue(new Promise(() => {})); + mockState = defaultMockState(); + }); + + it("resolves the task workflow step from task detail Query data", () => { + const client = makeQueryClient(); + mockState = { + ...defaultMockState(), + tasks: { activeTaskId: TASK_ID }, + taskSessions: { + items: { [SESSION_ID]: { state: "RUNNING", is_passthrough: false } }, + }, + }; + client.setQueryData( + qk.tasks.detail(TASK_ID), + makeTask(TASK_ID, "Query task", WORKFLOW_ID, { workflow_step_id: QUERY_STEP_ID }), + ); + + const { result } = renderHook(() => useSessionPanelState(SESSION_ID), { + wrapper: wrapper(client), + }); + + expect(result.current.sessionWorkflowStepId).toBe(QUERY_STEP_ID); + }); +}); + +describe("useTaskDetails", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchTask.mockReturnValue(new Promise(() => {})); + mockState = defaultMockState(); + }); + + it("uses the initial task for first paint without fetching the same task", () => { + const initialTask = makeTask(TASK_ID, INITIAL_TASK_TITLE); + + const { result } = renderHook(() => useTaskDetails(TASK_ID, initialTask), { + wrapper: wrapper(), + }); + + expect(result.current.task?.title).toBe(INITIAL_TASK_TITLE); + expect(mockFetchTask).not.toHaveBeenCalled(); + expect(mockUseTasks).toHaveBeenCalledWith(WORKFLOW_ID); + }); + + it("loads a changed active task through the task query option", async () => { + const initialTask = makeTask(TASK_ID, INITIAL_TASK_TITLE); + const loadedTask = makeTask(SECOND_TASK_ID, "Loaded task", SECOND_WORKFLOW_ID); + mockFetchTask.mockResolvedValue(loadedTask); + + const { result } = renderHook(() => useTaskDetails(SECOND_TASK_ID, initialTask), { + wrapper: wrapper(), + }); + + await waitFor(() => expect(result.current.task?.id).toBe(SECOND_TASK_ID)); + + expect(result.current.task?.title).toBe("Loaded task"); + expect(mockFetchTask).toHaveBeenCalledWith(SECOND_TASK_ID, { + init: { signal: expect.any(AbortSignal) }, + }); + expect(mockUseTasks).toHaveBeenLastCalledWith(SECOND_WORKFLOW_ID); + }); + + it("uses a cached workflow snapshot task while changed task details load", () => { + const client = makeQueryClient(); + const initialTask = makeTask(TASK_ID, INITIAL_TASK_TITLE); + const snapshotTask = makeTask(SECOND_TASK_ID, "Snapshot task", SECOND_WORKFLOW_ID); + client.setQueryData( + qk.workflows.snapshot(SECOND_WORKFLOW_ID), + makeWorkflowSnapshot([snapshotTask], SECOND_WORKFLOW_ID), + ); + + const { result } = renderHook(() => useTaskDetails(SECOND_TASK_ID, initialTask), { + wrapper: wrapper(client), + }); + + expect(result.current.task?.id).toBe(SECOND_TASK_ID); + expect(result.current.task?.title).toBe("Snapshot task"); + expect(result.current.taskLoadError).toBeNull(); + expect(mockFetchTask).toHaveBeenCalledWith(SECOND_TASK_ID, { + init: { signal: expect.any(AbortSignal) }, + }); + expect(mockUseTasks).toHaveBeenLastCalledWith(SECOND_WORKFLOW_ID); + }); + + it("does not let legacy kanban task data override cached task detail", () => { + const client = makeQueryClient(); + mockState = { + ...defaultMockState(), + kanban: { + ...defaultMockState().kanban, + tasks: [ + makeKanbanTask(TASK_ID, { + title: "Legacy title", + workflowStepId: "legacy-step", + }), + ], + }, + }; + client.setQueryData( + qk.tasks.detail(TASK_ID), + makeTask(TASK_ID, "Query title", WORKFLOW_ID, { workflow_step_id: QUERY_STEP_ID }), + ); + + const { result } = renderHook(() => useTaskDetails(TASK_ID, makeTask(TASK_ID, "Initial")), { + wrapper: wrapper(client), + }); + + expect(result.current.task?.title).toBe("Query title"); + expect(result.current.task?.workflow_step_id).toBe(QUERY_STEP_ID); + }); + + it("does not synthesize task details from the legacy kanban mirror", () => { + mockState = { + ...defaultMockState(), + kanban: { + ...defaultMockState().kanban, + tasks: [makeKanbanTask("task-legacy", { title: "Legacy only" })], + }, + }; + + const { result } = renderHook(() => useTaskDetails("task-legacy", null), { + wrapper: wrapper(), + }); + + expect(result.current.task).toBeNull(); + }); +}); diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index 8530680829..43a62a309a 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -1,17 +1,10 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { IconAlertTriangle } from "@tabler/icons-react"; -import { - taskId as toTaskId, - workflowId as toWorkflowId, - workspaceId as toWorkspaceId, - type Repository, - type RepositoryScript, - type Task, -} from "@/lib/types/http"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { type Repository, type RepositoryScript, type Task } from "@/lib/types/http"; import type { Terminal } from "@/hooks/domains/session/use-terminals"; -import type { KanbanState } from "@/lib/state/slices"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useSessionAgent } from "@/hooks/domains/session/use-session-agent"; import { useSessionResumption } from "@/hooks/domains/session/use-session-resumption"; @@ -19,10 +12,12 @@ import { useSessionAgentctl } from "@/hooks/domains/session/use-session-agentctl import { useTaskFocus } from "@/hooks/domains/session/use-task-focus"; import { useAppStore } from "@/components/state-provider"; import { useEnsureTaskSession } from "@/hooks/domains/session/use-ensure-task-session"; -import { fetchTask } from "@/lib/api"; import { useTasks } from "@/hooks/use-tasks"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import type { Layout } from "react-resizable-panels"; +import { taskQueryOptions, workflowStepsQueryOptions } from "@/lib/query/query-options"; +import { workflowSnapshotQueryData } from "@/lib/query/workflow-snapshot-cache"; +import { isPassthroughSession } from "@/lib/session/is-passthrough-session"; import { deriveIsAgentWorking, buildArchivedValue, @@ -47,66 +42,31 @@ type TaskPageContentProps = { function resolveEffectiveTask( taskDetails: Task | null, initialTask: Task | null, - kanbanTask: KanbanState["tasks"][number] | null, + snapshotTask: Task | null, effectiveTaskId: string | null, ): Task | null { const matchingTaskDetails = taskDetails?.id === effectiveTaskId ? taskDetails : null; const matchingInitialTask = initialTask?.id === effectiveTaskId ? initialTask : null; - const baseTask = matchingTaskDetails ?? matchingInitialTask; - - if (!baseTask && !kanbanTask) return null; - if (baseTask) return mergeBaseWithKanban(baseTask, kanbanTask); - if (kanbanTask) return buildTaskFromKanban(kanbanTask, taskDetails, initialTask); - return null; -} - -function mergeBaseWithKanban( - baseTask: Task, - kanbanTask: KanbanState["tasks"][number] | null, -): Task { - if (!kanbanTask) return baseTask; - return { - ...baseTask, - title: kanbanTask.title ?? baseTask.title, - description: kanbanTask.description ?? baseTask.description, - workflow_step_id: - (kanbanTask.workflowStepId as string | undefined) ?? baseTask.workflow_step_id, - position: kanbanTask.position ?? baseTask.position, - state: (kanbanTask.state as Task["state"] | undefined) ?? baseTask.state, - repositories: baseTask.repositories, - }; -} - -function buildTaskFromKanban( - kanbanTask: KanbanState["tasks"][number], - taskDetails: Task | null, - initialTask: Task | null, -): Task { - const prevWorkspaceId = taskDetails?.workspace_id ?? initialTask?.workspace_id; - const prevBoardId = taskDetails?.workflow_id ?? initialTask?.workflow_id; - return { - id: toTaskId(kanbanTask.id), - title: kanbanTask.title, - description: kanbanTask.description ?? "", - workflow_step_id: kanbanTask.workflowStepId, - position: kanbanTask.position, - state: kanbanTask.state ?? "CREATED", - workspace_id: prevWorkspaceId ?? toWorkspaceId(""), - workflow_id: prevBoardId ?? toWorkflowId(""), - priority: 0, - repositories: [], - created_at: "", - updated_at: kanbanTask.updatedAt ?? "", - }; + const matchingSnapshotTask = snapshotTask?.id === effectiveTaskId ? snapshotTask : null; + return matchingTaskDetails ?? matchingInitialTask ?? matchingSnapshotTask ?? null; } -export function useWorkflowStepsMapped() { - const kanbanSteps = useAppStore((state) => state.kanban.steps); +export function useWorkflowStepsMapped(workflowIdOverride?: string | null) { + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const taskQuery = useQuery({ + ...taskQueryOptions(activeTaskId ?? ""), + enabled: Boolean(activeTaskId) && !workflowIdOverride, + }); + const workflowId = workflowIdOverride ?? taskQuery.data?.workflow_id ?? null; + const stepsQuery = useQuery({ + ...workflowStepsQueryOptions(workflowId ?? ""), + enabled: Boolean(workflowId), + }); return useMemo( () => - kanbanSteps.map((s) => ({ + (stepsQuery.data ?? []).map((s) => ({ id: s.id, - name: s.title, + name: s.name, color: s.color, position: s.position, events: s.events, @@ -115,29 +75,27 @@ export function useWorkflowStepsMapped() { is_start_step: s.is_start_step, agent_profile_id: s.agent_profile_id, })), - [kanbanSteps], + [stepsQuery.data], ); } export function useSessionPanelState(effectiveSessionId: string | null | undefined) { + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeTaskQuery = useQuery({ + ...taskQueryOptions(activeTaskId ?? ""), + enabled: Boolean(activeTaskId), + }); const storeSessionState = useAppStore((state) => effectiveSessionId ? (state.taskSessions.items[effectiveSessionId]?.state ?? null) : null, ); const isSessionPassthrough = useAppStore((state) => - effectiveSessionId - ? state.taskSessions.items[effectiveSessionId]?.is_passthrough === true - : false, + effectiveSessionId ? isPassthroughSession(state.taskSessions.items[effectiveSessionId]) : false, ); // Use the task-level workflow step for the top-bar stepper. Individual sessions // may lag behind (e.g. a completed session stays at its old step), but the // task's step reflects the current workflow position and stays stable across // tab switches within the same task. - const sessionWorkflowStepId = useAppStore((state) => { - const taskId = state.tasks.activeTaskId; - if (!taskId) return null; - const task = state.kanban.tasks.find((t: { id: string }) => t.id === taskId); - return (task?.workflowStepId as string) ?? null; - }); + const sessionWorkflowStepId = activeTaskQuery.data?.workflow_step_id ?? null; const previewOpen = useAppStore((state) => effectiveSessionId ? (state.previewPanel.openBySessionId[effectiveSessionId] ?? false) : false, ); @@ -239,59 +197,62 @@ function TaskLoadErrorState() { ); } -function useTaskDetails(activeTaskId: string | null, initialTask: Task | null) { - const [taskDetails, setTaskDetails] = useState(initialTask); - const [taskLoadError, setTaskLoadError] = useState(null); - const kanbanTask = useAppStore((state) => - activeTaskId - ? (state.kanban.tasks.find( - (item: KanbanState["tasks"][number]) => item.id === activeTaskId, - ) ?? null) - : null, - ); - const effectiveTaskId = activeTaskId ?? initialTask?.id ?? null; +export function useTaskDetails(activeTaskId: string | null, initialTask: Task | null) { + const initialTaskId = initialTask?.id ?? null; + const effectiveTaskId = activeTaskId ?? initialTaskId; + const snapshotTask = useCachedWorkflowSnapshotTask(effectiveTaskId); + const taskDetailsQuery = useQuery({ + ...taskQueryOptions(effectiveTaskId ?? ""), + enabled: shouldFetchActiveTaskDetails(activeTaskId, initialTaskId), + staleTime: 0, + }); + const taskDetails = taskDetailsQuery.data?.id === effectiveTaskId ? taskDetailsQuery.data : null; const task = useMemo( - () => resolveEffectiveTask(taskDetails, initialTask, kanbanTask, effectiveTaskId), - [taskDetails, initialTask, kanbanTask, effectiveTaskId], + () => resolveEffectiveTask(taskDetails, initialTask, snapshotTask, effectiveTaskId), + [taskDetails, initialTask, snapshotTask, effectiveTaskId], ); const hasTaskDetails = hasResolvedTaskDetails({ effectiveTaskId, taskDetailsId: taskDetails?.id ?? null, - initialTaskId: initialTask?.id ?? null, + initialTaskId, + snapshotTaskId: snapshotTask?.id ?? null, }); useTasks(task?.workflow_id ?? null); useEffect(() => { - if (!activeTaskId || taskDetails?.id === activeTaskId) { - setTaskLoadError(null); - return; - } - let cancelled = false; - setTaskLoadError(null); - fetchTask(activeTaskId, { cache: "no-store" }) - .then((response) => { - if (cancelled) return; - setTaskDetails(response); - setTaskLoadError(null); - }) - .catch((error) => { - if (cancelled) return; - console.error("[TaskPageContent] Failed to load task details:", error); - setTaskLoadError(error); - }); - return () => { - cancelled = true; - }; - }, [ - activeTaskId, - taskDetails?.id, - taskDetails?.workspace_id, - taskDetails?.workflow_id, - kanbanTask, - setTaskDetails, - ]); + if (!taskDetailsQuery.isError) return; + console.error("[TaskPageContent] Failed to load task details:", taskDetailsQuery.error); + }, [taskDetailsQuery.error, taskDetailsQuery.isError]); + + return { task, taskLoadError: hasTaskDetails ? null : taskDetailsQuery.error }; +} - return { task, kanbanTask, taskLoadError: hasTaskDetails ? null : taskLoadError }; +function shouldFetchActiveTaskDetails( + activeTaskId: string | null, + initialTaskId: string | null, +): boolean { + return Boolean(activeTaskId) && activeTaskId !== initialTaskId; +} + +function useCachedWorkflowSnapshotTask(taskId: string | null): Task | null { + const queryClient = useQueryClient(); + const subscribe = useCallback( + (onStoreChange: () => void) => queryClient.getQueryCache().subscribe(onStoreChange), + [queryClient], + ); + const getSnapshot = useCallback( + () => (taskId ? taskFromWorkflowSnapshots(queryClient, taskId) : null), + [queryClient, taskId], + ); + return useSyncExternalStore(subscribe, getSnapshot, () => null); +} + +function taskFromWorkflowSnapshots(queryClient: QueryClient, taskId: string): Task | null { + for (const snapshot of workflowSnapshotQueryData(queryClient)) { + const task = snapshot.tasks.find((item) => item.id === taskId); + if (task) return task; + } + return null; } function useTaskPageData( @@ -362,7 +323,7 @@ export function TaskPageContent({ const { task, taskLoadError, agent, effectiveSessionId, repository, ensureSession } = useTaskPageData(initialTask, initialTaskId, sessionId, initialRepositories); - const workflowSteps = useWorkflowStepsMapped(); + const workflowSteps = useWorkflowStepsMapped(task?.workflow_id ?? null); const sessionPanel = useSessionPanelState(effectiveSessionId); const agentctlStatus = useSessionAgentctl(effectiveSessionId); const resumption = useSessionResumption(task?.id ?? null, effectiveSessionId); diff --git a/apps/web/components/task/task-select-helpers.test.ts b/apps/web/components/task/task-select-helpers.test.ts index a8dda13c28..d2589c8a28 100644 --- a/apps/web/components/task/task-select-helpers.test.ts +++ b/apps/web/components/task/task-select-helpers.test.ts @@ -22,6 +22,9 @@ vi.mock("@/lib/state/layout-manager", () => ({ vi.mock("@/lib/links", () => ({ replaceTaskUrl: vi.fn(), })); +vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(async () => ({ session: null })), +})); import { launchSession, type LaunchSessionResponse } from "@/lib/services/session-launch-service"; import { performLayoutSwitch, releaseLayoutToDefault } from "@/lib/state/dockview-store"; @@ -29,9 +32,65 @@ import { replaceTaskUrl } from "@/lib/links"; import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import type { TaskSession } from "@/lib/types/http"; +import { sessionId, taskId } from "@/lib/types/ids"; const NEW_TASK_ID = "task-new"; const OLD_SESSION_ID = "old-session"; +const SELECT_TASK_ID = "task-A"; +const PRIMARY_SESSION_ID = "sess-primary"; +const OTHER_TASK_SESSION_ID = "sess-other-task"; +const ENV_A = "env-A"; +const ENV_B = "env-B"; + +function makeKanbanStore(args: { + activeTaskId?: string | null; + activeSessionId: string | null; + envIds: Record; + lastSessionByTaskId?: Record; + sessionTaskIds?: Record; +}): StoreApi { + const items: Record & Pick> = {}; + for (const [sid, tid] of Object.entries(args.sessionTaskIds ?? {})) { + items[sid] = { + id: sessionId(sid), + task_id: taskId(tid), + task_environment_id: args.envIds[sid], + is_passthrough: false, + }; + } + const state = { + tasks: { + activeTaskId: args.activeTaskId ?? null, + activeSessionId: args.activeSessionId, + lastSessionByTaskId: args.lastSessionByTaskId ?? {}, + }, + taskPRs: { byTaskId: {} as Record }, + environmentIdBySessionId: args.envIds, + taskSessions: { items }, + setTaskSession: vi.fn((session: TaskSession) => { + items[session.id] = session; + }), + }; + return { + getState: () => state as unknown as AppState, + setState: vi.fn(), + subscribe: vi.fn(), + } as unknown as StoreApi; +} + +function runSelect(store: StoreApi) { + const switchToSession = vi.fn(); + selectTaskWithLayout({ + taskId: SELECT_TASK_ID, + task: { primarySessionId: PRIMARY_SESSION_ID }, + store, + switchToSession, + loadTaskSessionsForTask: vi.fn(async () => []), + setActiveTask: vi.fn(), + setPreparingTaskId: vi.fn(), + }); + return switchToSession; +} function makeStore(activeSessionId: string | null): StoreApi { const state = { @@ -66,56 +125,12 @@ function makeEnvStore( } as unknown as StoreApi; } -const TASK_ID = "task-A"; -const PRIMARY = "sess-primary"; const ORIGINAL_TASK_ID = "task-original"; const PENDING_TASK_ID = "task-pending"; const ORIGINAL_SESSION_ID = "sess-original"; const PENDING_SESSION_ID = "sess-pending"; const ORIGINAL_ENV_ID = "env-original"; -function makeKanbanStore(args: { - activeTaskId?: string | null; - activeSessionId: string | null; - envIds: Record; - lastSessionByTaskId?: Record; - sessionTaskIds?: Record; -}): StoreApi { - const items: Record = {}; - for (const [sid, tid] of Object.entries(args.sessionTaskIds ?? {})) { - items[sid] = { id: sid, task_id: tid }; - } - const state = { - tasks: { - activeTaskId: args.activeTaskId ?? null, - activeSessionId: args.activeSessionId, - lastSessionByTaskId: args.lastSessionByTaskId ?? {}, - }, - taskPRs: { byTaskId: {} as Record }, - environmentIdBySessionId: args.envIds, - taskSessions: { items }, - }; - return { - getState: () => state as unknown as AppState, - setState: vi.fn(), - subscribe: vi.fn(), - } as unknown as StoreApi; -} - -function runSelect(store: StoreApi) { - const switchToSession = vi.fn(); - selectTaskWithLayout({ - taskId: TASK_ID, - task: { primarySessionId: PRIMARY }, - store, - switchToSession, - loadTaskSessionsForTask: vi.fn(async () => []), - setActiveTask: vi.fn(), - setPreparingTaskId: vi.fn(), - }); - return switchToSession; -} - async function flushTaskSelection() { await Promise.resolve(); await Promise.resolve(); @@ -332,37 +347,40 @@ describe("selectTaskWithLayout — last-selected session preference", () => { const LAST = "sess-gpt"; const switchToSession = runSelect( makeKanbanStore({ - activeSessionId: "sess-other-task", - envIds: { "sess-other-task": "env-B", [PRIMARY]: "env-A", [LAST]: "env-A" }, - lastSessionByTaskId: { [TASK_ID]: LAST }, + activeSessionId: OTHER_TASK_SESSION_ID, + envIds: { [OTHER_TASK_SESSION_ID]: ENV_B, [PRIMARY_SESSION_ID]: ENV_A, [LAST]: ENV_A }, + lastSessionByTaskId: { [SELECT_TASK_ID]: LAST }, + sessionTaskIds: { [PRIMARY_SESSION_ID]: SELECT_TASK_ID, [LAST]: SELECT_TASK_ID }, }), ); - expect(switchToSession).toHaveBeenCalledWith(TASK_ID, LAST, "sess-other-task"); + expect(switchToSession).toHaveBeenCalledWith(SELECT_TASK_ID, LAST, OTHER_TASK_SESSION_ID); }); it("falls back to primarySessionId when the remembered session has no env mapping", () => { const switchToSession = runSelect( makeKanbanStore({ activeSessionId: null, - envIds: { [PRIMARY]: "env-A" }, - lastSessionByTaskId: { [TASK_ID]: "sess-stale" }, + envIds: { [PRIMARY_SESSION_ID]: ENV_A }, + lastSessionByTaskId: { [SELECT_TASK_ID]: "sess-stale" }, + sessionTaskIds: { [PRIMARY_SESSION_ID]: SELECT_TASK_ID }, }), ); - expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, null); + expect(switchToSession).toHaveBeenCalledWith(SELECT_TASK_ID, PRIMARY_SESSION_ID, null); }); it("uses primarySessionId when no last-selected session is recorded for the task", () => { const switchToSession = runSelect( makeKanbanStore({ activeSessionId: null, - envIds: { [PRIMARY]: "env-A" }, + envIds: { [PRIMARY_SESSION_ID]: ENV_A }, lastSessionByTaskId: {}, + sessionTaskIds: { [PRIMARY_SESSION_ID]: SELECT_TASK_ID }, }), ); - expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, null); + expect(switchToSession).toHaveBeenCalledWith(SELECT_TASK_ID, PRIMARY_SESSION_ID, null); }); /** @@ -379,13 +397,13 @@ describe("selectTaskWithLayout — last-selected session preference", () => { const switchToSession = runSelect( makeKanbanStore({ activeSessionId: null, - envIds: { [PRIMARY]: "env-A", [POISONED]: "env-B" }, - lastSessionByTaskId: { [TASK_ID]: POISONED }, - sessionTaskIds: { [POISONED]: "task-B", [PRIMARY]: TASK_ID }, + envIds: { [PRIMARY_SESSION_ID]: ENV_A, [POISONED]: ENV_B }, + lastSessionByTaskId: { [SELECT_TASK_ID]: POISONED }, + sessionTaskIds: { [POISONED]: "task-B", [PRIMARY_SESSION_ID]: SELECT_TASK_ID }, }), ); - expect(switchToSession).toHaveBeenCalledWith(TASK_ID, PRIMARY, null); + expect(switchToSession).toHaveBeenCalledWith(SELECT_TASK_ID, PRIMARY_SESSION_ID, null); }); }); diff --git a/apps/web/components/task/task-select-helpers.ts b/apps/web/components/task/task-select-helpers.ts index a68711ae7c..53aab0af5a 100644 --- a/apps/web/components/task/task-select-helpers.ts +++ b/apps/web/components/task/task-select-helpers.ts @@ -14,9 +14,13 @@ import { } from "@/lib/state/dockview-store"; import { INTENT_PR_REVIEW } from "@/lib/state/layout-manager"; import { replaceTaskUrl } from "@/lib/links"; +import { getBrowserQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import { fetchTaskSession } from "@/lib/api/domains/session-api"; import { launchSession } from "@/lib/services/session-launch-service"; import { buildPrepareRequest } from "@/lib/services/session-launch-helpers"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { sessionHasRoutingInfo } from "@/lib/session/task-session-navigation"; const debug = createDebugLogger("dockview:task-select"); let taskSelectionSequence = 0; @@ -76,6 +80,33 @@ export function resolvePreferredSessionId(args: { return last; } +async function hydrateSessionRoutingInfo( + store: StoreApi, + sessionId: string, + session: TaskSession | undefined, +): Promise { + if (sessionHasRoutingInfo(session)) return; + try { + const response = await fetchTaskSession(sessionId, { cache: "no-store" }); + if (!response?.session) return; + store.getState().setTaskSession(response.session); + getBrowserQueryClient().setQueryData(qk.taskSession.byId(sessionId), response.session); + } catch (error) { + console.warn("[task-select] failed to hydrate session routing info", error); + } +} + +async function resolveLoadedSessionIdForSwitch( + store: StoreApi, + sessions: TaskSession[], + preferredSessionId: string, +): Promise { + const sessionId = resolveLoadedSessionId(sessions, preferredSessionId); + const session = sessions.find((item) => item.id === sessionId); + await hydrateSessionRoutingInfo(store, sessionId, session); + return sessionId; +} + export function buildSwitchToSession( store: StoreApi, setActiveSession: (taskId: string, sessionId: string) => void, @@ -123,6 +154,16 @@ function taskSelectionWasSuperseded(selectionToken: number): boolean { return selectionToken !== taskSelectionSequence; } +function debugTaskSelectionEntry(args: { + taskId: string; + primarySessionId: string | null; + oldSessionId: string | null | undefined; + prevActiveTaskId: string | null; +}) { + if (!isDebug()) return; + debug("selectTaskWithLayout: entry", args); +} + export async function prepareAndSwitchTask( taskId: string, store: StoreApi, @@ -154,7 +195,10 @@ export async function prepareAndSwitchTask( // switchEnvLayout would call saveOutgoingEnv(envA) a second time and // overwrite envA's correctly-persisted layout with the default. switchToSession(taskId, resp.session_id, null); - if ((store.getState().taskPRs.byTaskId[taskId]?.length ?? 0) > 0) { + const taskPRs = getBrowserQueryClient().getQueryData( + qk.integrations.github.taskPr(taskId), + ); + if (Array.isArray(taskPRs) && taskPRs.length > 0) { const { api, buildDefaultLayout } = useDockviewStore.getState(); if (api) buildDefaultLayout(api, INTENT_PR_REVIEW); } @@ -190,23 +234,19 @@ export function selectTaskWithLayout(params: { activeTaskChangedExternally = true; } }); - const disposeSelectionGuard = () => { - if (typeof unsubscribeSelectionGuard === "function") unsubscribeSelectionGuard(); - }; + const disposeSelectionGuard = () => unsubscribeSelectionGuard?.(); const selectionWasSuperseded = () => { if (taskSelectionWasSuperseded(selectionToken)) return true; if (activeTaskChangedExternally) return true; const activeTaskId = store.getState().tasks.activeTaskId ?? null; return activeTaskId !== startActiveTaskId && activeTaskId !== taskId; }; - if (isDebug()) { - debug("selectTaskWithLayout: entry", { - taskId, - primarySessionId: task?.primarySessionId ?? null, - oldSessionId: oldSessionId ?? null, - prevActiveTaskId: state.tasks.activeTaskId ?? null, - }); - } + debugTaskSelectionEntry({ + taskId, + primarySessionId: task?.primarySessionId ?? null, + oldSessionId: oldSessionId ?? null, + prevActiveTaskId: state.tasks.activeTaskId ?? null, + }); if (task?.primarySessionId) { const targetSessionId = resolvePreferredSessionId({ taskId, @@ -215,8 +255,9 @@ export function selectTaskWithLayout(params: { environmentIdBySessionId: state.environmentIdBySessionId, taskSessionsById: state.taskSessions.items, }); - const hasEnvId = !!state.environmentIdBySessionId[targetSessionId]; - if (hasEnvId) { + const hasEnvId = Boolean(state.environmentIdBySessionId[targetSessionId]); + const hasRoutingInfo = sessionHasRoutingInfo(state.taskSessions.items[targetSessionId]); + if (hasEnvId && hasRoutingInfo) { disposeSelectionGuard(); switchToSession(taskId, targetSessionId, oldSessionId); loadTaskSessionsForTask(taskId); @@ -224,9 +265,11 @@ export function selectTaskWithLayout(params: { return; } void loadTaskSessionsForTask(taskId) - .then((sessions) => { + .then(async (sessions) => { if (selectionWasSuperseded()) return; - switchToSession(taskId, resolveLoadedSessionId(sessions, targetSessionId), oldSessionId); + const sessionId = await resolveLoadedSessionIdForSwitch(store, sessions, targetSessionId); + if (selectionWasSuperseded()) return; + switchToSession(taskId, sessionId, oldSessionId); replaceTaskUrl(taskId); }) .finally(disposeSelectionGuard) @@ -241,6 +284,12 @@ export function selectTaskWithLayout(params: { const primary = sessions.find((s) => s.is_primary); const sessionId = primary?.id ?? sessions[0]?.id ?? null; if (sessionId) { + await hydrateSessionRoutingInfo( + store, + sessionId, + sessions.find((item) => item.id === sessionId), + ); + if (selectionWasSuperseded()) return; switchToSession(taskId, sessionId, currentOldSessionId); replaceTaskUrl(taskId); return; diff --git a/apps/web/components/task/task-select-routing-hydration.test.ts b/apps/web/components/task/task-select-routing-hydration.test.ts new file mode 100644 index 0000000000..9454ecece8 --- /dev/null +++ b/apps/web/components/task/task-select-routing-hydration.test.ts @@ -0,0 +1,178 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StoreApi } from "zustand"; +import { fetchTaskSession } from "@/lib/api/domains/session-api"; +import type { AppState } from "@/lib/state/store"; +import type { TaskSession } from "@/lib/types/http"; +import { sessionId, taskId } from "@/lib/types/ids"; +import { selectTaskWithLayout } from "./task-select-helpers"; + +vi.mock("@/lib/services/session-launch-service", () => ({ + launchSession: vi.fn(), +})); +vi.mock("@/lib/services/session-launch-helpers", () => ({ + buildPrepareRequest: vi.fn(() => ({ request: { taskId: "task-new" } })), +})); +vi.mock("@/lib/state/dockview-store", () => ({ + performLayoutSwitch: vi.fn(), + releaseLayoutToDefault: vi.fn(), + useDockviewStore: { getState: () => ({ api: null, buildDefaultLayout: vi.fn() }) }, +})); +vi.mock("@/lib/state/layout-manager", () => ({ + INTENT_PR_REVIEW: "pr-review", +})); +vi.mock("@/lib/links", () => ({ + replaceTaskUrl: vi.fn(), +})); +vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(async () => ({ session: null })), +})); + +const SELECT_TASK_ID = "task-A"; +const PRIMARY_SESSION_ID = "sess-primary"; +const OTHER_TASK_SESSION_ID = "sess-other-task"; +const ENV_A = "env-A"; +const ENV_B = "env-B"; + +function makeKanbanStore(args: { + activeSessionId: string | null; + envIds: Record; + sessionTaskIds?: Record; +}): StoreApi { + const items: Record & Pick> = {}; + for (const [sid, tid] of Object.entries(args.sessionTaskIds ?? {})) { + items[sid] = { + id: sessionId(sid), + task_id: taskId(tid), + task_environment_id: args.envIds[sid], + is_passthrough: false, + }; + } + const state = { + tasks: { + activeTaskId: null, + activeSessionId: args.activeSessionId, + lastSessionByTaskId: {}, + }, + taskPRs: { byTaskId: {} as Record }, + environmentIdBySessionId: args.envIds, + taskSessions: { items }, + setTaskSession: vi.fn((session: TaskSession) => { + items[session.id] = session; + }), + }; + return { + getState: () => state as unknown as AppState, + setState: vi.fn(), + subscribe: vi.fn(), + } as unknown as StoreApi; +} + +describe("selectTaskWithLayout — session routing hydration", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("hydrates before switching when the target session has only partial WS fields", async () => { + let resolveLoad: (sessions: TaskSession[]) => void = () => {}; + const loadPromise = new Promise((resolve) => { + resolveLoad = resolve; + }); + const loadTaskSessionsForTask = vi.fn((_: string) => loadPromise); + const switchToSession = vi.fn(); + const store = makeKanbanStore({ + activeSessionId: OTHER_TASK_SESSION_ID, + envIds: { [OTHER_TASK_SESSION_ID]: ENV_B, [PRIMARY_SESSION_ID]: ENV_A }, + sessionTaskIds: { [PRIMARY_SESSION_ID]: SELECT_TASK_ID }, + }); + const state = store.getState(); + state.taskSessions.items[PRIMARY_SESSION_ID] = { + id: PRIMARY_SESSION_ID, + task_id: SELECT_TASK_ID, + task_environment_id: ENV_A, + } as TaskSession; + + selectTaskWithLayout({ + taskId: SELECT_TASK_ID, + task: { primarySessionId: PRIMARY_SESSION_ID }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask: vi.fn(), + setPreparingTaskId: vi.fn(), + }); + + expect(loadTaskSessionsForTask).toHaveBeenCalledWith(SELECT_TASK_ID); + expect(switchToSession).not.toHaveBeenCalled(); + + resolveLoad([ + { + id: PRIMARY_SESSION_ID, + task_id: SELECT_TASK_ID, + task_environment_id: ENV_A, + is_passthrough: true, + } as TaskSession, + ]); + await loadPromise; + await Promise.resolve(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(switchToSession).toHaveBeenCalledWith( + SELECT_TASK_ID, + PRIMARY_SESSION_ID, + OTHER_TASK_SESSION_ID, + ); + }); + + it("fetches session detail before switching when loaded task sessions still lack routing fields", async () => { + const loadTaskSessionsForTask = vi.fn(async () => [ + { + id: PRIMARY_SESSION_ID, + task_id: SELECT_TASK_ID, + task_environment_id: ENV_A, + is_primary: true, + } as TaskSession, + ]); + vi.mocked(fetchTaskSession).mockResolvedValueOnce({ + session: { + id: PRIMARY_SESSION_ID, + task_id: SELECT_TASK_ID, + task_environment_id: ENV_A, + is_passthrough: true, + } as TaskSession, + }); + const switchToSession = vi.fn(); + const store = makeKanbanStore({ + activeSessionId: OTHER_TASK_SESSION_ID, + envIds: { [OTHER_TASK_SESSION_ID]: ENV_B, [PRIMARY_SESSION_ID]: ENV_A }, + sessionTaskIds: { [PRIMARY_SESSION_ID]: SELECT_TASK_ID }, + }); + const state = store.getState(); + state.taskSessions.items[PRIMARY_SESSION_ID] = { + id: PRIMARY_SESSION_ID, + task_id: SELECT_TASK_ID, + task_environment_id: ENV_A, + } as TaskSession; + + selectTaskWithLayout({ + taskId: SELECT_TASK_ID, + task: { primarySessionId: PRIMARY_SESSION_ID }, + store, + switchToSession, + loadTaskSessionsForTask, + setActiveTask: vi.fn(), + setPreparingTaskId: vi.fn(), + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(fetchTaskSession).toHaveBeenCalledWith(PRIMARY_SESSION_ID, { cache: "no-store" }); + expect(state.setTaskSession).toHaveBeenCalledWith( + expect.objectContaining({ id: PRIMARY_SESSION_ID, is_passthrough: true }), + ); + expect(switchToSession).toHaveBeenCalledWith( + SELECT_TASK_ID, + PRIMARY_SESSION_ID, + OTHER_TASK_SESSION_ID, + ); + }); +}); diff --git a/apps/web/components/task/task-session-sidebar-aggregate.test.ts b/apps/web/components/task/task-session-sidebar-aggregate.test.ts index 0736a63428..531a4fa263 100644 --- a/apps/web/components/task/task-session-sidebar-aggregate.test.ts +++ b/apps/web/components/task/task-session-sidebar-aggregate.test.ts @@ -51,7 +51,7 @@ function makeSnapshot(steps: SidebarStepInfo[], tasks: KanbanTask[]) { describe("aggregateSidebarTasks", () => { it("returns empty result for empty snapshots and no active workflow", () => { - const result = aggregateSidebarTasks({}, null, [], []); + const result = aggregateSidebarTasks({}); expect(result.allTasks).toEqual([]); expect(result.allSteps).toEqual([]); expect(result.stepsByWorkflowId).toEqual({}); @@ -62,7 +62,7 @@ describe("aggregateSidebarTasks", () => { "wf-1": makeSnapshot([makeStep("s1", 0), makeStep("s2", 1)], [makeTask("t1", "s1")]), "wf-2": makeSnapshot([makeStep("s3", 0)], [makeTask("t2", "s3")]), }; - const result = aggregateSidebarTasks(snapshots, null, [], []); + const result = aggregateSidebarTasks(snapshots); expect(result.allTasks).toHaveLength(2); expect(result.allTasks.find((t) => t.id === "t1")?._workflowId).toBe("wf-1"); expect(result.allTasks.find((t) => t.id === "t2")?._workflowId).toBe("wf-2"); @@ -74,51 +74,25 @@ describe("aggregateSidebarTasks", () => { const snapshots: WorkflowSnapshotMap = { "wf-1": makeSnapshot([makeStep("s2", 1), makeStep("s1", 0), makeStep("s3", 2)], []), }; - const result = aggregateSidebarTasks(snapshots, null, [], []); + const result = aggregateSidebarTasks(snapshots); expect(result.stepsByWorkflowId["wf-1"].map((s) => s.id)).toEqual(["s1", "s2", "s3"]); }); - it("does not apply active-kanban fallback when activeWorkflowId is null", () => { - const result = aggregateSidebarTasks({}, null, [makeTask("t1", "s1")], [makeStep("s1", 0)]); - expect(result.allTasks).toEqual([]); - expect(result.allSteps).toEqual([]); - }); - - it("injects tasks from active kanban when the workflow has no snapshot", () => { - const result = aggregateSidebarTasks({}, "wf-1", [makeTask("t1", "s1")], [makeStep("s1", 0)]); - expect(result.allTasks).toHaveLength(1); - expect(result.allTasks[0].id).toBe("t1"); - expect(result.allTasks[0]._workflowId).toBe("wf-1"); - expect(result.stepsByWorkflowId["wf-1"]).toHaveLength(1); - expect(result.allSteps).toHaveLength(1); - }); - - it("does not duplicate tasks already present in the snapshot", () => { + it("uses tasks already present in the snapshot", () => { const snapshots: WorkflowSnapshotMap = { "wf-1": makeSnapshot([makeStep("s1", 0)], [makeTask("t1", "s1", { title: "from snapshot" })]), }; - const result = aggregateSidebarTasks( - snapshots, - "wf-1", - [makeTask("t1", "s1", { title: "from active" }), makeTask("t2", "s1")], - [makeStep("s1", 0)], - ); - expect(result.allTasks).toHaveLength(2); - const t1 = result.allTasks.find((t) => t.id === "t1"); - expect(t1?.title).toBe("from snapshot"); - expect(result.allTasks.find((t) => t.id === "t2")).toBeDefined(); + const result = aggregateSidebarTasks(snapshots); + expect(result.allTasks).toEqual([ + expect.objectContaining({ id: "t1", title: "from snapshot" }), + ]); }); - it("does not overwrite snapshot steps when the workflow already has a snapshot", () => { + it("uses snapshot steps for each workflow", () => { const snapshots: WorkflowSnapshotMap = { "wf-1": makeSnapshot([makeStep("s1", 0, { title: "Snapshot Step" })], []), }; - const result = aggregateSidebarTasks( - snapshots, - "wf-1", - [], - [makeStep("s1", 0, { title: "Active Step" })], - ); + const result = aggregateSidebarTasks(snapshots); expect(result.stepsByWorkflowId["wf-1"][0].title).toBe("Snapshot Step"); }); @@ -128,7 +102,7 @@ describe("aggregateSidebarTasks", () => { "wf-1": makeSnapshot([sharedStep], []), "wf-2": makeSnapshot([sharedStep], []), }; - const result = aggregateSidebarTasks(snapshots, null, [], []); + const result = aggregateSidebarTasks(snapshots); expect(result.allSteps.filter((s) => s.id === "shared")).toHaveLength(1); }); @@ -137,25 +111,9 @@ describe("aggregateSidebarTasks", () => { "wf-1": makeSnapshot([makeStep("a", 5), makeStep("b", 1)], []), "wf-2": makeSnapshot([makeStep("c", 3)], []), }; - const result = aggregateSidebarTasks(snapshots, null, [], []); + const result = aggregateSidebarTasks(snapshots); expect(result.allSteps.map((s) => s.position)).toEqual([1, 3, 5]); }); - - it("drops active-kanban tasks whose workflowStepId is not in the active steps", () => { - // Repro for workspace-switch-sidebar-isolation: SSR hydration on the - // session page refreshes kanban.steps to workspace B's steps, but - // hydrator's mergeKanbanTasks accumulates kanban.tasks across workflow - // switches so workspace A's tasks (referencing A's stepIds) linger. - // Before the fix, the fallback re-tagged them with workspace B's - // workflow id and they leaked into the sidebar. - const result = aggregateSidebarTasks( - {}, - "wf-B", - [makeTask("task-A", "step-from-A"), makeTask("task-B", "step-B")], - [makeStep("step-B", 0)], - ); - expect(result.allTasks.map((t) => t.id)).toEqual(["task-B"]); - }); }); describe("buildPendingFlags / readPendingFlags", () => { diff --git a/apps/web/components/task/task-session-sidebar-aggregate.ts b/apps/web/components/task/task-session-sidebar-aggregate.ts index 6751af38ad..80da271cca 100644 --- a/apps/web/components/task/task-session-sidebar-aggregate.ts +++ b/apps/web/components/task/task-session-sidebar-aggregate.ts @@ -99,42 +99,11 @@ function collectSnapshotTasks(snapshots: WorkflowSnapshotMap, acc: Acc): void { } } -function applyActiveKanbanFallback( - activeWorkflowId: string, - activeTasks: KanbanState["tasks"], - activeSteps: KanbanState["steps"], - acc: Acc, -): void { - if (!acc.wfSteps[activeWorkflowId] && activeSteps.length > 0) { - for (const step of activeSteps) if (!acc.stepMap.has(step.id)) acc.stepMap.set(step.id, step); - acc.wfSteps[activeWorkflowId] = [...activeSteps].sort((a, b) => a.position - b.position); - } - // Hydrator's mergeKanbanTasks accumulates tasks across workflow switches, so - // activeTasks can carry stale entries whose workflowStepId references a step - // from another workspace's workflow. Filter to current step membership so - // those leaks don't get re-tagged with the active workflow id. - const activeStepIds = new Set(activeSteps.map((s) => s.id)); - for (const t of activeTasks) { - if (acc.seen.has(t.id)) continue; - if (activeStepIds.size > 0 && !activeStepIds.has(t.workflowStepId)) continue; - acc.tasks.push({ ...t, _workflowId: activeWorkflowId }); - acc.seen.add(t.id); - } -} - /** - * Aggregate the sidebar's task/step view across all loaded workflow snapshots, - * with a fallback to the active `kanban` slice. The fallback is essential - * because `task.created` WS events arriving before `fetchWorkflowSnapshot` - * completes are dropped from `kanbanMulti.snapshots` and would otherwise be - * invisible until the next snapshot refresh. + * Aggregate the sidebar's task/step view across all loaded workflow snapshot + * query caches. */ -export function aggregateSidebarTasks( - snapshots: WorkflowSnapshotMap, - activeWorkflowId: string | null, - activeTasks: KanbanState["tasks"], - activeSteps: KanbanState["steps"], -): AggregatedSidebarTasks { +export function aggregateSidebarTasks(snapshots: WorkflowSnapshotMap): AggregatedSidebarTasks { const acc: Acc = { tasks: [], seen: new Set(), @@ -142,9 +111,6 @@ export function aggregateSidebarTasks( wfSteps: {}, }; collectSnapshotTasks(snapshots, acc); - if (activeWorkflowId) { - applyActiveKanbanFallback(activeWorkflowId, activeTasks, activeSteps, acc); - } const allSteps = [...acc.stepMap.values()].sort((a, b) => a.position - b.position); return { allTasks: acc.tasks, allSteps, stepsByWorkflowId: acc.wfSteps }; } diff --git a/apps/web/components/task/task-session-sidebar-link-actions.ts b/apps/web/components/task/task-session-sidebar-link-actions.ts index 9501a9356b..49cb300fdb 100644 --- a/apps/web/components/task/task-session-sidebar-link-actions.ts +++ b/apps/web/components/task/task-session-sidebar-link-actions.ts @@ -1,17 +1,8 @@ "use client"; import { useCallback, useState } from "react"; -import type { KanbanState } from "@/lib/state/slices"; -import { findTaskInSnapshots } from "@/lib/kanban/find-task"; import type { ExternalLinkProvider } from "./task-external-link-dialog"; -type StoreApi = { - getState: () => { - kanbanMulti: { snapshots: Record }; - kanban: { tasks: KanbanState["tasks"] }; - }; -}; - export type SidebarLinkTarget = { id: string; title: string; @@ -26,7 +17,7 @@ export type SidebarExternalLinkTarget = { task: SidebarLinkTarget; }; -export function useSidebarLinkActions(store: StoreApi) { +export function useSidebarLinkActions(taskById: ReadonlyMap) { const [linkingPullRequestTask, setLinkingPullRequestTask] = useState( null, ); @@ -36,8 +27,7 @@ export function useSidebarLinkActions(store: StoreApi) { const getLinkTarget = useCallback( (taskId: string, fallbackTitle?: string): SidebarLinkTarget => { - const state = store.getState(); - const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + const task = taskById.get(taskId); return { id: taskId, title: task?.title ?? fallbackTitle ?? "this task", @@ -47,7 +37,7 @@ export function useSidebarLinkActions(store: StoreApi) { repositories: task?.repositories, }; }, - [store], + [taskById], ); const handleLinkPullRequestTask = useCallback( diff --git a/apps/web/components/task/task-session-sidebar-messages.test.tsx b/apps/web/components/task/task-session-sidebar-messages.test.tsx new file mode 100644 index 0000000000..8b4ac12f10 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-messages.test.tsx @@ -0,0 +1,58 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { taskId as toTaskId, type Message } from "@/lib/types/http"; +import { useSidebarMessagesBySession } from "./task-session-sidebar-messages"; + +const mockListTaskSessionMessages = vi.fn(); + +vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(), + getQueueStatus: vi.fn(), + listSessionTurns: vi.fn(), + listTaskSessionMessages: (...args: unknown[]) => mockListTaskSessionMessages(...args), + listTaskSessions: vi.fn(), + searchSessionMessages: vi.fn(), +})); + +function wrapper({ children }: { children: ReactNode }) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, + }); + return createElement(QueryClientProvider, { client }, children); +} + +function makeMessage(sessionId: string): Message { + return { + id: `message-${sessionId}`, + task_id: toTaskId("task-1"), + session_id: sessionId, + author_type: "agent", + content: sessionId, + type: "message", + created_at: "2026-01-01T00:00:00Z", + } as Message; +} + +beforeEach(() => { + mockListTaskSessionMessages.mockReset(); +}); + +describe("useSidebarMessagesBySession", () => { + it("does not pass Array.map indexes as message limits", async () => { + mockListTaskSessionMessages.mockImplementation(async (sessionId: string) => ({ + messages: [makeMessage(sessionId)], + has_more: false, + cursor: null, + })); + + renderHook(() => useSidebarMessagesBySession(["session-a", "session-b"]), { wrapper }); + + await waitFor(() => expect(mockListTaskSessionMessages).toHaveBeenCalledTimes(2)); + expect(mockListTaskSessionMessages.mock.calls.map(([id, params]) => [id, params])).toEqual([ + ["session-a", { limit: 100, sort: "desc" }], + ["session-b", { limit: 100, sort: "desc" }], + ]); + }); +}); diff --git a/apps/web/components/task/task-session-sidebar-messages.ts b/apps/web/components/task/task-session-sidebar-messages.ts new file mode 100644 index 0000000000..2c64e1033e --- /dev/null +++ b/apps/web/components/task/task-session-sidebar-messages.ts @@ -0,0 +1,19 @@ +"use client"; + +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import type { Message } from "@/lib/types/http"; +import { sessionMessagesLatestQueryOptions } from "@/lib/query/query-options"; + +export function useSidebarMessagesBySession( + sessionIds: string[], +): Record { + const queries = useQueries({ + queries: sessionIds.map((id) => sessionMessagesLatestQueryOptions(id)), + }); + + return useMemo>( + () => Object.fromEntries(sessionIds.map((id, i) => [id, queries[i]?.data?.messages])), + [queries, sessionIds], + ); +} diff --git a/apps/web/components/task/task-session-sidebar.test.tsx b/apps/web/components/task/task-session-sidebar.test.tsx new file mode 100644 index 0000000000..d3cc139dd3 --- /dev/null +++ b/apps/web/components/task/task-session-sidebar.test.tsx @@ -0,0 +1,346 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { ToastProvider } from "@/components/toast-provider"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import type { Message, Task, WorkflowSnapshot } from "@/lib/types/http"; +import { sessionId, taskId, workflowId, workspaceId } from "@/lib/types/ids"; +import { TaskSessionSidebar } from "./task-session-sidebar"; + +const WORKSPACE_ID = workspaceId("workspace-1"); +const WORKFLOW_ID = workflowId("workflow-1"); +const STEP_TODO = "step-todo"; +const STEP_DONE = "step-done"; +const PRIMARY_SESSION_ID = "session-1"; +const CREATED_AT = "2026-06-24T00:00:00Z"; + +const actionMocks = vi.hoisted(() => ({ + archiveAndSwitch: vi.fn(), + deleteTaskById: vi.fn(), + moveTaskById: vi.fn(), + renameTaskById: vi.fn(), +})); + +vi.mock("@/lib/routing/client-router", () => ({ + usePathname: () => "/t/task-1", + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("@/hooks/use-task-actions", () => ({ + useArchiveAndSwitchTask: () => actionMocks.archiveAndSwitch, + useTaskActions: () => ({ + archiveTaskById: actionMocks.archiveAndSwitch, + deleteTaskById: actionMocks.deleteTaskById, + moveTaskById: actionMocks.moveTaskById, + renameTaskById: actionMocks.renameTaskById, + }), +})); + +vi.mock("@/hooks/domains/kanban/use-workspace-sidebar-tasks", () => ({ + useWorkspaceSidebarTasks: () => ({ + allTasks: [ + { + id: "task-1", + title: "Query task", + state: "TODO", + workflowId: WORKFLOW_ID, + workflowStepId: STEP_TODO, + primaryExecutorType: "remote-docker", + primarySessionId: PRIMARY_SESSION_ID, + createdAt: CREATED_AT, + updatedAt: CREATED_AT, + _workflowId: WORKFLOW_ID, + }, + ], + allSteps: [ + { id: STEP_TODO, title: "Todo", color: "bg-blue-500", position: 0 }, + { id: STEP_DONE, title: "Done", color: "bg-green-500", position: 1 }, + ], + stepsByWorkflowId: { + [WORKFLOW_ID]: [ + { id: STEP_TODO, title: "Todo", color: "bg-blue-500", position: 0 }, + { id: STEP_DONE, title: "Done", color: "bg-green-500", position: 1 }, + ], + }, + workflows: [{ id: WORKFLOW_ID, name: "Build", hidden: false }], + isLoading: false, + }), +})); + +vi.mock("@/hooks/domains/workspace/use-repository-cache", () => ({ + useCachedRepositories: () => [], +})); + +vi.mock("@/hooks/domains/workspace/use-repositories", () => ({ + useRepositories: () => ({ data: [] }), +})); + +vi.mock("@/hooks/domains/github/use-task-pr", () => ({ + useWorkspacePRs: () => ({}), +})); + +vi.mock("@/hooks/domains/sidebar/use-effective-sidebar-view", () => ({ + useEffectiveSidebarView: () => ({ + id: "all", + name: "All tasks", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + groupBy: "none", + collapsedGroups: [], + }), +})); + +vi.mock("@/hooks/domains/sidebar/use-sidebar-task-prefs", () => ({ + useSidebarTaskPrefs: () => ({ + pinnedTaskIds: [], + orderedTaskIds: [], + subtaskOrderByParentId: {}, + togglePinnedTask: vi.fn(), + handleReorderGroup: vi.fn(), + handleReorderSubtasks: vi.fn(), + }), +})); + +vi.mock("@/lib/sidebar/apply-view", () => ({ + applyView: (tasks: unknown[]) => ({ + groups: [{ key: "all", label: "All tasks", tasks }], + subTasksByParentId: new Map(), + }), + flattenVisibleTaskIds: (grouped: { + groups: Array<{ tasks: Array<{ id: string }> }>; + subTasksByParentId: Map>; + }) => grouped.groups.flatMap((group) => group.tasks.map((task) => task.id)), + computeMixedWorkflowSelection: ( + displayTasks: Array<{ id: string; workflowId?: string }>, + selectedIds: Set, + ) => { + const wfIds = new Set(); + const movableSelectedIds = new Set(); + let hasWorkflowless = false; + for (const task of displayTasks) { + if (!selectedIds.has(task.id)) continue; + if (task.workflowId) { + wfIds.add(task.workflowId); + movableSelectedIds.add(task.id); + } else { + hasWorkflowless = true; + } + } + return { isMixedWorkflowSelection: hasWorkflowless || wfIds.size > 1, movableSelectedIds }; + }, + sortIdsByVisibleOrder: (ids: string[], visibleTaskIds: string[]) => { + const order = new Map(visibleTaskIds.map((id, index) => [id, index])); + return [...ids].sort( + (a, b) => + (order.get(a) ?? Number.POSITIVE_INFINITY) - (order.get(b) ?? Number.POSITIVE_INFINITY), + ); + }, +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => null, +})); + +vi.mock("./task-archived-context", () => ({ + useArchivedTaskState: () => ({ isArchived: false }), +})); + +vi.mock("./task-switcher", () => ({ + TaskSwitcher: ({ + grouped, + onArchiveTask, + onMoveToStep, + }: { + grouped: { + groups: Array<{ + tasks: Array<{ id: string; title: string; hasPendingPermission?: boolean }>; + }>; + subTasksByParentId: Map>; + }; + onArchiveTask: (taskId: string) => void; + onMoveToStep: (taskId: string, workflowId: string, targetStepId: string) => void; + }) => { + const tasks = grouped.groups.flatMap((group) => group.tasks); + return ( +
+ {tasks.map((task) => ( +
+ {task.title} +
+ ))} + + +
+ ); + }, +})); + +vi.mock("./task-session-sidebar-dialogs", () => ({ + SidebarDialogs: ({ actions }: { actions: { archivingTask: null | { title: string } } }) => ( +
+ ), +})); + +function rawTask(id: string, stepId = STEP_TODO, position = 0): Task { + return { + id: taskId(id), + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: stepId, + position, + title: id, + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: CREATED_AT, + updated_at: CREATED_AT, + } as Task; +} + +function snapshot(tasks: Task[]): WorkflowSnapshot { + return { + workflow: { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 0, + hidden: false, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + steps: [ + { + id: STEP_TODO, + workflow_id: WORKFLOW_ID, + name: "Todo", + position: 0, + color: "bg-blue-500", + }, + { + id: STEP_DONE, + workflow_id: WORKFLOW_ID, + name: "Done", + position: 1, + color: "bg-green-500", + }, + ], + tasks, + } as WorkflowSnapshot; +} + +function pendingPermissionMessage(): Message { + return { + id: "message-1", + session_id: sessionId(PRIMARY_SESSION_ID), + task_id: taskId("task-1"), + turn_id: "turn-1", + author_type: "agent", + author_id: "agent-1", + content: "permission", + type: "permission_request", + metadata: { status: "pending", pending_id: "pending-1" }, + created_at: CREATED_AT, + updated_at: CREATED_AT, + } as Message; +} + +function renderSidebar( + setupQueryClient?: (queryClient: ReturnType) => void, +) { + const queryClient = makeQueryClient(); + queryClient.setQueryData( + qk.workflows.snapshot(WORKFLOW_ID), + snapshot([rawTask("task-1", STEP_TODO, 0), rawTask("task-2", STEP_DONE, 0)]), + ); + setupQueryClient?.(queryClient); + const initialState = { + workspaces: { activeId: WORKSPACE_ID }, + workflows: { activeId: WORKFLOW_ID }, + tasks: { activeTaskId: "task-1", activeSessionId: null }, + messages: { bySession: {}, metaBySession: {} }, + kanban: { workflowId: null, steps: [], tasks: [] }, + kanbanMulti: { snapshots: {} }, + } as unknown as Partial; + + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + + const view = render( + , + { wrapper }, + ); + return { ...view, queryClient }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => cleanup()); + +describe("TaskSessionSidebar Query metadata", () => { + it("opens archive metadata from Query-owned sidebar tasks when legacy kanban is empty", async () => { + renderSidebar(); + + await act(async () => { + screen.getByTestId("archive-task").click(); + }); + + expect(screen.getByTestId("sidebar-dialogs").dataset.archiveTitle).toBe("Query task"); + }); + + it("moves sidebar tasks through workflow snapshot Query caches when legacy kanbanMulti is empty", async () => { + const { queryClient } = renderSidebar(); + + await act(async () => { + screen.getByTestId("move-task").click(); + }); + + expect(actionMocks.moveTaskById).toHaveBeenCalledWith("task-1", { + workflow_id: WORKFLOW_ID, + workflow_step_id: STEP_DONE, + position: 1, + }); + expect( + queryClient + .getQueryData(qk.workflows.snapshot(WORKFLOW_ID)) + ?.tasks.find((task) => task.id === "task-1")?.workflow_step_id, + ).toBe(STEP_DONE); + }); + + it("derives pending permission state from Query-owned session messages", () => { + renderSidebar((queryClient) => { + queryClient.setQueryData(qk.session.messages(PRIMARY_SESSION_ID), { + messages: [pendingPermissionMessage()], + hasMore: false, + oldestCursor: "message-1", + }); + }); + + expect(screen.getByTestId("sidebar-task-task-1").getAttribute("data-pending-permission")).toBe( + "true", + ); + }); +}); diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 7ec4b149b7..31f3e9961d 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState, memo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { usePathname, useRouter } from "@/lib/routing/client-router"; import { linkToTask } from "@/lib/links"; import type { Repository, TaskSession, TaskSessionState, TaskState } from "@/lib/types/http"; @@ -14,10 +15,10 @@ import { SidebarDialogs } from "./task-session-sidebar-dialogs"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks"; +import { useCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions"; import { useSidebarSelection, SidebarBulkDialogs } from "./task-session-sidebar-selection"; import { useTaskRemoval } from "@/hooks/use-task-removal"; -import { findTaskInSnapshots } from "@/lib/kanban/find-task"; import { repositorySlug } from "@/lib/repository-slug"; import { buildSwitchToSession, selectTaskWithLayout } from "./task-select-helpers"; import { getSessionInfoForTask } from "@/lib/utils/session-info"; @@ -27,23 +28,27 @@ import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr"; import { buildPendingFlags, readPendingFlags } from "./task-session-sidebar-aggregate"; import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view"; -import { useSidebarLinkActions } from "./task-session-sidebar-link-actions"; +import { useSidebarLinkActions, type SidebarLinkTarget } from "./task-session-sidebar-link-actions"; import { buildArchivedSidebarItem } from "./task-session-sidebar-archived-item"; import { useSidebarTaskLinking } from "./task-session-sidebar-task-linking"; -import { useShallow } from "zustand/react/shallow"; import { type AgentErrorOptions, agentErrorMessageForTask } from "@/lib/task-agent-error"; import { - agentErrorAcknowledgementSessionIds, stablePrimarySessionIdsKey, - usePersistResolvedAgentErrorAcknowledgements, + usePersistTaskAgentErrorAcknowledgements, } from "./use-agent-error-acknowledgements"; - -function useStablePrimarySessionIds(allTasks: Array<{ primarySessionId?: string | null }>) { +import { + updateWorkflowSnapshotQuery, + workflowSnapshotQueryDataForWorkflow, +} from "@/lib/query/workflow-snapshot-cache"; +import { useSidebarMessagesBySession } from "./task-session-sidebar-messages"; + +function useStablePrimarySessionIds( + allTasks: Array<{ primarySessionId?: string | null }>, +): string[] { const key = useMemo(() => stablePrimarySessionIdsKey(allTasks), [allTasks]); return useMemo(() => (key ? key.split("\0") : []), [key]); } -/** Look up git status directly via primarySessionId, bypassing the session list. */ function getGitStatusForTask( task: { primarySessionId?: string | null }, envIdBySessionId: Record, @@ -70,7 +75,6 @@ function resolveDiffStats( return a > 0 || d > 0 ? { additions: a, deletions: d } : undefined; } -/** Format PR info for display, capitalising the state. */ function toPrInfo(pr: TaskPR | undefined): { number: number; state: string } | undefined { if (!pr?.state) return undefined; return { number: pr.pr_number, state: pr.state[0].toUpperCase() + pr.state.slice(1) }; @@ -158,6 +162,38 @@ function toSidebarItem( }; } +function buildSidebarLinkTargetMap( + tasks: Array, +): Map { + return new Map( + tasks.map((task) => [ + task.id, + { + id: task.id, + title: task.title, + repositoryId: task.repositoryId, + issueUrl: task.issueUrl, + issueNumber: task.issueNumber, + repositories: task.repositories, + }, + ]), + ); +} + +function includeArchivedSidebarItem( + items: TaskSwitcherItem[], + archivedState: ReturnType, +): TaskSwitcherItem[] { + if ( + !archivedState.isArchived || + !archivedState.archivedTaskId || + items.some((task) => task.id === archivedState.archivedTaskId) + ) { + return items; + } + return [buildArchivedSidebarItem(archivedState), ...items]; +} + type TaskSessionSidebarProps = { workspaceId: string | null; workflowId: string | null; @@ -172,9 +208,8 @@ function useSidebarData(workspaceId: string | null) { const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId); const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId); const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); - const taskPRsByTaskId = useAppStore((state) => state.taskPRs.byTaskId); - const messagesBySession = useAppStore((state) => state.messages.bySession); + const repositories = useCachedRepositories(workspaceId); + const taskPRsByTaskId = useWorkspacePRs(workspaceId); const dismissedAgentErrors = useAppStore((state) => state.dismissedAgentErrors); const acknowledgedAgentErrors = useAppStore((state) => state.acknowledgedAgentErrors); const archivedState = useArchivedTaskState(); @@ -194,22 +229,20 @@ function useSidebarData(workspaceId: string | null) { // Stable primary session IDs from kanban tasks feed subscriptions and pending-flag selectors. const primarySessionIds = useStablePrimarySessionIds(allTasks); - const acknowledgementSessionIds = useMemo( - () => agentErrorAcknowledgementSessionIds(allTasks, sessionsByTaskId), - [allTasks, sessionsByTaskId], - ); - usePersistResolvedAgentErrorAcknowledgements({ + const messagesBySession = useSidebarMessagesBySession(primarySessionIds); + usePersistTaskAgentErrorAcknowledgements({ + tasks: allTasks, + sessionsByTaskId, sessionsById, - sessionIds: acknowledgementSessionIds, messagesBySession, dismissedAgentErrors, }); - const pendingFlags = useAppStore( - useShallow((state) => buildPendingFlags(state.messages.bySession, primarySessionIds)), + const pendingFlags = useMemo( + () => buildPendingFlags(messagesBySession, primarySessionIds), + [messagesBySession, primarySessionIds], ); const tasksWithRepositories = useMemo(() => { - const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : []; const repositorySlugById = new Map( repositories.map((repo: Repository) => [repo.id, repositorySlug(repo)]), ); @@ -231,17 +264,10 @@ function useSidebarData(workspaceId: string | null) { acknowledgedAgentErrors, messagesBySession, }; - const items: TaskSwitcherItem[] = allTasks.map((task) => toSidebarItem(task, mapCtx)); - if ( - archivedState.isArchived && - archivedState.archivedTaskId && - !items.some((t) => t.id === archivedState.archivedTaskId) - ) { - items.unshift(buildArchivedSidebarItem(archivedState)); - } - return items; + const items = allTasks.map((task) => toSidebarItem(task, mapCtx)); + return includeArchivedSidebarItem(items, archivedState); }, [ - repositoriesByWorkspace, + repositories, allTasks, allSteps, workflows, @@ -257,6 +283,11 @@ function useSidebarData(workspaceId: string | null) { messagesBySession, archivedState, ]); + const taskById = useMemo( + () => new Map(tasksWithRepositories.map((task) => [task.id, task])), + [tasksWithRepositories], + ); + const linkTaskById = useMemo(() => buildSidebarLinkTargetMap(allTasks), [allTasks]); return { activeTaskId, @@ -265,6 +296,8 @@ function useSidebarData(workspaceId: string | null) { stepsByWorkflowId, isLoadingWorkflow, tasksWithRepositories, + taskById, + linkTaskById, primarySessionIds, workflows, }; @@ -272,30 +305,32 @@ function useSidebarData(workspaceId: string | null) { type StoreApi = ReturnType; -function useMoveToStep(store: StoreApi) { +function useMoveToStep() { + const queryClient = useQueryClient(); const { moveTaskById } = useTaskActions(); return useCallback( async (taskId: string, workflowId: string, targetStepId: string) => { - const state = store.getState(); - const snapshot = state.kanbanMulti.snapshots[workflowId]; + const snapshot = workflowSnapshotQueryDataForWorkflow(queryClient, workflowId); if (!snapshot) return; const originalTask = snapshot.tasks.find((t) => t.id === taskId); if (!originalTask) return; const targetTasks = snapshot.tasks - .filter((t) => t.workflowStepId === targetStepId && t.id !== taskId) - .sort((a, b) => a.position - b.position); + .filter((t) => t.workflow_step_id === targetStepId && t.id !== taskId) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); const nextPosition = targetTasks.length; - - // Optimistic update - state.setWorkflowSnapshot(workflowId, { - ...snapshot, - tasks: snapshot.tasks.map((t) => - t.id === taskId ? { ...t, workflowStepId: targetStepId, position: nextPosition } : t, + const originalSnapshot = snapshot; + + updateWorkflowSnapshotQuery(queryClient, workflowId, (current) => ({ + ...current, + tasks: current.tasks.map((task) => + task.id === taskId + ? { ...task, workflow_step_id: targetStepId, position: nextPosition } + : task, ), - }); + })); try { await moveTaskById(taskId, { @@ -304,31 +339,15 @@ function useMoveToStep(store: StoreApi) { position: nextPosition, }); } catch (error) { - // Rollback only the moved task, and only if it still has the optimistic values - const cur = store.getState().kanbanMulti.snapshots[workflowId]; - const curTask = cur?.tasks.find((t) => t.id === taskId); - if (cur && curTask?.workflowStepId === targetStepId && curTask.position === nextPosition) { - store.getState().setWorkflowSnapshot(workflowId, { - ...cur, - tasks: cur.tasks.map((t) => - t.id === taskId - ? { - ...t, - workflowStepId: originalTask.workflowStepId, - position: originalTask.position, - } - : t, - ), - }); - } + updateWorkflowSnapshotQuery(queryClient, workflowId, () => originalSnapshot); console.error("Failed to move task:", error); } }, - [store, moveTaskById], + [queryClient, moveTaskById], ); } -function useArchiveActions(store: StoreApi) { +function useArchiveActions(taskById: Map) { const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true }); const [archivingTask, setArchivingTask] = useState<{ id: string; @@ -340,15 +359,14 @@ function useArchiveActions(store: StoreApi) { const handleArchiveTask = useCallback( (taskId: string) => { - const state = store.getState(); - const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + const task = taskById.get(taskId); setArchivingTask({ id: taskId, title: task?.title ?? "this task", - executorType: task?.primaryExecutorType, + executorType: task?.remoteExecutorType, }); }, - [store], + [taskById], ); const handleArchiveConfirm = useCallback( @@ -383,6 +401,7 @@ function useArchiveActions(store: StoreApi) { function useDeleteActions( store: StoreApi, removeTaskFromBoard: ReturnType["removeTaskFromBoard"], + taskById: Map, ) { const { deleteTaskById } = useTaskActions(); const [deletingTask, setDeletingTask] = useState<{ @@ -394,15 +413,14 @@ function useDeleteActions( const handleDeleteTask = useCallback( (taskId: string) => { - const state = store.getState(); - const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + const task = taskById.get(taskId); setDeletingTask({ id: taskId, title: task?.title ?? "this task", - executorType: task?.primaryExecutorType, + executorType: task?.remoteExecutorType, }); }, - [store], + [taskById], ); const handleDeleteConfirm = useCallback( @@ -437,7 +455,11 @@ function useDeleteActions( }; } -export function useSidebarActions(store: StoreApi) { +export function useSidebarActions( + store: StoreApi, + taskById: Map, + linkTaskById: Map, +) { const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); const [preparingTaskId, setPreparingTaskId] = useState(null); @@ -468,8 +490,7 @@ export function useSidebarActions(store: StoreApi) { router.push(linkToTask(taskId)); return; } - const state = store.getState(); - const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + const task = taskById.get(taskId); selectTaskWithLayout({ taskId, task: task ?? undefined, @@ -480,12 +501,12 @@ export function useSidebarActions(store: StoreApi) { setPreparingTaskId, }); }, - [loadTaskSessionsForTask, switchToSession, setActiveTask, store, router, pathname], + [loadTaskSessionsForTask, switchToSession, setActiveTask, store, router, pathname, taskById], ); - const archiveActions = useArchiveActions(store); - const deleteActions = useDeleteActions(store, removeTaskFromBoard); - const linkActions = useSidebarLinkActions(store); + const archiveActions = useArchiveActions(taskById); + const deleteActions = useDeleteActions(store, removeTaskFromBoard, taskById); + const linkActions = useSidebarLinkActions(linkTaskById); const [renamingTask, setRenamingTask] = useState<{ id: string; title: string } | null>(null); @@ -506,7 +527,7 @@ export function useSidebarActions(store: StoreApi) { [renamingTask, renameTaskById], ); - const handleMoveToStep = useMoveToStep(store); + const handleMoveToStep = useMoveToStep(); return { preparingTaskId, @@ -553,6 +574,8 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workflows, isLoadingWorkflow, tasksWithRepositories, + taskById, + linkTaskById, primarySessionIds, } = useSidebarData(workspaceId); @@ -564,21 +587,10 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ useBulkGitStatusSubscription(primarySessionIds); - const sidebarActions = useSidebarActions(store); - const { - deletingTaskId, - preparingTaskId, - handleSelectTask, - handleArchiveTask, - handleDeleteTask, - handleMoveToStep, - handleRenameTask, - } = sidebarActions; + const sidebarActions = useSidebarActions(store, taskById, linkTaskById); + const preparingTaskId = sidebarActions.preparingTaskId; const taskLinkHandlers = useSidebarTaskLinking(workspaceId, sidebarActions); - const repositories = - useAppStore((state) => - workspaceId ? state.repositories.itemsByWorkspaceId[workspaceId] : undefined, - ) ?? []; + const repositories = useCachedRepositories(workspaceId); const displayTasks = useMemo(() => { if (MOCK_SIDEBAR) return MOCK_ITEMS; @@ -619,17 +631,17 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ onToggleGroup={handleToggleGroup} collapsedSubtaskParentIds={collapsedSubtaskParents} onToggleSubtasks={toggleSubtaskCollapsed} - onSelectTask={handleSelectTask} - onRenameTask={handleRenameTask} - onArchiveTask={handleArchiveTask} - onDeleteTask={handleDeleteTask} + onSelectTask={sidebarActions.handleSelectTask} + onRenameTask={sidebarActions.handleRenameTask} + onArchiveTask={sidebarActions.handleArchiveTask} + onDeleteTask={sidebarActions.handleDeleteTask} {...taskLinkHandlers} - onMoveToStep={handleMoveToStep} + onMoveToStep={sidebarActions.handleMoveToStep} onTogglePin={togglePinnedTask} onReorderGroup={handleReorderGroup} onReorderSubtasks={handleReorderSubtasks} pinnedTaskIds={pinnedTaskIds} - deletingTaskId={deletingTaskId} + deletingTaskId={sidebarActions.deletingTaskId} isLoading={isLoadingWorkflow} totalTaskCount={displayTasks.length} {...selection.switcherProps} diff --git a/apps/web/components/task/task-switcher-direct-subtasks.test.tsx b/apps/web/components/task/task-switcher-direct-subtasks.test.tsx index 979a36c586..6cdb8cf789 100644 --- a/apps/web/components/task/task-switcher-direct-subtasks.test.tsx +++ b/apps/web/components/task/task-switcher-direct-subtasks.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher"; @@ -8,10 +9,13 @@ import type { GroupedSidebarList } from "@/lib/sidebar/apply-view"; afterEach(() => cleanup()); function Providers({ children }: { children: React.ReactNode }) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return ( - - {children} - + + + {children} + + ); } diff --git a/apps/web/components/task/task-switcher.test.tsx b/apps/web/components/task/task-switcher.test.tsx index da88c8f266..8e63a62a51 100644 --- a/apps/web/components/task/task-switcher.test.tsx +++ b/apps/web/components/task/task-switcher.test.tsx @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher"; @@ -9,10 +10,13 @@ import type { GroupedSidebarList } from "@/lib/sidebar/apply-view"; afterEach(() => cleanup()); function Providers({ children }: { children: React.ReactNode }) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return ( - - {children} - + + + {children} + + ); } diff --git a/apps/web/components/task/task-top-bar.test.tsx b/apps/web/components/task/task-top-bar.test.tsx index b14e9f8523..87b0d5dddb 100644 --- a/apps/web/components/task/task-top-bar.test.tsx +++ b/apps/web/components/task/task-top-bar.test.tsx @@ -19,6 +19,10 @@ vi.mock("@/components/task/executor-settings-button", () => ({ ExecutorSettingsButton: () => , })); +vi.mock("@/components/system-metrics/topbar-metrics", () => ({ + TopbarMetrics: () => null, +})); + vi.mock("@/components/task/port-forward-dialog", () => ({ PortForwardButton: () => , })); diff --git a/apps/web/components/task/use-agent-error-acknowledgements.ts b/apps/web/components/task/use-agent-error-acknowledgements.ts index 5039ee1985..43170c3c82 100644 --- a/apps/web/components/task/use-agent-error-acknowledgements.ts +++ b/apps/web/components/task/use-agent-error-acknowledgements.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { type AgentErrorOptions, @@ -16,6 +16,14 @@ type UseAgentErrorAcknowledgementsParams = { dismissedAgentErrors: Record; }; +type UseTaskAgentErrorAcknowledgementsParams = Omit< + UseAgentErrorAcknowledgementsParams, + "sessionIds" +> & { + tasks: Array<{ id: string; primarySessionId?: string | null }>; + sessionsByTaskId: Record; +}; + /** * Mirrors the sessions `agentErrorMessageForTask` can inspect for visible task * rows. Primary sessions are included directly; fallback sessions are included @@ -82,3 +90,22 @@ export function usePersistResolvedAgentErrorAcknowledgements({ store, ]); } + +export function usePersistTaskAgentErrorAcknowledgements({ + tasks, + sessionsByTaskId, + sessionsById, + messagesBySession, + dismissedAgentErrors, +}: UseTaskAgentErrorAcknowledgementsParams) { + const sessionIds = useMemo( + () => agentErrorAcknowledgementSessionIds(tasks, sessionsByTaskId), + [tasks, sessionsByTaskId], + ); + usePersistResolvedAgentErrorAcknowledgements({ + sessionsById, + sessionIds, + messagesBySession, + dismissedAgentErrors, + }); +} diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index a087536ed9..7a00f3bae4 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -6,6 +6,13 @@ import { backendFixture, type BackendContext } from "./backend"; import { ApiClient } from "../helpers/api-client"; import { PrAssetCapture } from "../helpers/pr-asset-capture"; import { makeGitEnv } from "../helpers/git-helper"; +import { + clearWsAccount, + computeWsDrops, + formatDroppedEvents, + formatMissingExpectedDrops, + reconcileExpectedWsDrops, +} from "../helpers/ws-account"; import type { WorkflowStep } from "../../lib/types/http"; export type SeedData = { @@ -155,7 +162,7 @@ export const test = backendFixture.extend< // Resets user settings to the E2E workspace/workflow before each test so that // SSR always resolves to the correct workspace regardless of what commitSettings // may have written during previous tests. - testPage: async ({ browser, backend, apiClient, seedData }, use) => { + testPage: async ({ browser, backend, apiClient, seedData }, use, testInfo) => { // Clean up tasks, test-created workflows, and extra agent profiles from // previous tests in this worker. Keep the seeded workflow and the seed // agent profile so the worker-scoped seedData fixture remains valid. @@ -173,6 +180,7 @@ export const test = backendFixture.extend< // test renders cards with data-testid="pipeline-task-" instead of // "task-card-", breaking taskCardByTitle locators. kanban_view_mode: "", + task_create_last_used: defaultTaskCreateLastUsed(seedData), }); const context = await browser.newContext({ baseURL: backend.frontendUrl, @@ -184,8 +192,23 @@ export const test = backendFixture.extend< }); } await setupPage(page, backend, seedData); - await use(page); - await context.close(); + await clearWsAccount(page); + try { + await use(page); + if (process.env.KANDEV_E2E_WS_ASSERT === "1" && testInfo.status === "passed") { + const drops = await computeSettledWsDrops(page, apiClient); + const { unexpected, missing } = reconcileExpectedWsDrops(page, drops); + if (unexpected.length > 0 || missing.length > 0) { + throw new Error( + [formatDroppedEvents(unexpected), formatMissingExpectedDrops(missing)] + .filter(Boolean) + .join("\n\n"), + ); + } + } + } finally { + await context.close(); + } }, // PR asset capture — gated behind CAPTURE_PR_ASSETS env var. @@ -218,6 +241,18 @@ export const test = backendFixture.extend< ], }); +async function computeSettledWsDrops( + page: Page, + apiClient: ApiClient, +): ReturnType { + let drops = await computeWsDrops(page, apiClient, { strict: true }); + for (let attempt = 0; attempt < 6 && drops.length > 0; attempt++) { + await page.waitForTimeout(50); + drops = await computeWsDrops(page, apiClient, { strict: true }); + } + return drops; +} + // Reset the active workspace pointer before every test so that specs which // do not use the testPage fixture (e.g. API-only routing tests) start from // a known workspace_id instead of whatever a previous test's completeOnboarding @@ -231,11 +266,21 @@ test.beforeEach(async ({ apiClient, seedData }) => { enable_preview_on_click: false, sidebar_views: [], kanban_view_mode: "", + task_create_last_used: defaultTaskCreateLastUsed(seedData), }); }); export { expect } from "@playwright/test"; +function defaultTaskCreateLastUsed(seedData: SeedData) { + return { + repository_id: seedData.repositoryId, + branch: "main", + agent_profile_id: seedData.agentProfileId, + executor_profile_id: seedData.worktreeExecutorProfileId, + }; +} + async function setupPage(page: Page, backend: BackendContext, seedData: SeedData): Promise { await page.addInitScript( ({ diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 631a5789d3..0b845fb94d 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -672,6 +672,12 @@ export class ApiClient { tasks_list_sort?: string; tasks_list_group?: string; voice_mode?: VoiceModeSettings; + task_create_last_used?: { + repository_id?: string; + branch?: string; + agent_profile_id?: string; + executor_profile_id?: string; + }; }): Promise { await this.request("PATCH", "/api/v1/user/settings", settings); } @@ -770,6 +776,30 @@ export class ApiClient { return this.request("GET", `/api/v1/agent-profiles/${profileId}/mcp-config`); } + async getWsSent( + connectionId: string, + sinceSeq?: number, + sessionId?: string, + ): Promise<{ + connection_id: string; + session_id?: string; + events: Array<{ + connection_seq: number; + session_seq?: number; + session_id?: string; + type: string; + action: string; + sent_at: string; + }>; + max_connection_seq?: number; + max_session_seq?: number; + }> { + const params = new URLSearchParams({ connection_id: connectionId }); + if (sinceSeq !== undefined) params.set("since_seq", String(sinceSeq)); + if (sessionId) params.set("session_id", sessionId); + return this.request("GET", `/api/v1/_test/ws-sent?${params.toString()}`); + } + // --- E2E Test Reset --- async e2eReset(workspaceId: string, keepWorkflowIds?: string[]): Promise { @@ -1169,6 +1199,7 @@ export class ApiClient { async listSessionMessages(sessionId: string): Promise<{ messages: Array<{ id: string; + type: string; content: string; author_type: string; raw_content?: string; @@ -1203,7 +1234,7 @@ export class ApiClient { } async setPrimarySession(sessionId: string): Promise { - await this.request("POST", `/api/v1/task-sessions/${sessionId}/set-primary`); + await this.wsRequest("session.set_primary", { session_id: sessionId }, 15_000); } async deleteSession(sessionId: string): Promise { diff --git a/apps/web/e2e/helpers/dockview-resize.ts b/apps/web/e2e/helpers/dockview-resize.ts index 4c02b42797..60aef72d8b 100644 --- a/apps/web/e2e/helpers/dockview-resize.ts +++ b/apps/web/e2e/helpers/dockview-resize.ts @@ -32,8 +32,8 @@ export async function openWideTask( ); await page.goto(`/t/${task.id}`); const session = new SessionPage(page); - await session.waitForLoad(); await session.waitForDockviewReady(); + await expect(page.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 }); return session; } diff --git a/apps/web/e2e/helpers/session-store.ts b/apps/web/e2e/helpers/session-store.ts index 333fa46e4e..e89d8fb248 100644 --- a/apps/web/e2e/helpers/session-store.ts +++ b/apps/web/e2e/helpers/session-store.ts @@ -4,7 +4,6 @@ type E2EStoreWindow = Window & { __KANDEV_E2E_STORE__?: { getState: () => { taskSessions: { items: Record> }; - setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void; }; setState: ( updater: (state: { @@ -12,6 +11,9 @@ type E2EStoreWindow = Window & { }) => void, ) => void; }; + __KANDEV_E2E_QUERY_CLIENT__?: { + setQueryData: (key: readonly unknown[], data: AvailableCommand[]) => void; + }; }; type AvailableCommand = { @@ -57,11 +59,14 @@ export async function seedAvailableCommands( ): Promise { await page.evaluate( ({ sid, commandList }) => { - const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__; - if (!store) { - throw new Error("E2E store bridge missing — is __KANDEV_E2E_EXPOSE_STORE__ set?"); + const queryClient = (window as E2EStoreWindow).__KANDEV_E2E_QUERY_CLIENT__; + if (!queryClient) { + throw new Error("E2E query client bridge missing — is __KANDEV_E2E_EXPOSE_STORE__ set?"); } - store.getState().setAvailableCommands(sid, commandList); + queryClient.setQueryData( + ["sessionRuntime", "session", sid, "availableCommands"], + commandList, + ); }, { sid: sessionId, commandList: commands }, ); diff --git a/apps/web/e2e/helpers/ws-account.ts b/apps/web/e2e/helpers/ws-account.ts new file mode 100644 index 0000000000..61d74eb1cf --- /dev/null +++ b/apps/web/e2e/helpers/ws-account.ts @@ -0,0 +1,288 @@ +import type { Page } from "@playwright/test"; + +export type WsAccountReceivedEvent = { + connectionSeq: number; + sessionSeq?: number; + action: string; + sessionId: string | null; + type: string; +}; + +export type WsAccountSessionSnapshot = { + processedSeqs: number[]; + gaps: number[]; + minSeq: number | null; + maxSeq: number | null; +}; + +export type WsAccountSnapshot = { + connectionId: string | null; + processedSeqs: number[]; + gaps: number[]; + maxSeq: number | null; + minSeq: number | null; + receivedEvents: WsAccountReceivedEvent[]; + bySession: Record; +}; + +export type WsSentEvent = { + connection_seq: number; + session_seq?: number; + session_id?: string; + type: string; + action: string; + sent_at: string; +}; + +export type WsSentResponse = { + connection_id: string; + session_id?: string; + events: WsSentEvent[]; + max_connection_seq?: number; + max_session_seq?: number; +}; + +export type DroppedEvent = WsSentEvent; + +export type WsSentFetcher = { + getWsSent(connectionId: string, sinceSeq?: number, sessionId?: string): Promise; +}; + +export type ComputeWsDropsOptions = { + strict?: boolean; +}; + +export type ExpectedWsDrop = { + action?: string; + type?: string; + sessionId?: string; + reason?: string; +}; + +export type ExpectedWsDropResult = { + unexpected: DroppedEvent[]; + missing: ExpectedWsDrop[]; +}; + +const expectedDropsByPage = new WeakMap(); + +export async function readWsAccount(page: Page): Promise { + return page.evaluate(() => { + type Hook = () => WsAccountSnapshot; + const win = window as unknown as { __kandev_ws_account__?: Hook }; + return win.__kandev_ws_account__ ? win.__kandev_ws_account__() : null; + }); +} + +async function readWsAccountAfterNavigationSettles(page: Page): Promise { + const snapshot = await readWsAccountWithNavigationRetry(page); + if (snapshot || !pageHasLoadedApp(page)) return snapshot; + await waitForWsAccountHook(page); + return readWsAccountWithNavigationRetry(page); +} + +async function waitForWsAccountHook(page: Page): Promise { + await page + .waitForFunction( + () => { + const win = window as Window & { __kandev_ws_account__?: unknown }; + return typeof win.__kandev_ws_account__ === "function"; + }, + undefined, + { timeout: 1000 }, + ) + .catch(() => undefined); +} + +async function readWsAccountWithNavigationRetry(page: Page): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt++) { + try { + return await readWsAccount(page); + } catch (error) { + if (!isNavigationEvaluationError(error)) throw error; + lastError = error; + await page.waitForLoadState("domcontentloaded", { timeout: 1000 }).catch(() => undefined); + await page.waitForTimeout(50); + } + } + throw lastError; +} + +function isNavigationEvaluationError(error: unknown): boolean { + const message = String(error); + return ( + message.includes("Execution context was destroyed") || + message.includes("Cannot find context with specified id") || + message.includes("Frame was detached") + ); +} + +export async function clearWsAccount(page: Page): Promise { + await page.evaluate(() => { + type Hook = () => void; + const win = window as unknown as { __kandev_ws_account_clear__?: Hook }; + win.__kandev_ws_account_clear__?.(); + }); +} + +export function registerExpectedWsDrop(page: Page, expected: ExpectedWsDrop): void { + const existing = expectedDropsByPage.get(page) ?? []; + existing.push(expected); + expectedDropsByPage.set(page, existing); +} + +export function reconcileExpectedWsDrops(page: Page, drops: DroppedEvent[]): ExpectedWsDropResult { + const expected = [...(expectedDropsByPage.get(page) ?? [])]; + expectedDropsByPage.delete(page); + const unexpected: DroppedEvent[] = []; + + for (const drop of drops) { + const matchIndex = expected.findIndex((candidate) => expectedDropMatches(candidate, drop)); + if (matchIndex === -1) { + unexpected.push(drop); + continue; + } + expected.splice(matchIndex, 1); + } + + return { unexpected, missing: expected }; +} + +export async function computeWsDrops( + page: Page, + fetcher: WsSentFetcher, + options: ComputeWsDropsOptions = {}, +): Promise { + const snapshot = await readWsAccountAfterNavigationSettles(page); + if (!snapshot) { + if (options.strict && pageHasLoadedApp(page)) { + throw new Error( + "Strict WS accounting could not read the browser hook; " + + "the page either did not install the E2E hook or loaded before it was available.", + ); + } + return []; + } + if (!snapshot.connectionId) { + if (options.strict && snapshot.processedSeqs.length > 0) { + throw new Error( + "Strict WS accounting parsed stamped WS envelopes without a browser connection id.", + ); + } + return []; + } + const sinceSeq = snapshot.minSeq === null ? undefined : Math.max(0, snapshot.minSeq - 1); + const connectionSent = await getWsSent(fetcher, snapshot.connectionId, sinceSeq, undefined, { + strict: options.strict, + }); + const connectionDrops = connectionSent ? diffConnectionDrops(snapshot, connectionSent) : []; + const sessionDrops = await computeSessionDrops(fetcher, snapshot, options); + return dedupeDrops([...connectionDrops, ...sessionDrops]); +} + +export function formatDroppedEvents(drops: DroppedEvent[]): string { + if (drops.length === 0) return ""; + const lines = drops.slice(0, 20).map((drop) => { + const session = drop.session_id ? ` session=${drop.session_id}` : ""; + const sessionSeq = drop.session_seq ? ` session_seq=${drop.session_seq}` : ""; + return ` connection_seq=${drop.connection_seq}${sessionSeq}${session} ${drop.type}/${drop.action} sent_at=${drop.sent_at}`; + }); + const more = drops.length > 20 ? `\n ... and ${drops.length - 20} more` : ""; + return `${drops.length} WS event(s) sent by backend but not parsed by frontend:\n${lines.join("\n")}${more}`; +} + +export function formatMissingExpectedDrops(missing: ExpectedWsDrop[]): string { + if (missing.length === 0) return ""; + const lines = missing.slice(0, 20).map((drop) => { + const session = drop.sessionId ? ` session=${drop.sessionId}` : ""; + const action = drop.action ? ` action=${drop.action}` : ""; + const type = drop.type ? ` type=${drop.type}` : ""; + const reason = drop.reason ? ` reason=${drop.reason}` : ""; + return ` expected WS drop${type}${action}${session}${reason}`; + }); + const more = missing.length > 20 ? `\n ... and ${missing.length - 20} more` : ""; + return `${missing.length} expected WS drop(s) were not observed:\n${lines.join("\n")}${more}`; +} + +function diffConnectionDrops( + snapshot: WsAccountSnapshot, + backendSent: WsSentResponse, +): DroppedEvent[] { + const maxSeq = snapshot.maxSeq; + if (maxSeq === null) return []; + const received = new Set(snapshot.processedSeqs); + return backendSent.events.filter( + (event) => event.connection_seq <= maxSeq && !received.has(event.connection_seq), + ); +} + +async function computeSessionDrops( + fetcher: WsSentFetcher, + snapshot: WsAccountSnapshot, + options: ComputeWsDropsOptions, +): Promise { + if (!snapshot.connectionId) return []; + const drops: DroppedEvent[] = []; + for (const sessionId of Object.keys(snapshot.bySession)) { + const sent = await getWsSent(fetcher, snapshot.connectionId, undefined, sessionId, { + strict: options.strict, + }); + if (!sent) continue; + const sessionSnapshot = snapshot.bySession[sessionId]; + if (!sessionSnapshot || sessionSnapshot.minSeq === null || sessionSnapshot.maxSeq === null) { + continue; + } + const received = new Set(sessionSnapshot.processedSeqs); + for (const event of sent.events) { + if ( + event.session_seq && + event.session_seq >= sessionSnapshot.minSeq && + event.session_seq <= sessionSnapshot.maxSeq && + !received.has(event.session_seq) + ) { + drops.push(event); + } + } + } + return drops; +} + +async function getWsSent( + fetcher: WsSentFetcher, + connectionId: string, + sinceSeq?: number, + sessionId?: string, + options: ComputeWsDropsOptions = {}, +): Promise { + try { + return await fetcher.getWsSent(connectionId, sinceSeq, sessionId); + } catch (error) { + if (options.strict) { + throw new Error(`Strict WS accounting sent-log lookup failed: ${String(error)}`); + } + return null; + } +} + +function dedupeDrops(drops: DroppedEvent[]): DroppedEvent[] { + const seen = new Set(); + const unique: DroppedEvent[] = []; + for (const drop of drops) { + if (seen.has(drop.connection_seq)) continue; + seen.add(drop.connection_seq); + unique.push(drop); + } + return unique; +} + +function expectedDropMatches(expected: ExpectedWsDrop, drop: DroppedEvent): boolean { + if (expected.action && drop.action !== expected.action) return false; + if (expected.type && drop.type !== expected.type) return false; + if (expected.sessionId && drop.session_id !== expected.sessionId) return false; + return true; +} + +function pageHasLoadedApp(page: Page): boolean { + return page.url() !== "about:blank"; +} diff --git a/apps/web/e2e/helpers/ws-drop.ts b/apps/web/e2e/helpers/ws-drop.ts index 2d7f2eb541..ce0809aec2 100644 --- a/apps/web/e2e/helpers/ws-drop.ts +++ b/apps/web/e2e/helpers/ws-drop.ts @@ -1,4 +1,5 @@ import type { Page } from "@playwright/test"; +import { registerExpectedWsDrop } from "./ws-account"; type DroppedMessage = { action: string; @@ -28,10 +29,16 @@ function isTargetUserMessageAdded( ); } +function sessionIdFor(message: unknown): string | undefined { + const payload = asRecord(asRecord(message)?.payload); + return typeof payload?.session_id === "string" ? payload.session_id : undefined; +} + function filterServerFrame( message: string | Buffer, prompt: string | null, dropped: DroppedMessage[], + onDrop: (message: unknown) => void, ): string | Buffer | null { if (!prompt || typeof message !== "string") return message; @@ -48,6 +55,7 @@ function filterServerFrame( if (isTargetUserMessageAdded(parsed, prompt)) { didDrop = true; dropped.push({ action: "session.message.added", content: parsed.payload.content }); + onDrop(parsed); continue; } } catch { @@ -68,7 +76,14 @@ export async function routeMainWebSocketWithPromptDrop(page: Page): Promise { const server = ws.connectToServer(); server.onMessage((message) => { - const filtered = filterServerFrame(message, promptToDrop, dropped); + const filtered = filterServerFrame(message, promptToDrop, dropped, (parsed) => { + registerExpectedWsDrop(page, { + type: "notification", + action: "session.message.added", + sessionId: sessionIdFor(parsed), + reason: "intentional prompt WS-gap fault injection", + }); + }); if (filtered !== null) ws.send(filtered); }); }); diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index b623d198df..0daa467061 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -95,11 +95,184 @@ export class SessionPage { return this.page.locator("[data-testid='session-chat']:visible").first(); } + private chatEditor(): Locator { + return this.activeChat().locator(".tiptap.ProseMirror").first(); + } + + private sessionTabTriggers(): Locator { + return this.page.locator( + '[data-testid^="session-tab-"]:not([data-testid^="session-tab-close-"])', + ); + } + + private visibleSessionTabTriggers(): Locator { + return this.page.locator( + '[data-testid^="session-tab-"]:not([data-testid^="session-tab-close-"]):visible', + ); + } + + private visibleSessionDockTabs(): Locator { + return this.page.locator(".dv-default-tab").filter({ + has: this.visibleSessionTabTriggers(), + }); + } + + private visibleChatDockTabs(): Locator { + return this.page.locator(".dv-default-tab:visible").filter({ hasText: /^Chat$/ }); + } + + private currentRouteSessionId(): string | null { + try { + return new URL(this.page.url()).searchParams.get("sessionId"); + } catch { + return null; + } + } + + private currentRouteSessionTabTrigger(): Locator | null { + const sessionId = this.currentRouteSessionId(); + return sessionId ? this.page.getByTestId(`session-tab-${sessionId}`) : null; + } + + private currentRouteSessionDockTab(): Locator | null { + const routeTab = this.currentRouteSessionTabTrigger(); + return routeTab ? this.page.locator(".dv-default-tab").filter({ has: routeTab }).first() : null; + } + + private sessionTabClickCandidates(): Locator[] { + const candidates: Locator[] = []; + const routeDockTab = this.currentRouteSessionDockTab(); + const routeTrigger = this.currentRouteSessionTabTrigger(); + if (routeDockTab) candidates.push(routeDockTab); + if (routeTrigger) candidates.push(routeTrigger); + candidates.push(this.visibleChatDockTabs().first()); + candidates.push(this.visibleSessionDockTabs().first()); + candidates.push(this.visibleSessionTabTriggers().first()); + return candidates; + } + + private async clickFirstAvailableSessionTab(timeout = 15_000): Promise { + let lastError: unknown; + const candidateTimeout = Math.min(timeout, 5_000); + + for (const tab of this.sessionTabClickCandidates()) { + try { + await tab.waitFor({ state: "visible", timeout: candidateTimeout }); + await tab.click(); + return; + } catch (err) { + lastError = err; + } + } + + if (lastError instanceof Error) throw lastError; + throw new Error("no session tab could be clicked"); + } + + private async foregroundSessionChatFromTab(timeout = 15_000): Promise { + let lastError: unknown; + const candidateTimeout = Math.min(timeout, 5_000); + + for (const tab of this.sessionTabClickCandidates()) { + try { + await tab.waitFor({ state: "visible", timeout: candidateTimeout }); + await tab.click(); + await this.activeChat().waitFor({ state: "visible", timeout: candidateTimeout }); + return; + } catch (err) { + lastError = err; + } + } + + try { + await this.foregroundSessionChatWithDockviewApi(timeout); + return; + } catch (err) { + lastError = err; + } + + if (lastError instanceof Error) throw lastError; + throw new Error("session chat did not become visible"); + } + + private async foregroundSessionChatWithDockviewApi(timeout = 15_000): Promise { + const getPanelIds = async () => + this.page.evaluate(() => { + type PanelApi = { setActive: () => void }; + type Panel = { id: string; api: PanelApi }; + type Api = { + activePanel?: Panel; + panels: Panel[]; + getPanel: (id: string) => Panel | undefined; + }; + const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__; + if (!api) return []; + + const seen = new Set(); + const ids: string[] = []; + const add = (id: string | null | undefined) => { + if (!id || seen.has(id) || !api.getPanel(id)) return; + seen.add(id); + ids.push(id); + }; + + const sessionId = new URL(window.location.href).searchParams.get("sessionId"); + add(sessionId ? `session:${sessionId}` : undefined); + if (api.activePanel?.id.startsWith("session:")) add(api.activePanel.id); + for (const panel of api.panels) { + if (panel.id.startsWith("session:")) add(panel.id); + } + add("chat"); + + return ids; + }); + + await expect + .poll(async () => (await getPanelIds()).length, { + timeout, + message: "Finding session chat panel through dockview api", + }) + .toBeGreaterThan(0); + + let lastError: unknown; + for (const panelId of await getPanelIds()) { + try { + const activated = await this.page.evaluate((id) => { + type PanelApi = { setActive: () => void }; + type Panel = { id: string; api: PanelApi }; + type Api = { getPanel: (id: string) => Panel | undefined }; + const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__; + const panel = api?.getPanel(id); + if (!panel) return false; + panel.api.setActive(); + return true; + }, panelId); + if (!activated) continue; + await this.activeChat().waitFor({ state: "visible", timeout: Math.min(timeout, 5_000) }); + return; + } catch (err) { + lastError = err; + } + } + + if (lastError instanceof Error) throw lastError; + throw new Error("session chat did not become visible through dockview api"); + } + + private async waitForEditableChatEditor(timeout = 15_000): Promise { + const editor = this.chatEditor(); + await expect(editor).toBeVisible({ timeout }); + await expect(editor).toHaveAttribute("contenteditable", "true", { timeout }); + return editor; + } + async waitForLoad(timeout = 15_000) { // When multiple session tabs are open, multiple session-chat panels exist in // the DOM but only the active one is visible. Use :visible to avoid matching // a hidden background panel (which would cause the wait to time out). - await this.activeChat().waitFor({ state: "visible", timeout }); + await this.activeChat() + .waitFor({ state: "visible", timeout }) + .catch(() => this.showSessionContext(timeout)); } /** @@ -116,12 +289,24 @@ export class SessionPage { * already foregrounded. */ async showSessionContext(timeout = 15_000): Promise { - const tab = this.page.locator("[data-testid^='session-tab-']").first(); - await tab.waitFor({ state: "visible", timeout }); - // Clicking a tab that's already active is harmless; clicking a background - // one promotes its panel to the foreground. - await tab.click(); - await this.activeChat().waitFor({ state: "visible", timeout }); + await this.waitForDockviewReady(Math.min(timeout, 15_000)).catch(() => undefined); + if (await this.activeChat().isVisible()) return; + + let lastError: unknown; + for (const reloadFirst of [false, true]) { + try { + if (reloadFirst) await this.page.reload(); + // Clicking a tab that's already active is harmless; clicking a background + // one promotes its panel to the foreground. + await this.foregroundSessionChatFromTab(timeout); + return; + } catch (err) { + lastError = err; + } + } + + if (lastError instanceof Error) throw lastError; + throw new Error("session chat did not become visible"); } /** @@ -149,9 +334,16 @@ export class SessionPage { const idle = this.anyIdleInput(); const start = Date.now(); let reloaded = false; + const idleAndSettled = async () => { + if (!(await idle.isVisible())) return false; + return !(await this.activeChatAgentStatus() + .first() + .isVisible() + .catch(() => false)); + }; while (Date.now() - start < softTotalTimeout) { - if (await idle.isVisible()) return; + if (await idleAndSettled()) return; const resumeButton = this.recoveryResumeButton(); if (await resumeButton.isVisible()) { @@ -178,7 +370,7 @@ export class SessionPage { .catch(() => undefined); } - await idle.waitFor({ state: "visible", timeout: 1_000 }); + await expect.poll(idleAndSettled, { timeout: 1_000 }).toBe(true); } /** Wait for the passthrough terminal to be visible (for TUI/passthrough sessions). */ @@ -297,7 +489,9 @@ export class SessionPage { /** Agent STARTING or RUNNING status indicator. */ agentStatus(): Locator { - return this.page.getByRole("status", { name: /Agent is (starting|running)/ }); + return this.page.getByRole("status", { + name: /^(Agent is starting|Agent is running|Starting .+)$/, + }); } /** Divider that appears after the "New session started" status message is rendered. */ @@ -318,6 +512,13 @@ export class SessionPage { .or(this.page.locator('[data-placeholder="Continue working on the file..."]')); } + /** Visible agent busy status inside the active chat panel. */ + activeChatAgentStatus(): Locator { + return this.activeChat().getByRole("status", { + name: /^(Agent is starting|Agent is running|Starting .+)$/, + }); + } + /** Chat input placeholder when agent is idle (plan mode). */ planModeInput(): Locator { return this.page.locator('[data-placeholder="Continue working on the plan..."]'); @@ -334,7 +535,7 @@ export class SessionPage { /** Clarification overlay (visible when a clarification request is pending). */ clarificationOverlay(): Locator { - return this.page.getByTestId("clarification-overlay"); + return this.activeChat().getByTestId("clarification-overlay"); } /** A specific clarification option button by its text label. */ @@ -346,12 +547,12 @@ export class SessionPage { /** Skip (X) button on the clarification overlay. */ clarificationSkip(): Locator { - return this.page.getByTestId("clarification-skip"); + return this.clarificationOverlay().getByTestId("clarification-skip"); } /** Custom text input on the clarification overlay. */ clarificationInput(): Locator { - return this.page.getByTestId("clarification-input"); + return this.clarificationOverlay().getByTestId("clarification-input"); } /** Inline Send button shown next to the custom input on touch devices. */ @@ -361,12 +562,12 @@ export class SessionPage { /** Deferred notice shown when agent has disconnected from clarification. */ clarificationDeferredNotice(): Locator { - return this.page.getByTestId("clarification-deferred-notice"); + return this.clarificationOverlay().getByTestId("clarification-deferred-notice"); } /** Expired notice rendered in chat history when the agent timed out waiting. */ clarificationExpiredNotice(): Locator { - return this.page.getByTestId("clarification-expired-notice"); + return this.activeChat().getByTestId("clarification-expired-notice"); } /** Label span inside a clarification option. */ @@ -447,18 +648,20 @@ export class SessionPage { /** All visible "Approve / Deny" rows for pending permission requests. */ permissionActionRows(): Locator { - return this.chat.getByTestId("permission-action-row"); + return this.activeChat().getByTestId("permission-action-row"); } /** All "Approve" buttons for pending permission requests. */ permissionApproveButtons(): Locator { - return this.chat.getByTestId("permission-approve"); + return this.activeChat().getByTestId("permission-approve"); } /** Kandev-MCP-only "Approve" buttons (excludes the generic ToolCallMessage * fallback row that may briefly duplicate the same pending_id). */ kandevPermissionApproveButtons(): Locator { - return this.chat.getByTestId("kandev-tool-permission").getByTestId("permission-approve"); + return this.activeChat() + .getByTestId("kandev-tool-permission") + .getByTestId("permission-approve"); } /** Reset context button in the chat input toolbar. */ @@ -605,7 +808,7 @@ export class SessionPage { /** Multi-PR aggregate popover content (segmented tabs + selected PR's CI). */ prTopbarPopoverAggregate(): Locator { - return this.page.getByTestId("pr-multi-popover"); + return this.page.locator("[data-testid='pr-multi-popover']:visible").first(); } /** A single PR tab inside the multi-PR aggregate popover, by owner + repo + PR number. */ @@ -723,21 +926,59 @@ export class SessionPage { * Mirrors {@link hoverPRTopbar}: moves the real cursor onto the chip and * also dispatches the hover events so the open is reliable across browsers. */ - async hoverPRChip(): Promise { + private async hoverPRChipTrigger(): Promise { + const chip = this.prStatusChip(); + await chip.scrollIntoViewIfNeeded(); + const box = await chip.boundingBox(); + expect(box).not.toBeNull(); + await chip.focus(); + await this.page.mouse.move(0, 0); + await this.page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); + await chip.evaluate((element) => { + const targets = [element.parentElement, element].filter(Boolean) as Element[]; + const mouseInit: MouseEventInit = { bubbles: true, cancelable: true, view: window }; + const pointerInit: PointerEventInit = { + ...mouseInit, + pointerType: "mouse", + isPrimary: true, + }; + + for (const target of targets) { + if ("PointerEvent" in window) { + target.dispatchEvent(new PointerEvent("pointerover", pointerInit)); + target.dispatchEvent( + new PointerEvent("pointerenter", { ...pointerInit, bubbles: false }), + ); + target.dispatchEvent(new PointerEvent("pointermove", pointerInit)); + } + target.dispatchEvent(new MouseEvent("mouseover", mouseInit)); + target.dispatchEvent(new MouseEvent("mouseenter", { ...mouseInit, bubbles: false })); + target.dispatchEvent(new MouseEvent("mousemove", mouseInit)); + } + }); + } + + private async hoverPRChipUntil(popover: Locator): Promise { await expect(async () => { - const chip = this.prStatusChip(); - await chip.scrollIntoViewIfNeeded(); - const box = await chip.boundingBox(); - expect(box).not.toBeNull(); - await this.page.mouse.move(0, 0); - await this.page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); - await chip.dispatchEvent("mouseover", { bubbles: true }); - await chip.dispatchEvent("mouseenter", { bubbles: false }); - await chip.dispatchEvent("mousemove", { bubbles: true }); - await expect(this.prChipPopover()).toBeVisible({ timeout: 1_500 }); + await this.hoverPRChipTrigger(); + try { + await expect(popover).toBeVisible({ timeout: 1_500 }); + return; + } catch { + await this.prStatusChip().click({ force: true }); + await expect(popover).toBeVisible({ timeout: 2_000 }); + } }).toPass({ timeout: 10_000 }); } + async hoverPRChip(): Promise { + await this.hoverPRChipUntil(this.prChipPopover()); + } + + async hoverPRMultiChip(): Promise { + await this.hoverPRChipUntil(this.prTopbarPopoverAggregate()); + } + /** * Assert the `pr-detail` panel's dockview group contains at least one * `session:{sessionId}` panel — i.e. the PR opened as a tab next to a @@ -827,7 +1068,7 @@ export class SessionPage { * so this uses the stable data-testid on the ContextMenuTrigger instead. */ async clickSessionChatTab(): Promise { - await this.page.locator('[data-testid^="session-tab-"]').first().click(); + await this.clickFirstAvailableSessionTab(); } /** Main Changes-panel button that asks the agent to create a walkthrough. */ @@ -882,10 +1123,16 @@ export class SessionPage { async expandChangesSection(testId: string): Promise { const toggle = this.changes.getByTestId(`${testId}-collapse-toggle`); await expect(toggle).toBeVisible({ timeout: 15_000 }); - if ((await toggle.getAttribute("aria-expanded")) === "false") { - await toggle.click(); - await expect(toggle).toHaveAttribute("aria-expanded", "true"); - } + await expect + .poll( + async () => { + if ((await toggle.getAttribute("aria-expanded")) === "true") return true; + await toggle.click(); + return (await toggle.getAttribute("aria-expanded")) === "true"; + }, + { timeout: 15_000 }, + ) + .toBe(true); } /** Expand the commits section (collapsed by default in the changes panel). */ @@ -904,7 +1151,7 @@ export class SessionPage { * TipTap maps "Mod" to Meta on macOS and Control on Linux/Windows. */ async sendMessage(text: string) { - const editor = this.page.locator(".tiptap.ProseMirror").first(); + const editor = await this.waitForEditableChatEditor(); await editor.click(); await editor.fill(text); const modifier = process.platform === "darwin" ? "Meta" : "Control"; @@ -916,10 +1163,10 @@ export class SessionPage { * don't submit on Ctrl/Cmd+Enter, so mobile specs use this instead. */ async sendMessageViaButton(text: string) { - const editor = this.page.locator(".tiptap.ProseMirror").first(); + const editor = await this.waitForEditableChatEditor(); await editor.click(); await editor.fill(text); - await this.page.getByTestId("submit-message-button").click(); + await this.activeChat().getByTestId("submit-message-button").click(); } /** @@ -1244,7 +1491,7 @@ export class SessionPage { /** Dockview session tab matched by partial text (e.g., "Mock Agent" or index "1"). */ sessionTabByText(text: string): Locator { - return this.page.locator(`[data-testid^='session-tab-']:has-text('${text}')`); + return this.sessionTabTriggers().filter({ hasText: text }); } /** Session tab container identified by session ID (data-testid="session-tab-{id}"). */ @@ -1259,13 +1506,13 @@ export class SessionPage { /** Context menu on a dockview tab — right-click the tab to trigger it. */ async rightClickTab(text: string): Promise { - const tab = this.page.locator(`[data-testid^='session-tab-']:has-text('${text}')`); + const tab = this.sessionTabByText(text); await tab.click({ button: "right" }); } /** Right-click the first session tab (useful when there is only one session). */ async rightClickFirstSessionTab(): Promise { - const tab = this.page.locator("[data-testid^='session-tab-']").first(); + const tab = this.sessionTabTriggers().first(); await tab.click({ button: "right" }); } diff --git a/apps/web/e2e/pages/sidebar-filter-popover.ts b/apps/web/e2e/pages/sidebar-filter-popover.ts index 06b3db5307..2805386ab1 100644 --- a/apps/web/e2e/pages/sidebar-filter-popover.ts +++ b/apps/web/e2e/pages/sidebar-filter-popover.ts @@ -28,13 +28,17 @@ export class SidebarFilterPopoverPage { constructor(private readonly page: Page) { this.bar = page.getByTestId("tasks-view-picker"); this.viewPicker = page.getByTestId("tasks-view-picker"); - this.gear = page.getByTestId("sidebar-filter-gear"); + this.gear = this.viewPicker.locator("xpath=..").getByTestId("sidebar-filter-gear"); } get popover(): Locator { return this.page.getByTestId("sidebar-filter-popover"); } + get dirtyIndicator(): Locator { + return this.popover.getByTestId("sidebar-filter-dirty-indicator"); + } + /** Open radix dropdown menu hosting the view chips. Scoped to the menu that * actually contains the view chips so it can't match another sidebar menu. */ get chipMenu(): Locator { @@ -176,7 +180,9 @@ export class SidebarFilterPopoverPage { } async discard(): Promise { - await this.popover.getByTestId("view-discard-button").click(); + const discardButton = this.popover.getByTestId("view-discard-button"); + await expect(discardButton).toBeVisible(); + await discardButton.click(); } async deleteActiveView(): Promise { diff --git a/apps/web/e2e/tests/chat/clarification.spec.ts b/apps/web/e2e/tests/chat/clarification.spec.ts index f50f7085eb..004aa80dc1 100644 --- a/apps/web/e2e/tests/chat/clarification.spec.ts +++ b/apps/web/e2e/tests/chat/clarification.spec.ts @@ -21,6 +21,45 @@ function seedClarificationTask( return seedClarificationSession(testPage, apiClient, seedData, title, { scenario }); } +async function waitForSessionMessage( + apiClient: ApiClient, + sessionId: string, + content: string, +): Promise { + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.some((message) => message.content.includes(content)); + }, + { timeout: 45_000, message: `session should contain message: ${content}` }, + ) + .toBe(true); +} + +function currentTaskIdFromUrl(page: Page): string { + const [, resource, taskId] = new URL(page.url()).pathname.split("/"); + if (resource !== "t" || !taskId) { + throw new Error(`expected task URL, got ${page.url()}`); + } + return taskId; +} + +async function waitForTaskSessionId(apiClient: ApiClient, taskId: string): Promise { + let sessionId = ""; + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(taskId); + sessionId = sessions[0]?.id ?? ""; + return sessionId; + }, + { timeout: 30_000, message: `task ${taskId} should have a session` }, + ) + .not.toBe(""); + return sessionId; +} + // Exercises the regular task-create dialog (New Task in the sidebar); run with office off. useRegularMode(); @@ -181,7 +220,9 @@ test.describe("Clarification flow", () => { test("plan mode + clarification does not leave pointer-events stuck on body", async ({ testPage, + apiClient, }) => { + test.setTimeout(90_000); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -198,12 +239,18 @@ test.describe("Clarification flow", () => { await descriptionInput.fill("/e2e:clarification"); await testPage.getByTestId("submit-start-agent-chevron").click(); - await testPage.getByTestId("submit-plan-mode").click(); + const planModeButton = testPage.getByTestId("submit-plan-mode"); + await expect(planModeButton).toBeVisible({ timeout: 5_000 }); + await planModeButton.click(); + await expect(dialog).not.toBeVisible({ timeout: 10_000 }); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + await expect(testPage).toHaveURL(/\/t\/.*layout=plan/, { timeout: 15_000 }); + const taskId = currentTaskIdFromUrl(testPage); + const sessionId = await waitForTaskSessionId(apiClient, taskId); + await waitForSessionMessage(apiClient, sessionId, "Which database"); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.showSessionContext(30_000); await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 }); diff --git a/apps/web/e2e/tests/chat/message-pagination.spec.ts b/apps/web/e2e/tests/chat/message-pagination.spec.ts index c20272a5f7..3a67a9f5cc 100644 --- a/apps/web/e2e/tests/chat/message-pagination.spec.ts +++ b/apps/web/e2e/tests/chat/message-pagination.spec.ts @@ -24,10 +24,11 @@ import { seedMessagesDescription } from "../search/shared"; // (which the chat renders verbatim) so the only on-screen occurrence is the // paginated message itself. const INITIAL_PROMPT = "INITIAL-PROMPT-MARKER-7Q2X"; -// Enough filler to require multiple lazy-load pages beyond the initial 100, while -// keeping sequential seeding (one round-trip per message) off the test budget: -// 120 + the marker leaves ~20+ messages older than the initial window. -const FILLER_COUNT = 120; +// Enough filler to require multiple explicit load-older pages beyond the +// initial 100. The native top sentinel may opportunistically fetch one or two +// older pages before the first assertion on fast layouts, so keep the marker +// well behind that automatic head start. +const FILLER_COUNT = 220; /** Boot an idle session, seed the marker, then bury it under FILLER_COUNT messages. */ async function seedBigConversation(apiClient: ApiClient, seedData: SeedData): Promise { @@ -73,27 +74,21 @@ test.describe("@chat message pagination", () => { await session.waitForLoad(); await session.waitForChatIdle({ timeout: 30_000 }); - // Filler renders; the initial prompt does NOT yet (older than the window). - await expect(session.chat.getByText("filler message", { exact: false }).first()).toBeVisible({ + const chat = session.activeChat(); + const initialPrompt = chat.getByText(INITIAL_PROMPT); + const loadOlder = chat.getByTestId("load-older-messages"); + + // Filler renders; the native top sentinel may already have fetched older pages. + await expect(chat.getByText("filler message", { exact: false }).first()).toBeVisible({ timeout: 30_000, }); - await expect(session.chat.getByText(INITIAL_PROMPT)).toHaveCount(0); - - // The explicit load-older button is offered because more messages exist. - const loadOlder = session.chat.getByTestId("load-older-messages"); - await expect(loadOlder).toBeVisible({ timeout: 10_000 }); // Click it repeatedly until the initial prompt is reached. The button is the - // reliable path — no scrolling, no intersection timing. + // reliable path when the initial page has not already reached the marker. await expect .poll( async () => { - if ( - await session.chat - .getByText(INITIAL_PROMPT) - .isVisible() - .catch(() => false) - ) { + if (await initialPrompt.isVisible().catch(() => false)) { return "reached"; } if (await loadOlder.isVisible().catch(() => false)) { @@ -106,7 +101,7 @@ test.describe("@chat message pagination", () => { .toBe("reached"); // The initial prompt is visible, and there's nothing left to load. - await expect(session.chat.getByText(INITIAL_PROMPT)).toBeVisible(); + await expect(initialPrompt).toBeVisible(); await expect(loadOlder).toBeHidden(); }); }); diff --git a/apps/web/e2e/tests/chat/message-queue.spec.ts b/apps/web/e2e/tests/chat/message-queue.spec.ts index 0c929e06f6..51f95ba135 100644 --- a/apps/web/e2e/tests/chat/message-queue.spec.ts +++ b/apps/web/e2e/tests/chat/message-queue.spec.ts @@ -368,8 +368,7 @@ test.describe("Queue affordance", () => { // Send a slow command so the agent stays busy long enough to queue. const editor = dialog.locator(".tiptap.ProseMirror"); - await editor.click(); - await editor.fill("/slow 10s"); + await typeWhileBusy(testPage, editor, "/slow 10s"); const modifier = process.platform === "darwin" ? "Meta" : "Control"; await editor.press(`${modifier}+Enter`); diff --git a/apps/web/e2e/tests/chat/model-selector-error.spec.ts b/apps/web/e2e/tests/chat/model-selector-error.spec.ts index 406ca201f3..b176125a07 100644 --- a/apps/web/e2e/tests/chat/model-selector-error.spec.ts +++ b/apps/web/e2e/tests/chat/model-selector-error.spec.ts @@ -129,9 +129,10 @@ test.describe("Chat model selector — RPC failure", () => { await trigger.click(); await testPage.getByRole("option", { name: /Mock Smart/ }).click(); - // Re-open and pick again — second request will succeed. mock-agent only - // ships two models (Mock Fast / Mock Smart), so we go back to Mock Fast. - await trigger.click(); + await expect(trigger).toContainText("Mock Smart", { timeout: 5_000 }); + // The popover stays open because mock-agent also exposes extra config + // options. Pick again — second request will succeed. mock-agent only ships + // two models (Mock Fast / Mock Smart), so we go back to Mock Fast. await testPage.getByRole("option", { name: /Mock Fast/ }).click(); // Now release the first (stale) request — its 500 rejection should be diff --git a/apps/web/e2e/tests/chat/permission-approval.spec.ts b/apps/web/e2e/tests/chat/permission-approval.spec.ts index c0d4f2b80f..926da7fedb 100644 --- a/apps/web/e2e/tests/chat/permission-approval.spec.ts +++ b/apps/web/e2e/tests/chat/permission-approval.spec.ts @@ -10,6 +10,21 @@ import { SessionPage } from "../../pages/session-page"; // changes. const MULTI_PERMISSION_COUNT = 3; +async function waitForPendingPermission(apiClient: ApiClient, sessionId: string): Promise { + await expect + .poll( + async () => { + const { messages } = await apiClient.listSessionMessages(sessionId); + return messages.some( + (message) => + message.type === "permission_request" && message.metadata?.status !== "approved", + ); + }, + { timeout: 30_000, message: "Waiting for pending permission request" }, + ) + .toBe(true); +} + /** * Seed a task that runs the multi-permission scenario, then navigate to it. * The mock agent will request three permissions in sequence and block on each. @@ -36,6 +51,7 @@ async function seedMultiPermissionTask( if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await waitForPendingPermission(apiClient, task.session_id); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); @@ -141,6 +157,7 @@ test.describe("Permission approval persistence", () => { if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await waitForPendingPermission(apiClient, task.session_id); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); diff --git a/apps/web/e2e/tests/git/diff-expansion.spec.ts b/apps/web/e2e/tests/git/diff-expansion.spec.ts index 4b3f56bf6c..ba9b356d67 100644 --- a/apps/web/e2e/tests/git/diff-expansion.spec.ts +++ b/apps/web/e2e/tests/git/diff-expansion.spec.ts @@ -1,28 +1,29 @@ import { test, expect } from "../../fixtures/test-base"; -import { SessionPage } from "../../pages/session-page"; import type { ApiClient } from "../../helpers/api-client"; import type { SeedData } from "../../fixtures/test-base"; import type { Page } from "@playwright/test"; +import { GitHelper, makeGitEnv } from "../../helpers/git-helper"; + +const EXPANSION_FILE = "expansion_test.go"; /** - * Seed a task using the diff-expansion-setup mock scenario and navigate to - * its session page, waiting for the agent turn to complete. + * Seed a task and write a committed baseline plus an uncommitted two-hunk diff + * directly into its workspace. * - * The scenario writes a 50-line file, commits it, then modifies two lines far - * apart (line 3 and line 48). The diff viewer will show two separate hunks - * with ~44 collapsed lines between them. + * The file has 200 lines with modifications on lines 50 and 150. The diff + * viewer will show two separate hunks with a collapsed block between them. */ async function seedExpansionTask( testPage: Page, apiClient: ApiClient, seedData: SeedData, -): Promise { +): Promise { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, - "Diff Expansion E2E", + `Diff Expansion E2E ${Date.now()}`, seedData.agentProfileId, { - description: "/e2e:diff-expansion-setup", + description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], @@ -31,16 +32,53 @@ async function seedExpansionTask( if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + let workspacePath: string | null = null; + await expect + .poll( + async () => { + const env = await apiClient.getTaskEnvironment(task.id); + if (env?.status !== "ready") return null; + workspacePath = env.worktree_path ?? env.workspace_path ?? null; + return workspacePath; + }, + { timeout: 45_000, message: "ready task workspace path should be available" }, + ) + .not.toBeNull(); + if (!workspacePath) throw new Error("task environment did not expose a workspace path"); + + seedExpansionDiff(workspacePath); await testPage.goto(`/t/${task.id}`); +} + +function seedExpansionDiff(workspacePath: string) { + const git = new GitHelper(workspacePath, makeGitEnv(workspacePath)); + const original = buildExpansionFile(); - const session = new SessionPage(testPage); - await session.waitForLoad(); + git.exec(`git rm --force --ignore-unmatch "${EXPANSION_FILE}"`); + commitIfChanged(git, "cleanup expansion diff fixture"); - await expect( - session.chat.getByText("diff-expansion-setup complete", { exact: false }), - ).toBeVisible({ timeout: 45_000 }); + git.createFile(EXPANSION_FILE, original); + git.stageFile(EXPANSION_FILE); + commitIfChanged(git, "add expansion diff fixture"); - return session; + git.modifyFile(EXPANSION_FILE, buildExpansionFile({ modified: true })); +} + +function buildExpansionFile(opts: { modified?: boolean } = {}) { + const lines = Array.from( + { length: 200 }, + (_, idx) => `func original_${String(idx + 1).padStart(3, "0")}() { /* line ${idx + 1} */ }`, + ); + if (opts.modified) { + lines[49] = "func modified_mid_top() { /* HUNK_TOP - modified line 50 */ }"; + lines[149] = "func modified_mid_bottom() { /* HUNK_BOTTOM - modified line 150 */ }"; + } + return `${lines.join("\n")}\n`; +} + +function commitIfChanged(git: GitHelper, message: string) { + if (git.exec(`git status --porcelain -- "${EXPANSION_FILE}"`).trim() === "") return; + git.commit(message); } /** Click the Changes dockview tab. */ @@ -52,7 +90,7 @@ async function openChangesTab(testPage: Page) { /** Click the file row for expansion_test.go to open its diff view. */ async function openExpansionFileDiff(testPage: Page) { - const fileRow = testPage.getByTestId("file-row-expansion_test.go"); + const fileRow = testPage.getByTestId(`file-row-${EXPANSION_FILE}`); await expect(fileRow).toBeVisible({ timeout: 10_000 }); await fileRow.click(); } @@ -80,52 +118,99 @@ async function readDiffOverflow(testPage: Page): Promise { }); } -async function hoverUntilGutterSlotAppears(testPage: Page) { +type GutterButtonGeometry = { + marginRight: number; + buttonRight: number; + cellRight: number; +}; + +async function hoverUntilGutterButtonExtrudes(testPage: Page): Promise { const points = await testPage.evaluate(() => { const container = document.querySelector("diffs-container"); const shadow = container?.shadowRoot; if (!shadow) throw new Error("diffs-container shadow root missing"); - const line = shadow.querySelector("[data-line]"); - if (!line) throw new Error("no [data-line] found to hover"); - const r = line.getBoundingClientRect(); - const y = r.top + r.height / 2; - return [ - { x: r.left + 2, y }, - { x: r.left + 10, y }, - { x: r.left + Math.min(40, r.width / 4), y }, - { x: r.left + r.width / 2, y }, - ]; + const numbers = Array.from( + shadow.querySelectorAll("[data-column-number][data-line-index]"), + ); + const points = numbers + .map((number) => { + const r = number.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return null; + return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; + }) + .filter((point): point is { x: number; y: number } => point !== null); + if (points.length === 0) throw new Error("no visible [data-column-number] found to hover"); + return points.slice(0, 8); }); for (const point of points) { await testPage.mouse.move(point.x, point.y); - const appeared = await testPage + const geometry = await testPage .waitForFunction( - () => - Boolean( - document - .querySelector("diffs-container") - ?.shadowRoot?.querySelector("[data-gutter-utility-slot]"), - ), + () => { + const container = document.querySelector("diffs-container"); + const shadow = container?.shadowRoot; + const slotWrapper = shadow?.querySelector("[data-gutter-utility-slot]"); + const slottedLight = document.querySelector('[slot="gutter-utility-slot"]'); + const button = slottedLight?.firstElementChild as HTMLElement | null | undefined; + if (!slotWrapper || !button) return false; + const buttonRect = button.getBoundingClientRect(); + const cellRect = slotWrapper.parentElement?.getBoundingClientRect(); + if (!cellRect) return false; + const marginRight = parseFloat(getComputedStyle(button).marginRight); + if (marginRight >= 0 || buttonRect.right <= cellRect.right) return false; + return { + marginRight, + buttonRight: buttonRect.right, + cellRight: cellRect.right, + }; + }, null, { timeout: 1_500 }, ) - .then(() => true) + .then(async (handle) => { + const value = (await handle.jsonValue()) as GutterButtonGeometry | false; + await handle.dispose(); + return value; + }) .catch(() => false); - if (appeared) return; + if (geometry) return geometry; } - throw new Error("gutter-utility-slot did not appear after hover"); + throw new Error("gutter button did not extrude after hover"); +} + +async function readDiffBackgroundColors(testPage: Page) { + return testPage.evaluate(() => { + const container = document.querySelector("diffs-container"); + if (!container) throw new Error("diffs-container element not found"); + const shadow = container.shadowRoot; + if (!shadow) throw new Error("diffs-container shadow root is closed or not yet attached"); + const pre = shadow.querySelector("pre[data-diff]"); + if (!pre) throw new Error("pre[data-diff] not found in diffs-container shadow root"); + // Resolve var(--background) to a concrete rgb() via a probe element so we + // can compare it byte-for-byte to the shadow-DOM pre's computed bg. + const probe = document.createElement("div"); + probe.style.backgroundColor = `var(--background)`; + document.body.appendChild(probe); + const expected = getComputedStyle(probe).backgroundColor; + probe.remove(); + return { + pre: getComputedStyle(pre).backgroundColor, + expected, + }; + }); } test.describe("Diff expansion — Pierre Diffs provider", () => { - test.describe.configure({ retries: 2, timeout: 120_000 }); + test.describe.configure({ retries: 2, timeout: 360_000 }); test("diff viewer background matches app --background (regression for pierre 1.1.22 selector rename)", async ({ testPage, apiClient, seedData, }) => { + test.setTimeout(360_000); await seedExpansionTask(testPage, apiClient, seedData); await openChangesTab(testPage); await openExpansionFileDiff(testPage); @@ -137,25 +222,16 @@ test.describe("Diff expansion — Pierre Diffs provider", () => { // that variable to var(--background) on :host. If the selector ever stops // matching (as happened on the 1.0.11 -> 1.1.22 bump that renamed // data-diffs -> data-diff), pierre's dark default (#0a0c10) leaks through. - const colors = await testPage.evaluate(() => { - const container = document.querySelector("diffs-container"); - if (!container) throw new Error("diffs-container element not found"); - const shadow = container.shadowRoot; - if (!shadow) throw new Error("diffs-container shadow root is closed or not yet attached"); - const pre = shadow.querySelector("pre[data-diff]"); - if (!pre) throw new Error("pre[data-diff] not found in diffs-container shadow root"); - // Resolve var(--background) to a concrete rgb() via a probe element so we - // can compare it byte-for-byte to the shadow-DOM pre's computed bg. - const probe = document.createElement("div"); - probe.style.backgroundColor = `var(--background)`; - document.body.appendChild(probe); - const expected = getComputedStyle(probe).backgroundColor; - probe.remove(); - return { - pre: getComputedStyle(pre).backgroundColor, - expected, - }; - }); + await expect + .poll( + async () => { + const colors = await readDiffBackgroundColors(testPage); + return colors.pre === colors.expected; + }, + { message: "pre[data-diff] background should match app --background", timeout: 20_000 }, + ) + .toBe(true); + const colors = await readDiffBackgroundColors(testPage); await testPage.screenshot({ path: "test-results/diff-bg-regression.png", fullPage: false }); expect(colors.pre).toBe(colors.expected); @@ -215,29 +291,7 @@ test.describe("Diff expansion — Pierre Diffs provider", () => { // calc(1ch - 1lh) on our slotted button — same trick pierre uses on its // built-in [data-utility-button] — to push it outside the cell into the // code area. Verify the button's right edge ends up past the cell's right. - await hoverUntilGutterSlotAppears(testPage); - - const geometry = await testPage.evaluate(() => { - const container = document.querySelector("diffs-container")!; - const shadow = container.shadowRoot!; - const slotWrapper = shadow.querySelector("[data-gutter-utility-slot]"); - if (!slotWrapper) throw new Error("gutter-utility-slot did not appear after hover"); - const numberCell = slotWrapper.parentElement!; - // The slot wrapper is appended INTO numberCell; our React-rendered button - // is projected through the named . Its computed marginRight must - // resolve to a negative px value for the extrusion to work. - const slottedLight = document.querySelector('[slot="gutter-utility-slot"]'); - if (!slottedLight) throw new Error("light-DOM [slot=gutter-utility-slot] not found"); - const button = slottedLight.firstElementChild as HTMLElement | null; - if (!button) throw new Error("no button rendered inside slot"); - const buttonRect = button.getBoundingClientRect(); - const cellRect = numberCell.getBoundingClientRect(); - return { - marginRight: parseFloat(getComputedStyle(button).marginRight), - buttonRight: buttonRect.right, - cellRight: cellRect.right, - }; - }); + const geometry = await hoverUntilGutterButtonExtrudes(testPage); await testPage.screenshot({ path: "test-results/diff-hover-button-extrusion.png" }); expect(geometry.marginRight).toBeLessThan(0); diff --git a/apps/web/e2e/tests/git/diff-update-helpers.ts b/apps/web/e2e/tests/git/diff-update-helpers.ts index 2c71279d13..b2a648e014 100644 --- a/apps/web/e2e/tests/git/diff-update-helpers.ts +++ b/apps/web/e2e/tests/git/diff-update-helpers.ts @@ -2,14 +2,20 @@ import { expect } from "../../fixtures/test-base"; import { SessionPage } from "../../pages/session-page"; import type { ApiClient } from "../../helpers/api-client"; import type { SeedData } from "../../fixtures/test-base"; +import { GitHelper, makeGitEnv } from "../../helpers/git-helper"; import type { Page } from "@playwright/test"; const DIFFS_CONTAINER = "diffs-container"; type SeedTaskOptions = { title: string; - scenarioCommand: string; - completionText: string; + seedWorkspace: (workspacePath: string) => void; +}; + +type SeededDiffTask = { + session: SessionPage; + sessionId: string; + workspacePath: string; }; async function seedTaskWithScenario( @@ -17,13 +23,13 @@ async function seedTaskWithScenario( apiClient: ApiClient, seedData: SeedData, options: SeedTaskOptions, -): Promise<{ session: SessionPage; sessionId: string }> { +): Promise { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, options.title, seedData.agentProfileId, { - description: options.scenarioCommand, + description: "Prepare git diff fixture", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], @@ -32,17 +38,114 @@ async function seedTaskWithScenario( if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + const workspacePath = await waitForWorkspacePath(apiClient, task.id); + options.seedWorkspace(workspacePath); + await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); await closePreviewPanels(testPage); - await expect(session.chat.getByText(options.completionText, { exact: false })).toBeVisible({ - timeout: 45_000, - }); + return { session, sessionId: task.session_id, workspacePath }; +} + +async function waitForWorkspacePath(apiClient: ApiClient, taskId: string) { + let workspacePath: string | null = null; + await expect + .poll( + async () => { + const env = await apiClient.getTaskEnvironment(taskId); + if (env?.status !== "ready") return null; + workspacePath = env.worktree_path ?? env.workspace_path ?? null; + return workspacePath; + }, + { timeout: 45_000, message: "task workspace path should be available" }, + ) + .not.toBeNull(); + if (!workspacePath) throw new Error("task environment did not expose a workspace path"); + return workspacePath; +} + +function gitForWorkspace(workspacePath: string) { + return new GitHelper(workspacePath, makeGitEnv(workspacePath)); +} + +function commitPathsIfChanged(git: GitHelper, paths: string[], message: string) { + const quotedPaths = paths.map((filePath) => `"${filePath}"`).join(" "); + if (git.exec(`git status --porcelain -- ${quotedPaths}`).trim() === "") return; + git.commit(message); +} + +function seedDiffUpdateWorkspace(workspacePath: string) { + const git = gitForWorkspace(workspacePath); + const filePath = "diff_update_test.txt"; + const originalContent = "line 1: original\nline 2: unchanged\nline 3: original\n"; + const modifiedContent = "line 1: FIRST_MODIFICATION\nline 2: unchanged\nline 3: original\n"; + + git.exec(`git rm --force --ignore-unmatch "${filePath}"`); + commitPathsIfChanged(git, [filePath], "cleanup diff_update_test.txt"); + + git.createFile(filePath, originalContent); + git.stageFile(filePath); + commitPathsIfChanged(git, [filePath], "add diff_update_test.txt"); - return { session, sessionId: task.session_id }; + git.modifyFile(filePath, modifiedContent); +} + +function seedMultiFileWorkspace(workspacePath: string) { + const git = gitForWorkspace(workspacePath); + const files = ["multi_a.txt", "multi_b.txt", "multi_c.txt"]; + + for (const filePath of files) { + git.exec(`git rm --force --ignore-unmatch "${filePath}"`); + } + commitPathsIfChanged(git, files, "cleanup multi-file fixtures"); + + for (const filePath of files) { + const original = `${filePath} line 1: original\n${filePath} line 2: unchanged\n${filePath} line 3: original\n`; + git.createFile(filePath, original); + git.stageFile(filePath); + } + commitPathsIfChanged(git, files, "add multi-file fixtures"); + + for (const filePath of files) { + const modified = `${filePath} line 1: FIRST_MODIFICATION\n${filePath} line 2: unchanged\n${filePath} line 3: original\n`; + git.modifyFile(filePath, modified); + } +} + +function seedUntrackedWorkspace(workspacePath: string) { + const git = gitForWorkspace(workspacePath); + const filePath = "untracked_test.txt"; + + git.exec(`git rm --force --ignore-unmatch "${filePath}"`); + commitPathsIfChanged(git, [filePath], "cleanup untracked fixture"); + git.deleteFile(filePath); + git.createFile(filePath, "line 1: INITIAL_CONTENT\nline 2: some text\n"); +} + +export function writeDiffUpdateSecondModification(workspacePath: string) { + gitForWorkspace(workspacePath).modifyFile( + "diff_update_test.txt", + "line 1: SECOND_MODIFICATION\nline 2: unchanged\nline 3: ALSO_CHANGED\n", + ); +} + +export function writeMultiFileSecondModification(workspacePath: string) { + const git = gitForWorkspace(workspacePath); + for (const [index, filePath] of ["multi_a.txt", "multi_b.txt", "multi_c.txt"].entries()) { + git.modifyFile( + filePath, + `${filePath} line 1: SECOND_MODIFICATION\n${filePath} line 2: unchanged\n${filePath} line 3: ALSO_CHANGED_${index}\n`, + ); + } +} + +export function writeUntrackedModification(workspacePath: string) { + gitForWorkspace(workspacePath).modifyFile( + "untracked_test.txt", + "line 1: MODIFIED_CONTENT\nline 2: some text\nline 3: NEW_LINE\n", + ); } export async function closePreviewPanels(testPage: Page) { @@ -72,24 +175,21 @@ export async function closePreviewPanels(testPage: Page) { export function seedUntrackedFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) { return seedTaskWithScenario(testPage, apiClient, seedData, { title: "Untracked File E2E", - scenarioCommand: "/e2e:untracked-file-setup", - completionText: "untracked-file-setup complete", + seedWorkspace: seedUntrackedWorkspace, }); } export function seedDiffUpdateTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) { return seedTaskWithScenario(testPage, apiClient, seedData, { title: "Diff Update E2E", - scenarioCommand: "/e2e:diff-update-setup", - completionText: "diff-update-setup complete", + seedWorkspace: seedDiffUpdateWorkspace, }); } export function seedMultiFileTask(testPage: Page, apiClient: ApiClient, seedData: SeedData) { return seedTaskWithScenario(testPage, apiClient, seedData, { title: "Multi-file Diff Update E2E", - scenarioCommand: "/e2e:multi-file-setup", - completionText: "multi-file-setup complete", + seedWorkspace: seedMultiFileWorkspace, }); } diff --git a/apps/web/e2e/tests/git/diff-update.spec.ts b/apps/web/e2e/tests/git/diff-update.spec.ts index 24c8284b90..5d4e75d162 100644 --- a/apps/web/e2e/tests/git/diff-update.spec.ts +++ b/apps/web/e2e/tests/git/diff-update.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "../../fixtures/test-base"; +import type { SessionPage } from "../../pages/session-page"; import { fileDiffTab, getDiffsContainer, @@ -10,8 +11,25 @@ import { waitForDiffText, waitForDiffTextAbsent, waitForStoreFileDiffText, + writeDiffUpdateSecondModification, + writeMultiFileSecondModification, + writeUntrackedModification, } from "./diff-update-helpers"; +async function openFileTreeFile(session: SessionPage, filePath: string) { + const fileRow = session.fileTreeNode(filePath); + await expect + .poll( + async () => { + await session.clickTab("Files"); + return fileRow.isVisible(); + }, + { timeout: 20_000, intervals: [500, 1000, 2000] }, + ) + .toBe(true); + await fileRow.click(); +} + test.describe("Diff update on file change", () => { test.describe.configure({ retries: 2, timeout: 120_000 }); @@ -30,8 +48,8 @@ test.describe("Diff update on file change", () => { await waitForDiffText(testPage, "FIRST_MODIFICATION"); }); - test("diff updates when agent modifies file again", async ({ testPage, apiClient, seedData }) => { - const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData); + test("diff updates when the file changes again", async ({ testPage, apiClient, seedData }) => { + const { session, workspacePath } = await seedDiffUpdateTask(testPage, apiClient, seedData); await openChangesTab(testPage); await session.closeFileDiffPreview(); await openFileDiff(testPage, "diff_update_test.txt"); @@ -42,16 +60,7 @@ test.describe("Diff update on file change", () => { await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); await waitForDiffText(testPage, "FIRST_MODIFICATION"); - // Click on the session tab to make the chat input visible again - await session.clickSessionChatTab(); - - // Send another message to trigger the second modification - await session.sendMessage("/e2e:diff-update-modify"); - - // Wait for the second turn to complete - await expect( - session.chat.getByText("diff-update-modify complete", { exact: false }), - ).toBeVisible({ timeout: 45_000 }); + writeDiffUpdateSecondModification(workspacePath); // Switch back to Changes tab and click on the diff file again to see the updated diff. // The git status (with diff data) should have been updated via polling when @@ -81,7 +90,11 @@ test.describe("Diff update on file change", () => { }) => { // This test verifies the Diff [file] preview shows the latest git-status // snapshot after the underlying file changes. - const { session, sessionId } = await seedDiffUpdateTask(testPage, apiClient, seedData); + const { session, sessionId, workspacePath } = await seedDiffUpdateTask( + testPage, + apiClient, + seedData, + ); await openChangesTab(testPage); await openFileDiff(testPage, "diff_update_test.txt"); @@ -90,12 +103,7 @@ test.describe("Diff update on file change", () => { await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000); - // Switch to chat, trigger the second modification - await session.clickSessionChatTab(); - await session.sendMessage("/e2e:diff-update-modify"); - await expect( - session.chat.getByText("diff-update-modify complete", { exact: false }), - ).toBeVisible({ timeout: 45_000 }); + writeDiffUpdateSecondModification(workspacePath); // Wait for the Changes panel's git status source to observe the second // modification. Do not re-open the file diff; just verify the status source @@ -159,16 +167,12 @@ test.describe("File editor auto-update on file change", () => { seedData, }) => { // Regression: opening a file in the FileEditorPanel and then having the - // agent modify it should reactively update the editor content WITHOUT the + // file changes should reactively update the editor content WITHOUT the // user re-clicking the file. - const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData); + const { session, workspacePath } = await seedDiffUpdateTask(testPage, apiClient, seedData); // Open the file in the file editor via the Files tree. - await session.clickTab("Files"); - await expect(session.files).toBeVisible({ timeout: 10_000 }); - const fileRow = session.files.getByText("diff_update_test.txt"); - await expect(fileRow).toBeVisible({ timeout: 10_000 }); - await fileRow.click(); + await openFileTreeFile(session, "diff_update_test.txt"); // The editor tab should appear in dockview. const editorTab = testPage.locator(".dv-default-tab", { hasText: "diff_update_test.txt" }); @@ -178,12 +182,7 @@ test.describe("File editor auto-update on file change", () => { const editorContent = testPage.locator(".view-lines").first(); await expect(editorContent).toContainText("FIRST_MODIFICATION", { timeout: 15_000 }); - // Switch to chat and trigger the second modification. - await session.clickSessionChatTab(); - await session.sendMessage("/e2e:diff-update-modify"); - await expect( - session.chat.getByText("diff-update-modify complete", { exact: false }), - ).toBeVisible({ timeout: 45_000 }); + writeDiffUpdateSecondModification(workspacePath); // Click the editor tab back to view it (do NOT re-open from file tree). // The panel is still mounted, just not the active tab. @@ -199,16 +198,15 @@ test.describe("File editor auto-update on file change", () => { await expect(updatedEditorContent).not.toContainText("FIRST_MODIFICATION", { timeout: 5_000 }); }); - test("editor + diff panels auto-update while agent is streaming (mid-turn)", async ({ + test("editor + diff panels auto-update after direct file change", async ({ testPage, apiClient, seedData, }) => { // Reproduces the user-reported bug: open both editor and diff from the - // Changes tab, then have the agent modify the file MID-TURN (still - // streaming). Both panels must auto-update within a few seconds while - // the agent's turn is still active — without re-opening the file. - const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData); + // Changes tab, then modify the file without re-opening either panel. + // Both panels must auto-update within a few seconds. + const { session, workspacePath } = await seedDiffUpdateTask(testPage, apiClient, seedData); // Open the diff panel from the Changes tab. await openChangesTab(testPage); @@ -218,11 +216,7 @@ test.describe("File editor auto-update on file change", () => { await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000); // Also open the file editor for the same file via the Files tree. - await session.clickTab("Files"); - await expect(session.files).toBeVisible({ timeout: 5_000 }); - const fileRow = session.files.getByText("diff_update_test.txt"); - await expect(fileRow).toBeVisible({ timeout: 10_000 }); - await fileRow.click(); + await openFileTreeFile(session, "diff_update_test.txt"); const editorTab = testPage.locator(".dv-default-tab[type='file-editor']", { hasText: "diff_update_test.txt", }); @@ -230,19 +224,9 @@ test.describe("File editor auto-update on file change", () => { const editorContent = testPage.locator(".view-lines").first(); await expect(editorContent).toContainText("FIRST_MODIFICATION", { timeout: 15_000 }); - // Trigger the streaming scenario: agent will write the file mid-turn and - // keep emitting text for ~6s afterwards. Do NOT wait for completion — - // we want to assert updates while the turn is still streaming. - await session.clickSessionChatTab(); - await session.sendMessage("/e2e:diff-update-streaming"); + writeDiffUpdateSecondModification(workspacePath); - // Wait for the agent to confirm it has started — turn is now live. - await expect(session.chat.getByText("starting work", { exact: false })).toBeVisible({ - timeout: 30_000, - }); - - // While the agent is still mid-turn (it has ~6s of trailing delay), both - // panels must reflect the new content. This is the user's bug scenario. + // Both panels must reflect the new content. // Click back to the editor tab to make assertions. await editorTab.click(); const liveEditorContent = testPage.locator(".view-lines").first(); @@ -263,7 +247,7 @@ test.describe("File editor auto-update on file change", () => { test.describe("Multi-file editor + diff auto-update", () => { test.describe.configure({ retries: 2, timeout: 180_000 }); - test("diff panel auto-updates across all 3 files during a single multi-file streaming turn", async ({ + test("diff panel auto-updates across all 3 files during one multi-file change", async ({ testPage, apiClient, seedData, @@ -274,11 +258,11 @@ test.describe("Multi-file editor + diff auto-update", () => { // must propagate to gitStatus so: // - The currently open diff (file_a) auto-updates mid-turn. // - Switching the preview to file_b shows file_b's NEW diff immediately - // (not the pre-turn FIRST_MODIFICATION). + // (not the pre-change FIRST_MODIFICATION). // - Same for file_c. // This exercises the gitStatus-driven re-render path without the user // re-triggering anything per-file. - const { session } = await seedMultiFileTask(testPage, apiClient, seedData); + const { workspacePath } = await seedMultiFileTask(testPage, apiClient, seedData); const fileA = "multi_a.txt"; const fileB = "multi_b.txt"; const fileC = "multi_c.txt"; @@ -290,25 +274,19 @@ test.describe("Multi-file editor + diff auto-update", () => { await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); await waitForDiffText(testPage, "FIRST_MODIFICATION", 15_000); - // Trigger the multi-file streaming modification. Don't wait for completion. - await session.clickSessionChatTab(); - await session.sendMessage("/e2e:multi-file-modify"); - await expect(session.chat.getByText("starting work", { exact: false })).toBeVisible({ - timeout: 30_000, - }); + writeMultiFileSecondModification(workspacePath); // Click back to the file_a diff tab so its panel becomes the visible one. const diffTabA = fileDiffTab(testPage, fileA); await expect(diffTabA).toBeVisible({ timeout: 10_000 }); await diffTabA.click(); - // While the agent is still streaming, the file_a diff must reflect - // SECOND_MODIFICATION + ALSO_CHANGED_0. + // The file_a diff must reflect SECOND_MODIFICATION + ALSO_CHANGED_0. await waitForDiffText(testPage, "SECOND_MODIFICATION", 15_000); await waitForDiffText(testPage, "ALSO_CHANGED_0", 5_000); // Swap the diff preview to file_b — the gitStatus-driven content must - // already have SECOND_MODIFICATION available without re-running the agent. + // already have SECOND_MODIFICATION available without another user action. await openChangesTab(testPage); await openFileDiff(testPage, fileB); await waitForDiffText(testPage, "ALSO_CHANGED_1", 15_000); @@ -321,9 +299,9 @@ test.describe("Multi-file editor + diff auto-update", () => { }); test.describe("User-save then diff view (colleague repro)", () => { - // Intermittent bug: after agent modifies a file, user opens it in editor, + // Intermittent bug: after a file changes, user opens it in editor, // edits + saves, then switches to diff view \u2014 the diff panel shows the - // pre-save content (agent's edit only, not the user's save). Workaround + // pre-save content (seeded edit only, not the user's save). Workaround // reported: leave the task and re-enter. We try to repro by driving the // exact UI sequence and asserting the diff contains the user's marker. test.describe.configure({ retries: 2, timeout: 120_000 }); @@ -336,7 +314,7 @@ test.describe("User-save then diff view (colleague repro)", () => { const USER_MARKER = "USER_EDIT_MARKER_42"; const { session } = await seedDiffUpdateTask(testPage, apiClient, seedData); - // Step 1: agent has already modified diff_update_test.txt with FIRST_MODIFICATION. + // Step 1: diff_update_test.txt already has FIRST_MODIFICATION. // Wait for the Changes panel auto-activate (fires once gitStatus 0\u2192N) to // settle before we click Files \u2014 otherwise the auto-activate races with // our click and Files ends up inactive. Match any "Changes (N)" count so we @@ -387,7 +365,7 @@ test.describe("User-save then diff view (colleague repro)", () => { const diffsContainer = getDiffsContainer(testPage); await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); await waitForDiffText(testPage, USER_MARKER, 15_000); - // FIRST_MODIFICATION should still be present (agent's earlier edit). + // FIRST_MODIFICATION should still be present (the earlier seeded edit). await waitForDiffText(testPage, "FIRST_MODIFICATION", 5_000); }); }); @@ -399,7 +377,7 @@ test.describe("Untracked file diff update", () => { // This test verifies that modifying an untracked file triggers a git status update // and the diff viewer shows the updated content. This was a bug where the polling // mechanism didn't detect untracked file changes (git diff-files only shows tracked files). - const { session } = await seedUntrackedFileTask(testPage, apiClient, seedData); + const { workspacePath } = await seedUntrackedFileTask(testPage, apiClient, seedData); await openChangesTab(testPage); await openFileDiff(testPage, "untracked_test.txt"); @@ -409,19 +387,7 @@ test.describe("Untracked file diff update", () => { await expect(diffsContainer).toBeVisible({ timeout: 15_000 }); await waitForDiffText(testPage, "INITIAL_CONTENT", 15_000); - // Click on the session tab to make the chat input visible again - await session.clickSessionChatTab(); - - // Send another message to trigger the modification - await session.sendMessage("/e2e:untracked-file-modify"); - - // Wait for the second turn to complete - await expect( - session.chat.getByText("untracked-file-modify complete", { exact: false }), - ).toBeVisible({ timeout: 45_000 }); - - // Wait for git polling to detect the file change (polling interval is ~1-2s) - await testPage.waitForTimeout(3_000); + writeUntrackedModification(workspacePath); // Switch back to Changes tab and click on the diff file again await openChangesTab(testPage); diff --git a/apps/web/e2e/tests/git/symlink-file.spec.ts b/apps/web/e2e/tests/git/symlink-file.spec.ts index a97f9ddd55..21fdd953e8 100644 --- a/apps/web/e2e/tests/git/symlink-file.spec.ts +++ b/apps/web/e2e/tests/git/symlink-file.spec.ts @@ -4,6 +4,7 @@ import { type Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; +import { GitHelper, makeGitEnv } from "../../helpers/git-helper"; import { SessionPage } from "../../pages/session-page"; /** @@ -27,20 +28,59 @@ async function seedSimpleTask( }, ); + await waitForTaskWorkspacePath(apiClient, task.id); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); - return { session, sessionId: task.session_id ?? task.id }; } -/** - * Seed a task using the symlink-file-setup mock scenario. The scenario creates - * real-file.txt, a symlink link-file.txt → real-file.txt, commits both, then - * modifies real-file.txt leaving an uncommitted diff. - */ +async function waitForTaskWorkspacePath(apiClient: ApiClient, taskId: string): Promise { + let workspacePath = ""; + await expect + .poll( + async () => { + const env = await apiClient.getTaskEnvironment(taskId); + workspacePath = + env?.status === "ready" ? (env.worktree_path ?? env.workspace_path ?? "") : ""; + return workspacePath; + }, + { timeout: 45_000, message: "task environment should be ready" }, + ) + .not.toBe(""); + + return workspacePath; +} + +function gitForWorkspace(workspacePath: string) { + return new GitHelper(workspacePath, makeGitEnv(workspacePath)); +} + +function commitPathsIfChanged(git: GitHelper, paths: string[], message: string) { + const quotedPaths = paths.map((filePath) => `"${filePath}"`).join(" "); + if (git.exec(`git status --porcelain -- ${quotedPaths}`).trim() === "") return; + git.commit(message); +} + +function seedSymlinkDiffWorkspace(workspacePath: string) { + const git = gitForWorkspace(workspacePath); + const paths = ["real-file.txt", "link-file.txt"]; + + git.exec(`git rm --force --ignore-unmatch ${paths.map((filePath) => `"${filePath}"`).join(" ")}`); + for (const filePath of paths) { + fs.rmSync(path.join(workspacePath, filePath), { force: true }); + } + commitPathsIfChanged(git, paths, "cleanup symlink diff fixture"); + + git.createFile("real-file.txt", "Original symlink target\n"); + fs.symlinkSync("real-file.txt", path.join(workspacePath, "link-file.txt")); + git.stageFile("real-file.txt"); + git.stageFile("link-file.txt"); + commitPathsIfChanged(git, paths, "add symlink diff fixture"); + + git.modifyFile("real-file.txt", "Modified symlink target\n"); +} + async function seedSymlinkDiffTask( testPage: Page, apiClient: ApiClient, @@ -51,22 +91,18 @@ async function seedSymlinkDiffTask( "Symlink Diff E2E", seedData.agentProfileId, { - description: "/e2e:symlink-file-setup", + description: "Prepare symlink diff fixture", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }, ); + const workspacePath = await waitForTaskWorkspacePath(apiClient, task.id); + seedSymlinkDiffWorkspace(workspacePath); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); - - await expect(session.chat.getByText("symlink-file-setup complete", { exact: false })).toBeVisible( - { timeout: 45_000 }, - ); - return session; } diff --git a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts index 45d28fa834..014baaf645 100644 --- a/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts +++ b/apps/web/e2e/tests/kanban/card-menu-delete-archive.spec.ts @@ -84,7 +84,7 @@ test.describe("Kanban card actions menu — delete/archive does not navigate", ( }); }); -// Regression: in "All Workflows" swimlane view, state.kanban.workflowId is null. +// Regression: in "All Workflows" swimlane view, there is no single active workflow id. // useTaskCRUD's handleDelete/handleArchive used to early-return on that, so the // dialog closed but no API call ran and the task stayed on the board. test.describe("Kanban card actions menu — delete/archive in All Workflows view", () => { diff --git a/apps/web/e2e/tests/kanban/preview-primary-session.spec.ts b/apps/web/e2e/tests/kanban/preview-primary-session.spec.ts index 1a60cf04fd..ecb0840f90 100644 --- a/apps/web/e2e/tests/kanban/preview-primary-session.spec.ts +++ b/apps/web/e2e/tests/kanban/preview-primary-session.spec.ts @@ -1,6 +1,6 @@ +import { randomUUID } from "node:crypto"; import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; -import { SessionPage } from "../../pages/session-page"; const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"]; @@ -16,60 +16,41 @@ test.describe("Preview primary session", () => { }) => { test.setTimeout(120_000); - // 1. Create a task with agent — first session becomes primary - const task = await apiClient.createTaskWithAgent( - seedData.workspaceId, - "Preview Primary Task", - seedData.agentProfileId, - { - description: "/e2e:simple-message", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }, - ); - - // 2. Wait for first session to finish - await expect - .poll( - async () => { - const { sessions } = await apiClient.listTaskSessions(task.id); - return DONE_STATES.includes(sessions[0]?.state); - }, - { timeout: 30_000, message: "Waiting for first session to finish" }, - ) - .toBe(true); - - // Capture the primary session ID - const { sessions: sessionsAfterFirst } = await apiClient.listTaskSessions(task.id); - const primaryId = sessionsAfterFirst[0].id; - - // 3. Navigate to the task session page - const kanban = new KanbanPage(testPage); - await kanban.goto(); - - const card = kanban.taskCardByTitle("Preview Primary Task"); - await expect(card).toBeVisible({ timeout: 10_000 }); - await card.click(); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); - - const session = new SessionPage(testPage); - await session.waitForLoad(); - await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ - timeout: 15_000, + // 1. Create a task, then seed two completed sessions directly. This spec + // exercises kanban preview session selection, not mock-agent execution. + const task = await apiClient.createTask(seedData.workspaceId, "Preview Primary Task", { + description: "Preview primary session", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], }); - // 4. Create a second session via the new session dialog - await session.addPanelButton().click(); - await testPage.getByTestId("new-session-button").click(); + const now = Date.now(); + const primaryId = randomUUID(); + await apiClient.seedTaskSession(task.id, { + sessionId: primaryId, + state: "COMPLETED", + startedAt: new Date(now).toISOString(), + completedAt: new Date(now + 1_000).toISOString(), + }); + await apiClient.seedSessionMessage(primaryId, { + type: "message", + content: "simple mock response from primary session", + }); + await apiClient.setPrimarySession(primaryId); - const dialog = testPage.getByRole("dialog"); - await expect(dialog).toBeVisible({ timeout: 5_000 }); - await dialog.locator("textarea").fill('e2e:message("secondary-agent-response")'); - await dialog.getByRole("button").filter({ hasText: /Start/ }).click(); - await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + const secondary = await apiClient.seedTaskSession(task.id, { + sessionId: randomUUID(), + state: "COMPLETED", + startedAt: new Date(now + 2_000).toISOString(), + completedAt: new Date(now + 3_000).toISOString(), + }); + await apiClient.seedSessionMessage(secondary.session_id, { + type: "message", + content: "secondary-agent-response", + }); - // 5. Wait for second session to finish + // 4. Wait for both sessions to be visible to the API. await expect .poll( async () => { @@ -84,30 +65,81 @@ test.describe("Preview primary session", () => { const taskData = await apiClient.getTask(task.id); expect(taskData.primary_session_id).toBe(primaryId); - // 6. Enable preview-on-click and navigate back to kanban + // 5. Enable preview-on-click and navigate to kanban await apiClient.saveUserSettings({ enable_preview_on_click: true }); + const kanban = new KanbanPage(testPage); await kanban.goto(); - // 7. Verify the kanban store has primarySessionId + // 6. Verify the workflow snapshot query cache has primary_session_id + await expect + .poll( + async () => + testPage.evaluate((taskId) => { + type WorkflowSnapshot = { + tasks?: Array<{ id: string; primary_session_id?: string | null }>; + }; + type QueryClient = { + getQueryCache: () => { + findAll: () => Array<{ + queryKey: readonly unknown[]; + state: { data: unknown }; + }>; + }; + }; + const win = window as unknown as { __KANDEV_E2E_QUERY_CLIENT__?: QueryClient }; + const queryClient = win.__KANDEV_E2E_QUERY_CLIENT__; + if (!queryClient) return null; + const snapshots = queryClient + .getQueryCache() + .findAll() + .filter( + (query) => query.queryKey[0] === "workflows" && query.queryKey[2] === "snapshot", + ) + .map((query) => query.state.data as WorkflowSnapshot | undefined) + .filter((snapshot): snapshot is WorkflowSnapshot => Boolean(snapshot)); + const tasks = snapshots.flatMap((snapshot) => snapshot.tasks ?? []); + return tasks.find((item) => item.id === taskId)?.primary_session_id ?? null; + }, task.id), + { timeout: 10_000, message: "workflow snapshot query includes primary session" }, + ) + .toBe(primaryId); + const storeData = await testPage.evaluate((taskId) => { - // Access zustand store from the window (exposed by state-provider) - type KandevStore = { - getState: () => { kanban: { tasks: { id: string; primarySessionId?: string | null }[] } }; + type WorkflowSnapshot = { + tasks?: Array<{ id: string; primary_session_id?: string | null }>; + }; + type QueryClient = { + getQueryCache: () => { + findAll: () => Array<{ + queryKey: readonly unknown[]; + state: { data: unknown }; + }>; + }; }; - const win = window as unknown as { __KANDEV_STORE?: KandevStore }; - const store = win.__KANDEV_STORE; - if (!store) return { error: "no store" }; - const state = store.getState(); - const kanbanTask = state.kanban.tasks.find((t) => t.id === taskId); + const win = window as unknown as { __KANDEV_E2E_QUERY_CLIENT__?: QueryClient }; + const queryClient = win.__KANDEV_E2E_QUERY_CLIENT__; + if (!queryClient) return { error: "no query client" }; + const snapshots = queryClient + .getQueryCache() + .findAll() + .filter((query) => query.queryKey[0] === "workflows" && query.queryKey[2] === "snapshot") + .map((query) => query.state.data as WorkflowSnapshot | undefined) + .filter((snapshot): snapshot is WorkflowSnapshot => Boolean(snapshot)); + const tasks = snapshots.flatMap((snapshot) => snapshot.tasks ?? []); + const task = tasks.find((item) => item.id === taskId); return { - primarySessionId: kanbanTask?.primarySessionId ?? null, - taskFound: !!kanbanTask, - taskCount: state.kanban.tasks.length, + primarySessionId: task?.primary_session_id ?? null, + taskFound: !!task, + taskCount: tasks.length, }; }, task.id); - console.log("Store debug:", JSON.stringify(storeData)); + expect(storeData).toEqual({ + primarySessionId: primaryId, + taskFound: true, + taskCount: expect.any(Number), + }); - // 8. Click the task card to open the preview panel. + // 7. Click the task card to open the preview panel. // Wait for the "Open full page" button to appear on the card — this button is only // rendered when enablePreviewOnClick: true, so its presence confirms the SSR-hydrated // settings have been applied to the store before we click. @@ -121,13 +153,13 @@ test.describe("Preview primary session", () => { // Wait for preview panel to appear await expect(testPage).toHaveURL(/taskId=/, { timeout: 10_000 }); - // 9. The preview should show the primary session's content + // 8. The preview should show the primary session's content const previewPanel = testPage.getByTestId("task-preview-panel"); await expect(previewPanel.getByText("simple mock response", { exact: false })).toBeVisible({ timeout: 15_000, }); - // 10. The secondary session's content should NOT be visible + // 9. The secondary session's content should NOT be visible await expect( previewPanel.getByText("secondary-agent-response", { exact: false }), ).not.toBeVisible({ timeout: 3_000 }); diff --git a/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts b/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts index d740ff81a5..789166fbbe 100644 --- a/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts +++ b/apps/web/e2e/tests/kanban/preview-session-tabs.spec.ts @@ -1,6 +1,6 @@ +import { randomUUID } from "node:crypto"; import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; -import { SessionPage } from "../../pages/session-page"; const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"]; @@ -20,63 +20,42 @@ test.describe("Preview session tabs", () => { }) => { test.setTimeout(180_000); - // 1. Create a task — first session becomes primary. - // Task descriptions use the scenario registry (`/e2e:`), so we pick a - // scenario with a unique, agent-only response string to avoid prompt/response - // text collisions in `getByText` assertions. - const task = await apiClient.createTaskWithAgent( - seedData.workspaceId, - "Preview Tabs Task", - seedData.agentProfileId, - { - description: "/e2e:simple-message", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }, - ); - - // 2. Wait for first session to finish. - await expect - .poll( - async () => { - const { sessions } = await apiClient.listTaskSessions(task.id); - return DONE_STATES.includes(sessions[0]?.state); - }, - { timeout: 30_000, message: "Waiting for first session to finish" }, - ) - .toBe(true); - - const { sessions: afterFirst } = await apiClient.listTaskSessions(task.id); - const primaryId = afterFirst[0].id; - - // 3. Navigate to the full task view and launch a second session via the new-session dialog. - // This mirrors the approach in preview-primary-session.spec.ts since there is no - // dedicated API helper to start a second session on an existing task. - const kanban = new KanbanPage(testPage); - await kanban.goto(); - - const card = kanban.taskCardByTitle("Preview Tabs Task"); - await expect(card).toBeVisible({ timeout: 10_000 }); - await card.click(); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + // 1. Create a task and seed two completed sessions directly. This spec + // tests read-only preview tabs, not full-page new-session creation. + const task = await apiClient.createTask(seedData.workspaceId, "Preview Tabs Task", { + description: "Preview session tabs", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); - const session = new SessionPage(testPage); - await session.waitForLoad(); - await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({ - timeout: 15_000, + const now = Date.now(); + const primaryId = randomUUID(); + await apiClient.seedTaskSession(task.id, { + sessionId: primaryId, + state: "COMPLETED", + startedAt: new Date(now).toISOString(), + completedAt: new Date(now + 1_000).toISOString(), }); + await apiClient.seedSessionMessage(primaryId, { + type: "message", + content: "simple mock response from primary session", + }); + await apiClient.setPrimarySession(primaryId); - await session.addPanelButton().click(); - await testPage.getByTestId("new-session-button").click(); - const dialog = testPage.getByRole("dialog"); - await expect(dialog).toBeVisible({ timeout: 5_000 }); - // Dialog prompts use the script command form; the agent echoes the argument. - await dialog.locator("textarea").fill('e2e:message("secondary-session-response")'); - await dialog.getByRole("button").filter({ hasText: /Start/ }).click(); - await expect(dialog).not.toBeVisible({ timeout: 10_000 }); + const secondary = await apiClient.seedTaskSession(task.id, { + sessionId: randomUUID(), + state: "COMPLETED", + startedAt: new Date(now + 2_000).toISOString(), + completedAt: new Date(now + 3_000).toISOString(), + }); + const secondaryId = secondary.session_id; + await apiClient.seedSessionMessage(secondaryId, { + type: "message", + content: "secondary-session-response", + }); - // 4. Wait for the second session to finish (two sessions in a done state). + // 2. Wait for both sessions to be visible to the API. await expect .poll( async () => { @@ -87,16 +66,9 @@ test.describe("Preview session tabs", () => { ) .toBe(2); - const { sessions: afterSecond } = await apiClient.listTaskSessions(task.id); - const secondaryId = afterSecond.find((s) => s.id !== primaryId)?.id; - if (!secondaryId) throw new Error("Secondary session not created"); - - // The first session remains primary by default — creating a second via the - // new-session dialog does not steal the primary flag (verified by - // preview-primary-session.spec.ts). - - // 5. Enable preview-on-click and return to the kanban board. + // 3. Enable preview-on-click and open the kanban board. await apiClient.saveUserSettings({ enable_preview_on_click: true }); + const kanban = new KanbanPage(testPage); await kanban.goto(); const previewCard = kanban.taskCardByTitle("Preview Tabs Task"); @@ -106,7 +78,7 @@ test.describe("Preview session tabs", () => { }); await previewCard.click(); - // 6. Preview panel + both tabs are visible. + // 4. Preview panel + both tabs are visible. const previewPanel = testPage.getByTestId("task-preview-panel"); await expect(previewPanel).toBeVisible({ timeout: 10_000 }); @@ -115,7 +87,7 @@ test.describe("Preview session tabs", () => { await expect(primaryTab).toBeVisible({ timeout: 10_000 }); await expect(secondaryTab).toBeVisible(); - // 7. Primary tab is active by default and its session content is visible. + // 5. Primary tab is active by default and its session content is visible. // "simple mock response" appears only in the agent's reply, not in any prompt, // so the single getByText match is unambiguous. await expect(primaryTab).toHaveAttribute("data-state", "active"); @@ -124,10 +96,7 @@ test.describe("Preview session tabs", () => { timeout: 15_000, }); - // 8. Click the secondary tab → content switches, URL updates. - // The echoed marker "secondary-session-response" appears in both the user - // prompt and the agent reply; `.first()` picks one deterministically and - // is enough to prove the secondary session's body is rendered. + // 6. Click the secondary tab → content switches, URL updates. await secondaryTab.click(); await expect(secondaryTab).toHaveAttribute("data-state", "active"); await expect(primaryTab).toHaveAttribute("data-state", "inactive"); diff --git a/apps/web/e2e/tests/kanban/stale-session-navigation.spec.ts b/apps/web/e2e/tests/kanban/stale-session-navigation.spec.ts index bcc4258b01..0f91a983f6 100644 --- a/apps/web/e2e/tests/kanban/stale-session-navigation.spec.ts +++ b/apps/web/e2e/tests/kanban/stale-session-navigation.spec.ts @@ -1,6 +1,6 @@ +import { randomUUID } from "node:crypto"; import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; -import { SessionPage } from "../../pages/session-page"; /** * Regression test: navigating from a task with chat messages to a sessionless @@ -18,67 +18,64 @@ test.describe("Stale session navigation", () => { }) => { test.setTimeout(120_000); - // 1. Create Task A with an agent session that produces messages - await apiClient.createTaskWithAgent( - seedData.workspaceId, - "Task With Messages", - seedData.agentProfileId, - { - description: "/e2e:simple-message", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }, - ); + // 1. Create Task A with a completed session and message history. + const taskA = await apiClient.createTask(seedData.workspaceId, "Task With Messages", { + description: "Task with seeded messages", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + const sessionId = randomUUID(); + const now = Date.now(); + await apiClient.seedTaskSession(taskA.id, { + sessionId, + state: "COMPLETED", + startedAt: new Date(now).toISOString(), + completedAt: new Date(now + 1_000).toISOString(), + }); + await apiClient.seedSessionMessage(sessionId, { + type: "message", + content: "simple mock response from stale-session source", + }); + await apiClient.setPrimarySession(sessionId); - // 2. Navigate to Task A's session page and wait for its message. - // We wait for the chat message directly instead of polling session state - // because the mock agent emits the message quickly; the backend session - // state transition to COMPLETED/WAITING_FOR_INPUT can be slow in CI. + // 2. Create Task B with no session — simulates a PR watcher or new task. + const taskB = await apiClient.createTask(seedData.workspaceId, "Sessionless Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + // 3. Open Task A in the kanban preview and verify its message is visible. + await apiClient.saveUserSettings({ enable_preview_on_click: true }); const kanban = new KanbanPage(testPage); await kanban.goto(); - const cardA = kanban.taskCardByTitle("Task With Messages"); + const cardA = kanban.taskCard(taskA.id); await expect(cardA).toBeVisible({ timeout: 15_000 }); await cardA.click(); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); - - const session = new SessionPage(testPage); - await session.waitForLoad(); - await expect( - session.activeChat().getByText("simple mock response", { exact: false }), - ).toBeVisible({ + const previewPanel = testPage.getByTestId("task-preview-panel"); + await expect(previewPanel).toBeVisible({ timeout: 10_000 }); + await expect(previewPanel.getByText("simple mock response", { exact: false })).toBeVisible({ timeout: 30_000, }); - // 3. Create Task B (no agent session — simulates a PR watcher or new task) - await apiClient.createTask(seedData.workspaceId, "Sessionless Task", { - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - }); - - // 4. Go back to kanban - await kanban.goto(); - await expect(kanban.board).toBeVisible({ timeout: 10_000 }); + // 4. Close the floating preview, then click Task B from kanban. The + // floating layout uses a backdrop that intentionally blocks board clicks. + await testPage.getByLabel("Close preview").click(); + await expect(previewPanel).not.toBeVisible({ timeout: 5_000 }); - // 5. Click on Task B from kanban - const cardB = kanban.taskCardByTitle("Sessionless Task"); + const cardB = kanban.taskCard(taskB.id); await expect(cardB).toBeVisible({ timeout: 10_000 }); await cardB.click(); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); - // 6. Verify Task B's page shows the correct title. - // Use the breadcrumb link specifically — `getByText` collides with the - // task card still visible in the sidebar list, triggering a strict-mode - // failure ("resolved to 2 elements") under flaky conditions where both - // surfaces happen to render the title. - await expect(testPage.getByRole("link", { name: "Sessionless Task" })).toBeVisible({ + // 5. Verify Task B's preview shows the correct title. + await expect(previewPanel.getByText("Sessionless Task", { exact: true })).toBeVisible({ timeout: 10_000, }); - // 7. Task A's messages must NOT appear on Task B's page - await expect(testPage.getByText("simple mock response", { exact: false })).not.toBeVisible({ + // 6. Task A's messages must NOT appear on Task B's preview. + await expect(previewPanel.getByText("simple mock response", { exact: false })).not.toBeVisible({ timeout: 5_000, }); }); diff --git a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts index 87a7731d51..8609697d6c 100644 --- a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts +++ b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts @@ -124,6 +124,159 @@ async function setGitStatusForSession(testPage: Page, sessionId: string, changed ); } +function createIsolatedGitRepo(backendTmpDir: string, prefix: string) { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const repoDir = path.join(backendTmpDir, "repos", `${prefix}-${suffix}`); + const gitEnv = makeGitEnv(backendTmpDir); + fs.mkdirSync(repoDir, { recursive: true }); + execSync("git init -b main", { cwd: repoDir, env: gitEnv }); + execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env: gitEnv }); + return { suffix, repoDir, gitEnv }; +} + +async function clearDockviewLayoutStorage(testPage: Page) { + await testPage + .evaluate(() => { + window.localStorage.removeItem("dockview-layout-v3"); + for (const key of Object.keys(window.sessionStorage)) { + if (key.startsWith("kandev.dockview.env-layout-v3.")) { + window.sessionStorage.removeItem(key); + } + } + }) + .catch(() => undefined); +} + +async function activeDockviewPanelId(testPage: Page): Promise { + return testPage.evaluate(() => { + type Api = { activePanel?: { id?: string } | null }; + const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__; + return api?.activePanel?.id ?? null; + }); +} + +async function activateSessionPanelWithDockviewApi( + testPage: Page, + sessionId: string, +): Promise { + let activatedPanelId: string | null = null; + await expect + .poll( + async () => { + activatedPanelId = await testPage.evaluate((sessionId) => { + type PanelApi = { setActive: () => void }; + type Panel = { id: string; api: PanelApi }; + type Api = { + panels: Panel[]; + getPanel: (id: string) => Panel | undefined; + activePanel?: Panel | null; + }; + const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__; + if (!api) return null; + + const panel = + api.getPanel(`session:${sessionId}`) ?? + api.getPanel("chat") ?? + api.panels.find((p) => p.id.startsWith("session:")); + if (!panel) return null; + + panel.api.setActive(); + return api.activePanel?.id ?? panel.id; + }, sessionId); + return activatedPanelId; + }, + { timeout: 20_000, message: "Activating session panel through dockview api" }, + ) + .not.toBeNull(); + return activatedPanelId ?? ""; +} + +async function persistLayoutWithSessionActive( + testPage: Page, + sessionId: string, + taskEnvironmentId: string | undefined, +) { + await testPage.evaluate( + ({ sessionId, taskEnvironmentId }) => { + type LeafData = { id?: string; views?: string[]; activeView?: string }; + type SerializedNode = { data?: unknown }; + type SerializedDockview = { + activeGroup?: string; + grid?: { root?: SerializedNode }; + panels?: Record; + }; + type Api = { toJSON: () => SerializedDockview }; + + const api = (window as unknown as { __dockviewApi__?: Api }).__dockviewApi__; + if (!api) throw new Error("Dockview api was not available"); + + const json = api.toJSON(); + const panelIds = Object.keys(json.panels ?? {}); + const candidates = [ + `session:${sessionId}`, + "chat", + ...panelIds.filter((id) => id.startsWith("session:")), + ]; + let activeGroup: string | undefined; + + const setActiveSessionView = (node: unknown): boolean => { + if (!node || typeof node !== "object") return false; + const data = (node as SerializedNode).data; + if (Array.isArray(data)) return data.some(setActiveSessionView); + + const leaf = data as LeafData | undefined; + if (!leaf || !Array.isArray(leaf.views)) return false; + const panelId = candidates.find((id) => leaf.views?.includes(id)); + if (!panelId) return false; + + leaf.activeView = panelId; + activeGroup = leaf.id; + return true; + }; + + if (!setActiveSessionView(json.grid?.root)) { + throw new Error(`No session panel found in layout: ${panelIds.join(", ")}`); + } + if (activeGroup) json.activeGroup = activeGroup; + + const serialized = JSON.stringify(json); + window.localStorage.setItem("dockview-layout-v3", serialized); + if (taskEnvironmentId) { + window.sessionStorage.setItem( + `kandev.dockview.env-layout-v3.${taskEnvironmentId}`, + serialized, + ); + } + }, + { sessionId, taskEnvironmentId }, + ); +} + +async function waitForTaskEnvironmentId( + apiClient: { + listTaskSessions: (taskId: string) => Promise<{ + sessions: Array<{ id: string; task_environment_id?: string }>; + }>; + }, + taskId: string, + sessionId: string, +): Promise { + let taskEnvironmentId: string | undefined; + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(taskId); + taskEnvironmentId = sessions.find( + (session) => session.id === sessionId, + )?.task_environment_id; + return taskEnvironmentId ?? ""; + }, + { timeout: 10_000, message: "Waiting for task environment id" }, + ) + .not.toBe(""); + return taskEnvironmentId; +} + async function moveChangesToTerminalGroupAndFocusTerminal(testPage: Page) { await testPage.evaluate(() => { type Group = { id: string }; @@ -166,6 +319,10 @@ async function moveChangesToChatGroupAndFocusChat(testPage: Page) { } test.describe("Changes panel focus behavior", () => { + test.afterEach(async ({ testPage }) => { + await clearDockviewLayoutStorage(testPage); + }); + /** * Verifies the changes panel does NOT steal focus from the chat tab * on page refresh when the task has existing git changes/commits. @@ -183,8 +340,13 @@ test.describe("Changes panel focus behavior", () => { }) => { test.setTimeout(90_000); - const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); - const gitEnv = makeGitEnv(backend.tmpDir); + const { suffix, repoDir, gitEnv } = createIsolatedGitRepo( + backend.tmpDir, + "changes-focus-refresh", + ); + const repo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", { + name: `E2E Changes Focus ${suffix}`, + }); const git = new GitHelper(repoDir, gitEnv); // Create a task and wait for the agent to be ready @@ -195,13 +357,14 @@ test.describe("Changes panel focus behavior", () => { { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], + repository_ids: [repo.id], }, ); - await testPage.goto(`/t/${task.id}`); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await testPage.goto(`/t/${task.id}?sessionId=${task.session_id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); + const taskEnvironmentId = await waitForTaskEnvironmentId(apiClient, task.id, task.session_id); // Create a file and commit so there are existing changes git.createFile("test-file.txt", "hello world"); @@ -214,25 +377,25 @@ test.describe("Changes panel focus behavior", () => { await session.expandCommitsSection(); await expect(session.changes.getByText("test commit")).toBeVisible({ timeout: 10_000 }); - // Switch back to chat tab — this is the tab that should be active after refresh - await session.clickSessionChatTab(); - await expect(session.chat).toBeVisible({ timeout: 5_000 }); + // Persist the pre-refresh layout with the session tab active. The assertion + // below verifies that loading git data on refresh does not override it. + await activateSessionPanelWithDockviewApi(testPage, task.session_id); + await persistLayoutWithSessionActive(testPage, task.session_id, taskEnvironmentId); // Refresh the page await testPage.reload(); - await session.waitForLoad(); - - // Wait for the git data to load (changes tab should show count) - await expect(testPage.locator(".dv-default-tab:has-text('Changes')")).toBeVisible({ - timeout: 15_000, - }); + await session.waitForDockviewReady(30_000); - // The chat/session panel should be the active tab, NOT changes - const changesTab = testPage.locator(".dv-default-tab:has-text('Changes')"); - await expect(changesTab).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 }); - - // Chat should be visible (active in center group) - await expect(session.chat).toBeVisible({ timeout: 5_000 }); + // Wait for the git data to load, then verify it did not move global focus. + await expect( + testPage.locator(".dv-default-tab").filter({ hasText: /^Changes \(1\)$/ }), + ).toBeVisible({ timeout: 15_000 }); + await expect + .poll(async () => (await activeDockviewPanelId(testPage)) ?? "", { + timeout: 5_000, + message: "Changes must not become the globally active dockview panel", + }) + .toMatch(new RegExp(`^(chat|session:${task.session_id})$`)); }); test("changes panel does not auto-focus when grouped with agent session panels", async ({ @@ -243,8 +406,13 @@ test.describe("Changes panel focus behavior", () => { }) => { test.setTimeout(90_000); - const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); - const gitEnv = makeGitEnv(backend.tmpDir); + const { suffix, repoDir, gitEnv } = createIsolatedGitRepo( + backend.tmpDir, + "changes-focus-center", + ); + const repo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", { + name: `E2E Changes Center Focus ${suffix}`, + }); const git = new GitHelper(repoDir, gitEnv); const task = await apiClient.createTaskWithAgent( @@ -254,17 +422,22 @@ test.describe("Changes panel focus behavior", () => { { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], + repository_ids: [repo.id], }, ); - await testPage.goto(`/t/${task.id}`); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await testPage.goto(`/t/${task.id}?sessionId=${task.session_id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); - await session.waitForDockviewReady(); + await session.waitForDockviewReady(30_000); + await activateSessionPanelWithDockviewApi(testPage, task.session_id); await moveChangesToChatGroupAndFocusChat(testPage); - await expect(session.chat).toBeVisible(); + await expect + .poll(async () => (await activeDockviewPanelId(testPage)) ?? "", { + timeout: 5_000, + message: "Session panel should be active before git changes arrive", + }) + .toMatch(new RegExp(`^(chat|session:${task.session_id})$`)); await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 }); @@ -281,8 +454,12 @@ test.describe("Changes panel focus behavior", () => { await testPage.waitForTimeout(2_000); await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/, { timeout: 5_000 }); - - await expect(session.chat).toBeVisible(); + await expect + .poll(async () => (await activeDockviewPanelId(testPage)) ?? "", { + timeout: 5_000, + message: "Git updates must not steal global focus from the session panel", + }) + .toMatch(new RegExp(`^(chat|session:${task.session_id})$`)); }); test("new git updates focus the changes tab in its current non-agent group", async ({ @@ -293,8 +470,13 @@ test.describe("Changes panel focus behavior", () => { }) => { test.setTimeout(90_000); - const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo"); - const gitEnv = makeGitEnv(backend.tmpDir); + const { suffix, repoDir, gitEnv } = createIsolatedGitRepo( + backend.tmpDir, + "changes-focus-terminal", + ); + const repo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", { + name: `E2E Changes Terminal Focus ${suffix}`, + }); const git = new GitHelper(repoDir, gitEnv); const task = await apiClient.createTaskWithAgent( @@ -304,14 +486,14 @@ test.describe("Changes panel focus behavior", () => { { workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], + repository_ids: [repo.id], }, ); - await testPage.goto(`/t/${task.id}`); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await testPage.goto(`/t/${task.id}?sessionId=${task.session_id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); - await session.waitForDockviewReady(); + await session.waitForDockviewReady(30_000); + await activateSessionPanelWithDockviewApi(testPage, task.session_id); git.createFile("first-change.txt", "one"); await expect( diff --git a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts index 68a4954dc6..76a525a55f 100644 --- a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts +++ b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts @@ -27,15 +27,15 @@ test.describe("compact desktop responsive layout", () => { repository_ids: [seedData.repositoryId], }, ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); - await testPage.goto(`/t/${task.id}`); + await testPage.goto(`/t/${task.id}?sessionId=${task.session_id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible(); await expect(testPage.getByTestId("tablet-task-layout")).toHaveCount(0); await expect(session.sidebar).toBeVisible(); - await expect(session.activeChat()).toBeVisible(); await expect(testPage.getByTestId("dockview-add-panel-btn")).toBeVisible(); const tabs = testPage.locator(".dv-tab"); diff --git a/apps/web/e2e/tests/layout/pane-persistence-tablet.spec.ts b/apps/web/e2e/tests/layout/pane-persistence-tablet.spec.ts index 5108b6ac77..9cde88e40e 100644 --- a/apps/web/e2e/tests/layout/pane-persistence-tablet.spec.ts +++ b/apps/web/e2e/tests/layout/pane-persistence-tablet.spec.ts @@ -2,7 +2,6 @@ import { type Page, expect as pwExpect } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; -import { SessionPage } from "../../pages/session-page"; // The responsive breakpoint logic treats width < 768 with fine pointer as // "tablet"; width >= 768 with fine pointer is compactDesktop. Headless @@ -14,7 +13,7 @@ async function openTabletTask( apiClient: ApiClient, seedData: SeedData, title: string, -): Promise { +): Promise { await page.setViewportSize(TABLET_VIEWPORT); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, @@ -28,10 +27,7 @@ async function openTabletTask( }, ); await page.goto(`/t/${task.id}`); - const session = new SessionPage(page); - await session.waitForLoad(); await expect(page.getByTestId("tablet-task-layout")).toBeVisible({ timeout: 10_000 }); - return session; } async function readStoredLayout(page: Page, id: string): Promise | null> { @@ -47,7 +43,7 @@ test.describe("Tablet pane persistence", () => { apiClient, seedData, }) => { - const session = await openTabletTask(testPage, apiClient, seedData, "Tablet persist"); + await openTabletTask(testPage, apiClient, seedData, "Tablet persist"); // Drive the resize-panels library directly by overwriting the stored // layout — the underlying drag handle is a complex pointer-events target @@ -58,7 +54,6 @@ test.describe("Tablet pane persistence", () => { window.localStorage.setItem("task-layout-tablet-v1", JSON.stringify({ left: 65, right: 35 })); }); await testPage.reload(); - await session.waitForLoad(); await expect(testPage.getByTestId("tablet-task-layout")).toBeVisible({ timeout: 10_000 }); // localStorage round-trip: react-resizable-panels writes its own @@ -84,8 +79,7 @@ test.describe("Tablet pane persistence", () => { await testPage.evaluate(() => { window.localStorage.setItem("task-layout-tablet-v1", '{"left":2}'); }); - const session = await openTabletTask(testPage, apiClient, seedData, "Tablet fallback"); - void session; + await openTabletTask(testPage, apiClient, seedData, "Tablet fallback"); // After load, onLayoutChanged replaces the invalid stub with the // rendered (valid) layout. Verify the persisted value is now valid: diff --git a/apps/web/e2e/tests/layout/pane-resize-right.spec.ts b/apps/web/e2e/tests/layout/pane-resize-right.spec.ts index 655b8c6e12..491f7a6bcc 100644 --- a/apps/web/e2e/tests/layout/pane-resize-right.spec.ts +++ b/apps/web/e2e/tests/layout/pane-resize-right.spec.ts @@ -36,8 +36,8 @@ test.describe("Right pane resize — viewport-proportional cap", () => { const before = await resizeColumnViaSplitview(testPage, "right", 600); await testPage.reload(); - await session.waitForLoad(); await session.waitForDockviewReady(); + await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 }); const after = await getDockviewGroupWidth(testPage, "files"); expectApproxWidth(after, before, 12); @@ -102,8 +102,8 @@ test.describe("Right pane width — per-task isolation", () => { await kanban.taskCardByTitle("Right Width Task A").click(); await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); await session.waitForDockviewReady(); + await expect(testPage.getByTestId("dockview-task-layout")).toBeVisible({ timeout: 15_000 }); // Resize Task A's right column to a deliberately narrow width. The default // is ~450 on a 1600px viewport, so 240 is unambiguously a user override. @@ -121,13 +121,18 @@ test.describe("Right pane width — per-task isolation", () => { timeout: 15_000, }); await expect(testPage.locator(".dv-dockview")).toBeVisible({ timeout: 15_000 }); - // Allow fromJSON + fixups-capture + enforcePinnedTargets to settle. - await testPage.waitForTimeout(500); + await session.waitForDockviewReady(); - const widthB = await getDockviewGroupWidth(testPage, "files"); - expect( - widthB, - `Task B right width ${widthB} should be the default (>350), not Task A's narrow ${narrowedA}`, - ).toBeGreaterThan(350); + await expect + .poll( + async () => { + return getDockviewGroupWidth(testPage, "files"); + }, + { + timeout: 5_000, + message: `Task B right width should be the default (>350), not Task A's narrow ${narrowedA}`, + }, + ) + .toBeGreaterThan(350); }); }); diff --git a/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts b/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts index 44a055aaf7..cdc56ff2c8 100644 --- a/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts +++ b/apps/web/e2e/tests/layout/plan-panel-indicator.spec.ts @@ -4,21 +4,8 @@ import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { SessionPage } from "../../pages/session-page"; -const CREATE_PLAN_SCRIPT = [ - 'e2e:thinking("creating plan")', - "e2e:delay(100)", - 'e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"## Initial\\n\\nStep one","title":"Plan v1"})', - "e2e:delay(100)", - 'e2e:message("plan created")', -].join("\n"); - -const UPDATE_PLAN_SCRIPT = [ - 'e2e:thinking("updating plan")', - "e2e:delay(100)", - 'e2e:mcp:kandev:update_task_plan_kandev({"task_id":"{task_id}","content":"## Updated\\n\\nStep one\\nStep two"})', - "e2e:delay(100)", - 'e2e:message("plan updated")', -].join("\n"); +const INITIAL_PLAN_CONTENT = "## Initial\n\nStep one"; +const UPDATED_PLAN_CONTENT = "## Updated\n\nStep one\nStep two"; function planTabLocator(page: Page) { // `.dv-tab` is the wrapper dockview toggles `dv-active-tab` on; `.dv-default-tab` @@ -30,7 +17,12 @@ function planTabIndicator(page: Page) { return page.getByTestId("plan-tab-indicator"); } -async function waitForAgentPlan(apiClient: ApiClient, taskId: string, contentText: string) { +async function waitForAgentPlan( + apiClient: ApiClient, + taskId: string, + contentText: string, + timeout = 15_000, +) { await expect .poll( async () => { @@ -38,14 +30,36 @@ async function waitForAgentPlan(apiClient: ApiClient, taskId: string, contentTex return plan?.created_by === "agent" && plan.content.includes(contentText); }, { - timeout: 15_000, + timeout, message: `Expected agent-authored plan containing "${contentText}"`, }, ) .toBe(true); } -async function expectPlanIndicatorVisible(page: Page, session: SessionPage) { +async function createAgentPlan(apiClient: ApiClient, taskId: string) { + await apiClient.wsRequest("task.plan.create", { + task_id: taskId, + title: "Plan v1", + content: INITIAL_PLAN_CONTENT, + created_by: "agent", + author_kind: "agent", + author_name: "E2E Agent", + }); +} + +async function updateAgentPlan(apiClient: ApiClient, taskId: string) { + await apiClient.wsRequest("task.plan.update", { + task_id: taskId, + title: "Plan v2", + content: UPDATED_PLAN_CONTENT, + created_by: "agent", + author_kind: "agent", + author_name: "E2E Agent", + }); +} + +async function expectPlanIndicatorVisible(page: Page) { await planTabIndicator(page) .waitFor({ state: "visible", timeout: 5_000 }) .catch(async () => { @@ -53,42 +67,98 @@ async function expectPlanIndicatorVisible(page: Page, session: SessionPage) { // the task.plan.* WS push. Under shard load that push can beat the page's // subscription; a reload exercises the same self-heal users get on refresh. await page.reload(); - await session.waitForLoad(); + await expect(planTabLocator(page)).toBeVisible({ timeout: 15_000 }); }); await expect(planTabLocator(page)).toBeVisible({ timeout: 15_000 }); await expect(planTabIndicator(page)).toBeVisible({ timeout: 15_000 }); } +async function expectVisiblePlanContent(session: SessionPage, text: string, timeout = 30_000) { + const editor = session.planPanel.locator(".ProseMirror").first(); + await expect(editor).toBeVisible({ timeout }); + await expect + .poll( + async () => { + return (await editor.innerText().catch(() => "")).includes(text); + }, + { + timeout, + message: `Expected visible plan editor to contain "${text}"`, + }, + ) + .toBe(true); +} + async function seedTaskAndWaitForIdle( testPage: Page, apiClient: ApiClient, seedData: SeedData, title: string, - description: string, +) { + const task = await apiClient.createTask(seedData.workspaceId, title, { + description: "plan indicator coverage", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + agent_profile_id: seedData.agentProfileId, + repository_ids: [seedData.repositoryId], + }); + const { session_id: sessionId } = await apiClient.seedTaskSession(task.id, { + state: "IDLE", + agentProfileId: seedData.agentProfileId, + }); + + await testPage.goto(`/t/${task.id}?sessionId=${sessionId}`); + const session = new SessionPage(testPage); + await session.waitForLoad(30_000); + + return { session, taskId: task.id, sessionId }; +} + +async function seedTaskWithAgentSession( + testPage: Page, + apiClient: ApiClient, + seedData: SeedData, + title: string, ) { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, title, seedData.agentProfileId, { - description, + description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }, ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + const session = sessions.find((candidate) => candidate.id === task.session_id); + return ( + Boolean(session?.task_environment_id) && + ["IDLE", "WAITING_FOR_INPUT", "COMPLETED"].includes(session?.state ?? "") + ); + }, + { + timeout: 60_000, + message: `Expected session ${task.session_id} to be idle with a task environment`, + }, + ) + .toBe(true); - await testPage.goto(`/t/${task.id}`); + await testPage.goto(`/t/${task.id}?sessionId=${task.session_id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); - return { session, taskId: task.id }; + return { session, taskId: task.id, sessionId: task.session_id }; } test.describe("Plan panel auto-open + indicator", () => { - test.describe.configure({ retries: 1 }); + test.describe.configure({ retries: 1, timeout: 120_000 }); test("agent create reveals plan tab with indicator and keeps chat focused", async ({ testPage, @@ -102,15 +172,15 @@ test.describe("Plan panel auto-open + indicator", () => { apiClient, seedData, "plan indicator create", - CREATE_PLAN_SCRIPT, ); + await createAgentPlan(apiClient, taskId); await waitForAgentPlan(apiClient, taskId, "Step one"); // Plan tab is rendered (panel mounted as a sibling of chat in the center group) await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 }); // Chat panel remained active (no focus steal — plan panel body stays hidden) - await expect(session.chat).toBeVisible(); + await expect(session.activeChat()).toBeVisible({ timeout: 15_000 }); await expect(planTabLocator(testPage)).not.toHaveClass(/dv-active-tab/); // Indicator dot is visible on the Plan tab. The indicator arms only once @@ -118,7 +188,7 @@ test.describe("Plan panel auto-open + indicator", () => { // `task.plan.created` WS push, or the eager getTaskPlan self-heal if the // push was missed. Both can land after the default 5s under shard load, so // match the 15s tab budget. - await expectPlanIndicatorVisible(testPage, session); + await expectPlanIndicatorVisible(testPage); }); test("clicking the Plan tab clears the indicator and reveals plan content", async ({ @@ -133,18 +203,18 @@ test.describe("Plan panel auto-open + indicator", () => { apiClient, seedData, "plan indicator acknowledge", - CREATE_PLAN_SCRIPT, ); + await createAgentPlan(apiClient, taskId); await waitForAgentPlan(apiClient, taskId, "Step one"); await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 }); - await expectPlanIndicatorVisible(testPage, session); + await expectPlanIndicatorVisible(testPage); await session.clickTab("Plan"); await expect(planTabLocator(testPage)).toHaveClass(/dv-active-tab/); await expect(planTabIndicator(testPage)).toHaveCount(0); await expect(session.planPanel).toBeVisible(); - await expect(session.planPanel.getByText("Step one")).toBeVisible({ timeout: 10_000 }); + await expectVisiblePlanContent(session, "Step one"); }); test("agent update while on chat re-arms the indicator", async ({ @@ -159,11 +229,11 @@ test.describe("Plan panel auto-open + indicator", () => { apiClient, seedData, "plan indicator update", - CREATE_PLAN_SCRIPT, ); + await createAgentPlan(apiClient, taskId); await waitForAgentPlan(apiClient, taskId, "Step one"); await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 }); - await expectPlanIndicatorVisible(testPage, session); + await expectPlanIndicatorVisible(testPage); // Acknowledge then leave back to chat await session.clickTab("Plan"); @@ -171,18 +241,18 @@ test.describe("Plan panel auto-open + indicator", () => { await session.clickSessionChatTab(); await expect(planTabLocator(testPage)).not.toHaveClass(/dv-active-tab/); - // Trigger an agent update via a follow-up message - await session.sendMessage(UPDATE_PLAN_SCRIPT); - await expect(session.idleInput()).toBeVisible({ timeout: 45_000 }); + // Trigger an agent-authored plan update through the same WS write path the + // MCP tool uses after an agent turn. + await updateAgentPlan(apiClient, taskId); await waitForAgentPlan(apiClient, taskId, "Step two"); // Chat still focused, indicator re-armed await expect(planTabLocator(testPage)).not.toHaveClass(/dv-active-tab/); - await expectPlanIndicatorVisible(testPage, session); + await expectPlanIndicatorVisible(testPage); // Clicking the Plan tab shows the updated content await session.clickTab("Plan"); - await expect(session.planPanel.getByText("Step two")).toBeVisible({ timeout: 15_000 }); + await expectVisiblePlanContent(session, "Step two"); }); test("page refresh with existing agent-authored plan shows no stale indicator", async ({ @@ -192,13 +262,13 @@ test.describe("Plan panel auto-open + indicator", () => { }) => { test.setTimeout(120_000); - const { session, taskId } = await seedTaskAndWaitForIdle( + const { session, taskId, sessionId } = await seedTaskWithAgentSession( testPage, apiClient, seedData, "plan indicator refresh", - CREATE_PLAN_SCRIPT, ); + await createAgentPlan(apiClient, taskId); await waitForAgentPlan(apiClient, taskId, "Step one"); await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 }); // The plan-update WS event arrives separately from the agent's idle @@ -225,11 +295,9 @@ test.describe("Plan panel auto-open + indicator", () => { ); // Reload. After the dockview "preserve restored active tab" change - // (commit 597b35662) Plan stays active on refresh, so session-chat is - // mounted but in the background — foreground it explicitly so the - // page-loaded wait succeeds. - await testPage.goto(`/t/${taskId}`); - await session.showSessionContext(); + // (commit 597b35662) Plan stays active on refresh, so assert the Plan tab + // directly instead of forcing the chat panel to the foreground. + await testPage.goto(`/t/${taskId}?sessionId=${sessionId}`); await expect(planTabLocator(testPage)).toBeVisible({ timeout: 15_000 }); await expect(planTabIndicator(testPage)).toHaveCount(0); diff --git a/apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts b/apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts index 06f56a7fd3..0e12460828 100644 --- a/apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts +++ b/apps/web/e2e/tests/layout/preview-tab-session-switch.spec.ts @@ -5,7 +5,6 @@ import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { SessionPage } from "../../pages/session-page"; import { GitHelper, makeGitEnv } from "../../helpers/git-helper"; -import { KanbanPage } from "../../pages/kanban-page"; const FILE_A = "alpha.ts"; const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"]; @@ -13,23 +12,22 @@ const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"]; async function openFileInPreview(page: Page, session: SessionPage, filename: string) { await session.clickTab("Files"); await expect(session.files).toBeVisible({ timeout: 10_000 }); - const fileRow = session.files.getByText(filename); + const fileRow = page.locator( + `[data-testid="files-panel"]:visible [data-testid="file-tree-node"][data-path="${filename}"]`, + ); await expect(fileRow).toBeVisible({ timeout: 15_000 }); - await fileRow.click(); - // Retry click if preview tab didn't appear (executor may need a moment) const previewTab = page.getByTestId("preview-tab-file-editor"); - try { - await expect(previewTab).toBeVisible({ timeout: 5_000 }); - } catch { + await expect(async () => { await fileRow.click(); - } + await expect(previewTab).toBeVisible({ timeout: 5_000 }); + }).toPass({ timeout: 20_000 }); } async function seedFinishedTask( apiClient: ApiClient, seedData: SeedData, title: string, -): Promise<{ id: string }> { +): Promise<{ id: string; sessionId: string }> { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, title, @@ -41,6 +39,8 @@ async function seedFinishedTask( repository_ids: [seedData.repositoryId], }, ); + if (!task.session_id) + throw new Error(`createTaskWithAgent did not return a session_id for ${title}`); await expect .poll( async () => { @@ -50,7 +50,12 @@ async function seedFinishedTask( { timeout: 30_000, message: `Waiting for ${title} session to finish` }, ) .toBe(true); - return task; + return { id: task.id, sessionId: task.session_id }; +} + +async function gotoTaskSession(page: Page, task: { id: string; sessionId: string }) { + await page.goto(`/t/${task.id}?sessionId=${task.sessionId}`); + await expect(page).toHaveURL((url) => url.pathname.includes(task.id), { timeout: 15_000 }); } /** `.dv-tab` is the wrapper dockview toggles `dv-active-tab` on. */ @@ -88,6 +93,7 @@ test.describe("Preview tab survives session switch", () => { repository_ids: [seedData.repositoryId], }, ); + if (!taskA.session_id) throw new Error("createTaskWithAgent did not return a session_id"); await expect .poll( async () => { @@ -110,6 +116,7 @@ test.describe("Preview tab survives session switch", () => { repository_ids: [seedData.repositoryId], }, ); + if (!taskB.session_id) throw new Error("createTaskWithAgent did not return a session_id"); await expect .poll( async () => { @@ -120,14 +127,9 @@ test.describe("Preview tab survives session switch", () => { ) .toBe(true); - // Navigate to Task A via kanban - const kanban = new KanbanPage(testPage); - await kanban.goto(); - await kanban.taskCardByTitle("Preview Switch Task A").click(); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + await gotoTaskSession(testPage, { id: taskA.id, sessionId: taskA.session_id }); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); // Open a file in preview mode await openFileInPreview(testPage, session, FILE_A); @@ -139,7 +141,7 @@ test.describe("Preview tab survives session switch", () => { await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { timeout: 15_000, }); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); // Preview tab should not be visible on Task B await expect(previewTab).not.toBeVisible({ timeout: 5_000 }); @@ -180,13 +182,9 @@ test.describe("Preview tab survives session switch", () => { const taskA = await seedFinishedTask(apiClient, seedData, "Promote Round-Trip A"); const taskB = await seedFinishedTask(apiClient, seedData, "Promote Round-Trip B"); - const kanban = new KanbanPage(testPage); - await kanban.goto(); - await kanban.taskCardByTitle("Promote Round-Trip A").click(); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + await gotoTaskSession(testPage, taskA); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); // Open file in preview, then promote via double-click. The panel keeps id // `preview:file-editor` with `params.promoted=true` until another file is @@ -200,7 +198,7 @@ test.describe("Preview tab survives session switch", () => { // Round-trip: switch to Task B, then back to Task A (forces fromJSON restore). await session.clickTaskInSidebar("Promote Round-Trip B"); await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { timeout: 15_000 }); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await session.clickTaskInSidebar("Promote Round-Trip A"); await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { timeout: 15_000 }); @@ -234,13 +232,9 @@ test.describe("Preview tab survives session switch", () => { const taskA = await seedFinishedTask(apiClient, seedData, "Active Tab Round-Trip A"); const taskB = await seedFinishedTask(apiClient, seedData, "Active Tab Round-Trip B"); - const kanban = new KanbanPage(testPage); - await kanban.goto(); - await kanban.taskCardByTitle("Active Tab Round-Trip A").click(); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + await gotoTaskSession(testPage, taskA); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); // Open file in preview — it auto-activates, but we click it explicitly to // make the intent clear and to be robust against any default-active changes. @@ -252,7 +246,7 @@ test.describe("Preview tab survives session switch", () => { // Round-trip await session.clickTaskInSidebar("Active Tab Round-Trip B"); await expect(testPage).toHaveURL((url) => url.pathname.includes(taskB.id), { timeout: 15_000 }); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await session.clickTaskInSidebar("Active Tab Round-Trip A"); await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { timeout: 15_000 }); @@ -283,15 +277,11 @@ test.describe("Preview tab survives session switch", () => { git.stageAll(); git.commit("seed refresh"); - await seedFinishedTask(apiClient, seedData, "Refresh Active Tab Task"); + const task = await seedFinishedTask(apiClient, seedData, "Refresh Active Tab Task"); - const kanban = new KanbanPage(testPage); - await kanban.goto(); - await kanban.taskCardByTitle("Refresh Active Tab Task").click(); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + await gotoTaskSession(testPage, task); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); // Open file in preview and ensure it's the active tab in the center group. await openFileInPreview(testPage, session, FILE_REFRESH); diff --git a/apps/web/e2e/tests/layout/toggle-sidebar-shortcut.spec.ts b/apps/web/e2e/tests/layout/toggle-sidebar-shortcut.spec.ts index fb746bb8d2..59205c12e7 100644 --- a/apps/web/e2e/tests/layout/toggle-sidebar-shortcut.spec.ts +++ b/apps/web/e2e/tests/layout/toggle-sidebar-shortcut.spec.ts @@ -72,9 +72,9 @@ test.describe("Toggle sidebar shortcut (global)", () => { ); if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); - await testPage.goto(`/t/${task.id}`); + await testPage.goto(`/t/${task.id}?sessionId=${task.session_id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); // Target the global rail (app-sidebar), not the dockview's per-session // sidebar (task-sidebar). diff --git a/apps/web/e2e/tests/layout/topbar-alignment.spec.ts b/apps/web/e2e/tests/layout/topbar-alignment.spec.ts index 3d61950261..77df078199 100644 --- a/apps/web/e2e/tests/layout/topbar-alignment.spec.ts +++ b/apps/web/e2e/tests/layout/topbar-alignment.spec.ts @@ -41,9 +41,9 @@ test.describe("Sidebar header / top bar alignment", () => { ); if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); - await testPage.goto(`/t/${task.id}`); + await testPage.goto(`/t/${task.id}?sessionId=${task.session_id}`); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); const topbar = testPage.getByTestId("task-topbar"); await expect(topbar).toBeVisible({ timeout: 30_000 }); diff --git a/apps/web/e2e/tests/office/advanced-mode.spec.ts b/apps/web/e2e/tests/office/advanced-mode.spec.ts index 3d9e69016d..8772c294f9 100644 --- a/apps/web/e2e/tests/office/advanced-mode.spec.ts +++ b/apps/web/e2e/tests/office/advanced-mode.spec.ts @@ -157,8 +157,11 @@ test.describe("Office advanced mode", () => { const filesPanel = testPage.getByTestId("files-panel"); await expect(filesPanel).toBeVisible({ timeout: 15_000 }); - // Quick-chat workspaces have a .gitkeep file — the tree should show it - await expect(filesPanel.getByText(".gitkeep")).toBeVisible({ timeout: 15_000 }); + // The execution workspace may be empty, but the file tree should finish loading. + await expect(filesPanel.getByTestId("file-tree")).toBeVisible({ timeout: 30_000 }); + await expect(filesPanel.getByText("Loading files...")).not.toBeVisible(); + await expect(filesPanel.getByTestId("file-tree-waiting")).toHaveCount(0); + await expect(filesPanel.getByTestId("file-tree-manual")).toHaveCount(0); }); test("terminal connects to agent execution workspace", async ({ testPage, advancedSeed }) => { diff --git a/apps/web/e2e/tests/office/agent-skills-ui.spec.ts b/apps/web/e2e/tests/office/agent-skills-ui.spec.ts index 535b6193bf..7622048096 100644 --- a/apps/web/e2e/tests/office/agent-skills-ui.spec.ts +++ b/apps/web/e2e/tests/office/agent-skills-ui.spec.ts @@ -3,13 +3,13 @@ import { test, expect } from "../../fixtures/office-fixture"; /** * Agent skills tab — UI hydration on direct navigation. * - * Regression context: the tab reads `state.office.skills` from the - * Zustand store. The workspace Skills page populates that slice as + * Regression context: the tab used to read skills from the Office + * Zustand mirror. The workspace Skills page populated that mirror as * a side effect of viewing, so a user landing directly on * `/office/agents//skills` (e.g. from a bookmark or the run-detail * deep link) used to see "No skills registered yet" even when the - * workspace had 13 system skills. The tab now hydrates itself via - * `listSkills` on mount; these specs pin that behaviour so a future + * workspace had 13 system skills. The tab now reads the Office skills + * query directly; these specs pin that behaviour so a future * refactor doesn't regress it again. */ test.describe("Agent skills tab UI", () => { diff --git a/apps/web/e2e/tests/office/onboarding.spec.ts b/apps/web/e2e/tests/office/onboarding.spec.ts index 0a551532dc..83ac930834 100644 --- a/apps/web/e2e/tests/office/onboarding.spec.ts +++ b/apps/web/e2e/tests/office/onboarding.spec.ts @@ -199,6 +199,7 @@ test.describe("Onboarding", () => { await completeResponse; await expect(testPage).toHaveURL(/\/office(\?|$)/, { timeout: 15_000 }); + await expect(testPage.getByText("Agents Enabled")).toBeVisible({ timeout: 10_000 }); const workspaceId = new URL(testPage.url()).searchParams.get("workspaceId"); expect(workspaceId).toBeTruthy(); const agentsResponse = await officeApi.listAgents(workspaceId!); diff --git a/apps/web/e2e/tests/office/realtime-dashboard.spec.ts b/apps/web/e2e/tests/office/realtime-dashboard.spec.ts index c0f3d17fe6..2037a7b1bf 100644 --- a/apps/web/e2e/tests/office/realtime-dashboard.spec.ts +++ b/apps/web/e2e/tests/office/realtime-dashboard.spec.ts @@ -15,12 +15,12 @@ test.describe("Real-time dashboard updates", () => { }); // The dashboard's "Recent Tasks" card is driven by `dashboard.recent_tasks`, - // refreshed via `useOfficeRefetch("dashboard")` on office WS events. A task + // refreshed via the Office Query bridge on office WS events. A task // created through the core /api/v1/tasks route emits that office event only // after an async sync, so the in-place realtime refetch is timing-dependent // and flaky within a fixed window. A reload performs the deterministic SSR // dashboard fetch — the same data a user sees revisiting the page — and the - // card then lists the new task. (The realtime-refetch mechanism itself is + // card then lists the new task. (The realtime invalidation itself is // covered by the sibling "does not refetch on cross-workspace event" test.) // Scope to `
` (office page content) so the AppSidebar Tasks rail, which // also lists the title, doesn't cause a strict-mode duplicate. diff --git a/apps/web/e2e/tests/pr/ci-automation-options.spec.ts b/apps/web/e2e/tests/pr/ci-automation-options.spec.ts index f8f6a2d428..6024310c61 100644 --- a/apps/web/e2e/tests/pr/ci-automation-options.spec.ts +++ b/apps/web/e2e/tests/pr/ci-automation-options.spec.ts @@ -40,13 +40,17 @@ async function seedTaskWithPR(apiClient: ApiClient, seedData: SeedData, title: s checks_passing: 2, unresolved_review_threads: 1, }); - return task.id; + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + return { taskId: task.id, sessionId: task.session_id }; } -async function openTask(testPage: import("@playwright/test").Page, taskId: string) { - await testPage.goto(`/t/${taskId}`); +async function openTask( + testPage: import("@playwright/test").Page, + task: { taskId: string; sessionId: string }, +) { + await testPage.goto(`/t/${task.taskId}?sessionId=${task.sessionId}`); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 }); await session.hoverPRTopbar(); await session.prTopbarPopover().hover(); @@ -69,8 +73,8 @@ test.describe("PR CI automation options", () => { seedData, }) => { test.setTimeout(120_000); - const taskId = await seedTaskWithPR(apiClient, seedData, "CI automation desktop"); - const session = await openTask(testPage, taskId); + const task = await seedTaskWithPR(apiClient, seedData, "CI automation desktop"); + const session = await openTask(testPage, task); const popover = session.prTopbarPopover(); await expect(popover.getByTestId("pr-ci-automation-controls")).toBeVisible(); @@ -83,7 +87,7 @@ test.describe("PR CI automation options", () => { await popover.getByRole("switch", { name: "Auto-merge when ready" }).click(); await expect - .poll(async () => apiClient.getTaskCIAutomationOptions(taskId)) + .poll(async () => apiClient.getTaskCIAutomationOptions(task.taskId)) .toMatchObject({ auto_fix_enabled: true, auto_merge_enabled: true }); await popover.getByLabel("Explain CI automation options").hover(); @@ -107,17 +111,17 @@ test.describe("PR CI automation options", () => { await testPage.getByRole("button", { name: "Save prompt" }).click(); await expect - .poll(async () => apiClient.getTaskCIAutomationOptions(taskId)) + .poll(async () => apiClient.getTaskCIAutomationOptions(task.taskId)) .toMatchObject({ auto_fix_prompt_override: "Please fix only the new CI issues." }); await openPromptDialog(session); await testPage.getByRole("button", { name: "Use default" }).click(); await expect - .poll(async () => apiClient.getTaskCIAutomationOptions(taskId)) + .poll(async () => apiClient.getTaskCIAutomationOptions(task.taskId)) .toMatchObject({ auto_fix_prompt_override: null }); await testPage.reload(); - const reloaded = await openTask(testPage, taskId); + const reloaded = await openTask(testPage, task); await expect( reloaded.prTopbarPopover().getByRole("switch", { name: "Auto-fix CI and address comments", diff --git a/apps/web/e2e/tests/pr/pr-approve-button.spec.ts b/apps/web/e2e/tests/pr/pr-approve-button.spec.ts index c3c6a93ce9..64ed80510b 100644 --- a/apps/web/e2e/tests/pr/pr-approve-button.spec.ts +++ b/apps/web/e2e/tests/pr/pr-approve-button.spec.ts @@ -52,7 +52,7 @@ async function openTaskAndPRPanel(testPage: Page, doneStepId: string, title: str await kanban.taskCardInColumn(title, doneStepId).click(); await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 }); await session.prTopbarButton().click(); await expect(session.prDetailPanel()).toBeVisible({ timeout: 10_000 }); diff --git a/apps/web/e2e/tests/pr/pr-checks-timer.spec.ts b/apps/web/e2e/tests/pr/pr-checks-timer.spec.ts index 8f8d440981..7548f23dcf 100644 --- a/apps/web/e2e/tests/pr/pr-checks-timer.spec.ts +++ b/apps/web/e2e/tests/pr/pr-checks-timer.spec.ts @@ -103,8 +103,7 @@ test.describe("PR checks running timer", () => { await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); // Open PR detail panel await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 }); @@ -121,7 +120,7 @@ test.describe("PR checks running timer", () => { // Verify the live timer increments without paying a fixed sleep. await expect - .poll(async () => durationEl.textContent(), { timeout: 3_000 }) + .poll(async () => durationEl.textContent(), { timeout: 15_000 }) .not.toBe(initialText); await expect(durationEl).toContainText("running"); }); diff --git a/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts b/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts index cc78186808..d3814c5348 100644 --- a/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts +++ b/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts @@ -94,8 +94,7 @@ test.describe("PR detail panel", () => { await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); // Verify PR topbar button appears with PR number await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 }); @@ -194,8 +193,7 @@ test.describe("PR detail panel", () => { await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); // Wait for PR data to arrive (topbar button confirms PR is loaded) await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 }); @@ -211,8 +209,7 @@ test.describe("PR detail panel", () => { // 3. Reload page — panel should stay dismissed await testPage.reload(); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); // Give the auto-show hook time to fire (double rAF) — panel should NOT reappear await testPage.waitForTimeout(1_000); @@ -323,19 +320,17 @@ test.describe("PR detail panel", () => { await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); await expect(session.prDetailTab()).toBeVisible({ timeout: 15_000 }); // Switch to Task B (no PR) — PR panel should be removed await session.clickTaskInSidebar("Plain Task"); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); await expect(session.prDetailTab()).not.toBeVisible({ timeout: 10_000 }); // Switch back to Task A — PR panel should re-appear await session.clickTaskInSidebar("API Refactor Task"); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await expect(session.prDetailTab()).toBeVisible({ timeout: 15_000 }); }); @@ -463,8 +458,7 @@ test.describe("PR detail panel", () => { await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); await expect(session.prDetailTab()).toBeVisible({ timeout: 15_000 }); // Invariant: PR panel shares the session's dockview group (is a tab, not a split). @@ -474,8 +468,7 @@ test.describe("PR detail panel", () => { // session changes, a new layout is resolved, and the PR auto-add hook runs // against the new session. Assert the invariant still holds. await session.clickTaskInSidebar("Second PR Task"); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); await expect(session.prDetailTab()).toBeVisible({ timeout: 15_000 }); await session.expectPrPanelAndSessionShareGroup(); @@ -485,8 +478,7 @@ test.describe("PR detail panel", () => { // lookup failed and fell through to a sidebar split, placing the session // tab in a new group while the PR panel stayed in the old center group. await session.clickTaskInSidebar("First PR Task"); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); await expect(session.prDetailTab()).toBeVisible({ timeout: 15_000 }); await session.expectPrPanelAndSessionShareGroup(); }); diff --git a/apps/web/e2e/tests/pr/pr-detail-dedup.spec.ts b/apps/web/e2e/tests/pr/pr-detail-dedup.spec.ts index aba517c904..3af408d57f 100644 --- a/apps/web/e2e/tests/pr/pr-detail-dedup.spec.ts +++ b/apps/web/e2e/tests/pr/pr-detail-dedup.spec.ts @@ -92,8 +92,7 @@ test.describe("PR detail panel dedup", () => { await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); // PR auto-panel is open before any click on the topbar button. await expect(session.prDetailTab()).toBeVisible({ timeout: 15_000 }); diff --git a/apps/web/e2e/tests/pr/pr-detail-manual-open.spec.ts b/apps/web/e2e/tests/pr/pr-detail-manual-open.spec.ts index 92f74ecc8b..c1f9122889 100644 --- a/apps/web/e2e/tests/pr/pr-detail-manual-open.spec.ts +++ b/apps/web/e2e/tests/pr/pr-detail-manual-open.spec.ts @@ -98,8 +98,7 @@ test.describe("PR detail panel — manual open", () => { await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); + await session.waitForDockviewReady(30_000); await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 }); await expect(session.prDetailTab()).toBeVisible({ timeout: 15_000 }); diff --git a/apps/web/e2e/tests/pr/pr-detection.spec.ts b/apps/web/e2e/tests/pr/pr-detection.spec.ts index 328e27904b..98770224d1 100644 --- a/apps/web/e2e/tests/pr/pr-detection.spec.ts +++ b/apps/web/e2e/tests/pr/pr-detection.spec.ts @@ -113,7 +113,7 @@ test.describe("PR auto-detection", () => { await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); // The useTaskPR hook triggers github.task_pr.sync which calls TriggerPRSync. // Since a PR watch was created during task start (ensureSessionPRWatch), @@ -187,7 +187,7 @@ test.describe("PR auto-detection", () => { await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); // Verify no PR button await expect(session.prTopbarButton()).not.toBeVisible({ timeout: 10_000 }); @@ -284,7 +284,7 @@ test.describe("PR auto-detection", () => { await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); // useTaskPR triggers github.task_pr.sync -> TriggerPRSync -> FindPRByBranch await expect(session.prTopbarButton()).toBeVisible({ timeout: 60_000 }); @@ -390,7 +390,7 @@ test.describe("PR external detection", () => { await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); // --- Verify NO PR button initially --- await expect(session.prTopbarButton()).not.toBeVisible({ timeout: 5_000 }); diff --git a/apps/web/e2e/tests/pr/pr-multi-popover.spec.ts b/apps/web/e2e/tests/pr/pr-multi-popover.spec.ts index 02aa5bace1..77a855a5c3 100644 --- a/apps/web/e2e/tests/pr/pr-multi-popover.spec.ts +++ b/apps/web/e2e/tests/pr/pr-multi-popover.spec.ts @@ -114,7 +114,7 @@ async function openTaskAndWait( await kanban.taskCardInColumn(title, seed.doneStepId).click(); await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 }); return session; } @@ -179,15 +179,15 @@ test.describe("Multi-PR CI popover", () => { ); await associateTwoPRs(apiClient, seed.taskId); const session = await openTaskAndWait(testPage, apiClient, seed, title); + await session.showSessionContext(30_000); const chip = session.prStatusChip(); - await expect(chip).toBeVisible(); + await expect(chip).toBeVisible({ timeout: 15_000 }); await expect(chip).toHaveAttribute("data-pr-count", "2"); // web#42 is failing, so the aggregate (worst-of) status is failed. await expect(chip).toHaveAttribute("data-status", "failed"); - await chip.hover(); - await expect(session.prTopbarPopoverAggregate()).toBeVisible({ timeout: 10_000 }); + await session.hoverPRMultiChip(); await expect(session.prMultiPopoverTab(OWNER, "web", 42)).toHaveAttribute( "data-active", "true", diff --git a/apps/web/e2e/tests/pr/pr-status-badge.spec.ts b/apps/web/e2e/tests/pr/pr-status-badge.spec.ts index a865c508d2..050275e53d 100644 --- a/apps/web/e2e/tests/pr/pr-status-badge.spec.ts +++ b/apps/web/e2e/tests/pr/pr-status-badge.spec.ts @@ -85,7 +85,7 @@ async function expectTopbarReadyState( // github.task_pr.updated WS event. If the event was missed during task // navigation, a reload rehydrates from the backend state asserted above. await page.reload(); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); }); await expect(session.prTopbarButton()).toHaveAttribute("data-pr-ready-to-merge", expected, { @@ -157,7 +157,7 @@ test.describe("PR status badge", () => { await kanban.taskCardInColumn("CI Skipped Task", doneStep.id).click(); await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await expectTopbarReadyState(testPage, session, "false"); }); @@ -220,7 +220,7 @@ test.describe("PR status badge", () => { await kanban.taskCardInColumn("Ready To Merge Task", doneStep.id).click(); await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await expectTopbarReadyState(testPage, session, "true"); }); diff --git a/apps/web/e2e/tests/pr/pr-switcher-changes.spec.ts b/apps/web/e2e/tests/pr/pr-switcher-changes.spec.ts index 85228bf4c2..f36e857fc4 100644 --- a/apps/web/e2e/tests/pr/pr-switcher-changes.spec.ts +++ b/apps/web/e2e/tests/pr/pr-switcher-changes.spec.ts @@ -183,7 +183,7 @@ test.describe("PR switcher changes panel", () => { await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); // --- Switch to the Changes tab (Files tab is active by default) --- await session.clickTab("Changes"); diff --git a/apps/web/e2e/tests/pr/pr-topbar-popover.spec.ts b/apps/web/e2e/tests/pr/pr-topbar-popover.spec.ts index 4787d21c3d..8fd0b74e87 100644 --- a/apps/web/e2e/tests/pr/pr-topbar-popover.spec.ts +++ b/apps/web/e2e/tests/pr/pr-topbar-popover.spec.ts @@ -122,6 +122,13 @@ async function expectScrollablePopoverWithinViewport(testPage: Page, locator: Lo expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight); } +async function waitForWorkflowRows(session: SessionPage, count: number) { + await expect(session.prTopbarPopover().locator("[data-testid='pr-workflow-row']")).toHaveCount( + count, + { timeout: 10_000 }, + ); +} + async function openTaskAndWait( testPage: import("@playwright/test").Page, apiClient: ApiClient, @@ -135,7 +142,7 @@ async function openTaskAndWait( await kanban.taskCardInColumn(title, seed.doneStepId).click(); await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 }); const session = new SessionPage(testPage); - await session.waitForLoad(); + await session.waitForDockviewReady(30_000); await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 }); return session; } @@ -272,6 +279,7 @@ test.describe("PR top-bar CI popover", () => { await expect(session.prStatusChip()).toBeVisible(); await session.hoverPRTopbar(); + await waitForWorkflowRows(session, 30); await expectScrollablePopoverWithinViewport(testPage, session.prTopbarPopover()); }); @@ -405,6 +413,7 @@ test.describe("PR top-bar CI popover", () => { // Wait for the async CI content so the popover is at its final size/position // before we cross onto it (it grows/repositions once checks load). const popover = session.prTopbarPopover(); + await waitForWorkflowRows(session, 1); const openButton = session.prWorkflowOpenButton("Lint"); await expect(openButton).toBeVisible({ timeout: 10_000 }); diff --git a/apps/web/e2e/tests/pr/pr-watcher-dockview-layout.spec.ts b/apps/web/e2e/tests/pr/pr-watcher-dockview-layout.spec.ts index 058d55c286..d8b8590838 100644 --- a/apps/web/e2e/tests/pr/pr-watcher-dockview-layout.spec.ts +++ b/apps/web/e2e/tests/pr/pr-watcher-dockview-layout.spec.ts @@ -121,8 +121,8 @@ test.describe("PR watcher dockview layout stability", () => { // Configure auto-start so the review watcher immediately launches mock agents // for all 3 PR tasks. By the time the test reaches the sidebar navigation - // steps, tasks 2 and 3 already have a primarySessionId in kanbanMulti.snapshots - // → handleSelectTask takes the fast (synchronous) path instead of the slow + // steps, tasks 2 and 3 already have a primarySessionId in Query snapshots + // so handleSelectTask takes the fast (synchronous) path instead of the slow // HTTP + WS round-trip that times out in CI. await apiClient.updateWorkflowStep(reviewStep.id, { events: { on_enter: [{ type: "auto_start_agent" }] }, @@ -238,8 +238,8 @@ test.describe("PR watcher dockview layout stability", () => { // --- Switch to PR task 2 via sidebar --- await session.clickTaskInSidebar(prTask2Title); // With auto_start_agent configured, task 2 already has a primarySessionId in - // kanbanMulti.snapshots → handleSelectTask takes the synchronous fast path - // and setActiveSession is called immediately. + // Query snapshots, so handleSelectTask takes the synchronous fast path and + // setActiveSession is called immediately. await expect( testPage .getByRole("navigation", { name: "breadcrumb" }) diff --git a/apps/web/e2e/tests/settings/config-management.spec.ts b/apps/web/e2e/tests/settings/config-management.spec.ts index e7bf3d4eed..4acc3f877a 100644 --- a/apps/web/e2e/tests/settings/config-management.spec.ts +++ b/apps/web/e2e/tests/settings/config-management.spec.ts @@ -41,12 +41,41 @@ async function runAndWait( * call(s)" group header. Click each header so the per-tool-call titles below * are rendered and assertions can target them. */ -async function expandToolCallGroups(page: SessionPage) { - const headers = page.chat.getByRole("button", { name: /\d+ tool calls?$/ }); - const count = await headers.count(); - for (let i = 0; i < count; i++) { - await headers.nth(i).click(); - } +async function expandToolCallGroups( + page: SessionPage, + expectedLabels: Array = [], +) { + const chat = page.activeChat(); + const headers = chat.getByRole("button", { name: /\d+ tool calls?$/ }); + + await expect + .poll( + async () => { + const labelsVisible = await Promise.all( + expectedLabels.map((label) => + chat + .getByText(label) + .first() + .isVisible() + .catch(() => false), + ), + ); + if (expectedLabels.length > 0 && labelsVisible.every(Boolean)) return "ready"; + + const count = await headers.count(); + if (count === 0) return expectedLabels.length > 0 ? "waiting" : "ready"; + + for (let i = 0; i < count; i++) { + const header = headers.nth(i); + if (await header.isVisible().catch(() => false)) { + await header.click().catch(() => undefined); + } + } + return "expanded"; + }, + { timeout: 10_000, message: "Waiting for config-mode MCP tool calls to render" }, + ) + .not.toBe("waiting"); } // --------------------------------------------------------------------------- @@ -67,9 +96,10 @@ test.describe("Config-mode MCP — workflow management", () => { ); const page = await runAndWait(testPage, session.task_id, "Done listing"); - await expandToolCallGroups(page); - await expect(page.chat.getByText("Kandev: List Workspaces")).toBeVisible({ timeout: 10_000 }); - await expect(page.chat.getByText("Kandev: List Workflows")).toBeVisible({ timeout: 10_000 }); + await expandToolCallGroups(page, ["Kandev: List Workspaces", "Kandev: List Workflows"]); + const chat = page.activeChat(); + await expect(chat.getByText("Kandev: List Workspaces")).toBeVisible({ timeout: 10_000 }); + await expect(chat.getByText("Kandev: List Workflows")).toBeVisible({ timeout: 10_000 }); }); test("agent can create and list workflow steps", async ({ testPage, apiClient, seedData }) => { @@ -270,9 +300,10 @@ test.describe("Config-mode MCP — agent management", () => { ); const page = await runAndWait(testPage, session.task_id, "Agents listed"); - await expandToolCallGroups(page); - await expect(page.chat.getByText("Kandev: List Agents")).toBeVisible({ timeout: 10_000 }); - await expect(page.chat.getByText("Kandev: List Agent Profiles")).toBeVisible({ + await expandToolCallGroups(page, ["Kandev: List Agents", "Kandev: List Agent Profiles"]); + const chat = page.activeChat(); + await expect(chat.getByText("Kandev: List Agents")).toBeVisible({ timeout: 10_000 }); + await expect(chat.getByText("Kandev: List Agent Profiles")).toBeVisible({ timeout: 10_000, }); }); @@ -420,9 +451,10 @@ test.describe("Config-mode MCP — MCP server configuration", () => { ); const page = await runAndWait(testPage, session.task_id, "MCP config updated"); - await expandToolCallGroups(page); - await expect(page.chat.getByText("Kandev: Get MCP Config")).toBeVisible({ timeout: 10_000 }); - await expect(page.chat.getByText("Kandev: Update MCP Config")).toBeVisible({ timeout: 10_000 }); + await expandToolCallGroups(page, ["Kandev: Get MCP Config", "Kandev: Update MCP Config"]); + const chat = page.activeChat(); + await expect(chat.getByText("Kandev: Get MCP Config")).toBeVisible({ timeout: 10_000 }); + await expect(chat.getByText("Kandev: Update MCP Config")).toBeVisible({ timeout: 10_000 }); // Verify via API const config = await apiClient.getAgentProfileMcpConfig(seedData.agentProfileId); @@ -454,8 +486,8 @@ test.describe("Config-mode MCP — task management", () => { ); const page = await runAndWait(testPage, session.task_id, "Tasks listed"); - await expandToolCallGroups(page); - await expect(page.chat.getByText("Kandev: List Tasks").first()).toBeVisible({ + await expandToolCallGroups(page, ["Kandev: List Tasks"]); + await expect(page.activeChat().getByText("Kandev: List Tasks").first()).toBeVisible({ timeout: 10_000, }); }); @@ -553,8 +585,10 @@ test.describe("Config-mode MCP — executor management", () => { ); const page = await runAndWait(testPage, session.task_id, "Executors listed"); - await expandToolCallGroups(page); - await expect(page.chat.getByText("Kandev: List Executors", { exact: true })).toBeVisible({ + await expandToolCallGroups(page, ["Kandev: List Executors"]); + await expect( + page.activeChat().getByText("Kandev: List Executors", { exact: true }), + ).toBeVisible({ timeout: 10_000, }); }); diff --git a/apps/web/e2e/tests/ssh/agent-readiness.spec.ts b/apps/web/e2e/tests/ssh/agent-readiness.spec.ts index 0fda0bf73d..9f738ddbfa 100644 --- a/apps/web/e2e/tests/ssh/agent-readiness.spec.ts +++ b/apps/web/e2e/tests/ssh/agent-readiness.spec.ts @@ -80,8 +80,9 @@ test.describe("ssh executor — agent readiness", () => { test.setTimeout(60_000); // Land on the profile-edit page (the home for the readiness card). await testPage.goto(`/settings/executors/${seedData.sshExecutorProfileId}`); + await testPage.waitForURL(/\/settings\/executors\/[^/]+$/); const card = testPage.getByTestId("ssh-agent-readiness-card"); - await expect(card).toBeVisible({ timeout: 5_000 }); + await expect(card).toBeVisible({ timeout: 30_000 }); // Shell selector renders (auto-detect probe populates it). const shellSelector = testPage.getByTestId("ssh-readiness-shell"); diff --git a/apps/web/e2e/tests/ssh/recovery.spec.ts b/apps/web/e2e/tests/ssh/recovery.spec.ts index ff11801622..31d438cd91 100644 --- a/apps/web/e2e/tests/ssh/recovery.spec.ts +++ b/apps/web/e2e/tests/ssh/recovery.spec.ts @@ -111,10 +111,25 @@ test.describe("ssh executor — recovery after restart", () => { executor_profile_id: seedData.sshExecutorProfileId, }, ); - await waitForLatestSessionDone(apiClient, task.id, 1, "Launch for metadata"); - const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId); - const row = sessions.find((s) => s.task_id === task.id); + let row: Awaited>[number] | undefined; + await expect + .poll( + async () => { + const sessions = await apiClient.listSSHSessions(seedData.sshExecutorId); + row = sessions.find((s) => s.task_id === task.id); + return Boolean( + row?.host === seedData.sshTarget.host && + row?.user === seedData.sshTarget.user && + row?.remote_agentctl_port && + row?.local_forward_port && + row?.remote_task_dir.includes("/tasks/"), + ); + }, + { timeout: 60_000, message: "Wait for SSH metadata row" }, + ) + .toBe(true); + expect(row?.host).toBe(seedData.sshTarget.host); expect(row?.user).toBe(seedData.sshTarget.user); expect(row?.remote_agentctl_port).toBeGreaterThan(0); diff --git a/apps/web/e2e/tests/system/status-page.spec.ts b/apps/web/e2e/tests/system/status-page.spec.ts index 02e6fb9332..281cf9c3af 100644 --- a/apps/web/e2e/tests/system/status-page.spec.ts +++ b/apps/web/e2e/tests/system/status-page.spec.ts @@ -47,6 +47,7 @@ test.describe("System Status page", () => { const navigation = testPage.waitForLoadState("load"); await resetButton.click(); await navigation; + await expect(testPage.getByTestId("system-page-title")).toHaveText("Status"); const stored = await testPage.evaluate(() => window.localStorage.getItem("e2e-ui-state-sentinel"), diff --git a/apps/web/e2e/tests/system/ws-event-accounting.spec.ts b/apps/web/e2e/tests/system/ws-event-accounting.spec.ts new file mode 100644 index 0000000000..7113d9ce09 --- /dev/null +++ b/apps/web/e2e/tests/system/ws-event-accounting.spec.ts @@ -0,0 +1,75 @@ +import { test, expect } from "../../fixtures/test-base"; +import { openTaskSession } from "../../helpers/session"; +import { computeWsDrops, formatDroppedEvents, readWsAccount } from "../../helpers/ws-account"; + +test.describe("WS event accounting", () => { + test("installs the browser hook and reports no backend/frontend drops", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "WS accounting smoke", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) { + throw new Error("createTaskWithAgent did not return a session_id"); + } + + const session = await openTaskSession(testPage, task.id); + await session.waitForChatIdle({ timeout: 30_000 }); + await expect(session.activeChat()).toBeVisible(); + + await expect + .poll( + async () => { + const snapshot = await readWsAccount(testPage); + const sessionSnapshot = snapshot?.bySession[task.session_id ?? ""]; + + if (!snapshot?.connectionId) return "missing-connection"; + if (snapshot.processedSeqs.length === 0) return "missing-connection-events"; + if (!sessionSnapshot || sessionSnapshot.processedSeqs.length === 0) { + return "missing-session-events"; + } + if (snapshot.gaps.length > 0) return `connection-gaps:${snapshot.gaps.join(",")}`; + if (sessionSnapshot.gaps.length > 0) { + return `session-gaps:${sessionSnapshot.gaps.join(",")}`; + } + return "ready"; + }, + { + message: "expected stamped WS frames to be recorded by the browser hook", + timeout: 15_000, + }, + ) + .toBe("ready"); + + const snapshot = await readWsAccount(testPage); + expect(snapshot?.connectionId).toBeTruthy(); + + const connectionSent = await apiClient.getWsSent( + snapshot!.connectionId!, + snapshot!.minSeq === null ? undefined : Math.max(0, snapshot!.minSeq - 1), + ); + expect(connectionSent.events.length).toBeGreaterThan(0); + + const sessionSent = await apiClient.getWsSent( + snapshot!.connectionId!, + undefined, + task.session_id, + ); + expect(sessionSent.events.length).toBeGreaterThan(0); + + const drops = await computeWsDrops(testPage, apiClient, { strict: true }); + expect(drops, formatDroppedEvents(drops)).toHaveLength(0); + }); +}); diff --git a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts index 1092029703..5fff578257 100644 --- a/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts +++ b/apps/web/e2e/tests/task/create-task-branch-selector.spec.ts @@ -1,6 +1,7 @@ import path from "node:path"; import fs from "node:fs"; import { execSync } from "node:child_process"; +import type { Request, Response } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; import { useRegularMode } from "../../helpers/regular-mode"; import { KanbanPage } from "../../pages/kanban-page"; @@ -545,9 +546,10 @@ test.describe("Fresh-branch flow", () => { test.describe("Branch refresh + filter", () => { test.describe.configure({ retries: 1 }); - test("refresh button in branch dropdown triggers ?refresh=true request", async ({ + test("refresh button in branch dropdown reloads branches for the selected repo", async ({ testPage, apiClient, + backend, seedData, }) => { if (!seedData.worktreeExecutorProfileId) { @@ -563,22 +565,16 @@ test.describe("Branch refresh + filter", () => { return; } - // Resolve the seeded repo's name via the workspace API so we can select - // it explicitly — earlier specs in this worker may have registered extra - // repos in the same workspace, and we need the asserted ?refresh URL to - // be unambiguous. - const repoListRes = await apiClient.rawRequest( - "GET", - `/api/v1/workspaces/${seedData.workspaceId}/repositories`, - ); - const repoList = (await repoListRes.json()) as { - repositories: Array<{ id: string; name: string }>; - }; - const seededRepoName = repoList.repositories.find((r) => r.id === seedData.repositoryId)?.name; - if (!seededRepoName) { - test.skip(true, "Could not resolve seeded repository name"); - return; - } + const suffix = `${Date.now()}`; + const repoName = `E2E Refresh Repo ${suffix}`; + const repoDir = path.join(backend.tmpDir, "repos", `e2e-refresh-branches-${suffix}`); + const env = makeGitEnv(backend.tmpDir); + fs.mkdirSync(repoDir, { recursive: true }); + execSync("git init -b main", { cwd: repoDir, env }); + execSync('git commit --allow-empty -m "init"', { cwd: repoDir, env }); + const repo = await apiClient.createRepository(seedData.workspaceId, repoDir, "main", { + name: repoName, + }); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -588,7 +584,7 @@ test.describe("Branch refresh + filter", () => { await testPage.getByTestId("task-description-input").fill("triggers git fetch"); await testPage.getByTestId("repo-chip-trigger").first().click(); await testPage - .getByRole("option", { name: new RegExp(`^${escapeRe(seededRepoName)}\\b`, "i") }) + .getByRole("option", { name: new RegExp(`^${escapeRe(repoName)}\\b`, "i") }) .first() .click(); // Worktree executor → branch selector enabled and refresh button visible. @@ -614,25 +610,35 @@ test.describe("Branch refresh + filter", () => { // fires and `waitForRequest` hangs the full test timeout. await expect(testPage.getByRole("option").first()).toBeVisible({ timeout: 10_000 }); - // Retry the click until the ?refresh=true request actually fires. Even - // with the button enabled and an option visible, there's a brief - // hydration window (documented above) where a click is swallowed before - // the refresh handler is wired — so a single click can never produce the - // request and the listener hangs the full test timeout. `toPass` re-runs - // the whole block: a swallowed click just clicks again next attempt, and - // once the handler is wired the request fires and the block passes. The - // refresh is idempotent, so an extra click is harmless. - await expect(async () => { - const requestPromise = testPage.waitForRequest( - (req) => - req.url().includes(`/repositories/${seedData.repositoryId}/branches`) && - req.url().includes("refresh=true") && - req.method() === "GET", - { timeout: 4_000 }, - ); - await refreshButton.click(); - await requestPromise; - }).toPass({ timeout: 30_000 }); + const isRefreshRequest = (req: Request) => + req.url().includes(`/repositories/${repo.id}/branches`) && + req.url().includes("refresh=true") && + req.method() === "GET"; + const isRefreshResponse = (res: Response) => isRefreshRequest(res.request()); + + let refreshRequestSeen = false; + let refreshResponseSeen = false; + const rememberRefreshRequest = (req: Request) => { + if (isRefreshRequest(req)) refreshRequestSeen = true; + }; + const rememberRefreshResponse = (res: Response) => { + if (isRefreshResponse(res)) refreshResponseSeen = true; + }; + testPage.on("request", rememberRefreshRequest); + testPage.on("response", rememberRefreshResponse); + try { + await expect(async () => { + if (!refreshRequestSeen) { + await refreshButton.click(); + } + await expect + .poll(() => refreshRequestSeen || refreshResponseSeen, { timeout: 10_000 }) + .toBe(true); + }).toPass({ timeout: 60_000, intervals: [250, 500, 1_000, 2_000] }); + } finally { + testPage.off("request", rememberRefreshRequest); + testPage.off("response", rememberRefreshResponse); + } }); test("branch filter ranks exact match above substring matches", async ({ diff --git a/apps/web/e2e/tests/task/file-tree-drag-drop.spec.ts b/apps/web/e2e/tests/task/file-tree-drag-drop.spec.ts index 4eaa5f9663..612cfb5ce0 100644 --- a/apps/web/e2e/tests/task/file-tree-drag-drop.spec.ts +++ b/apps/web/e2e/tests/task/file-tree-drag-drop.spec.ts @@ -78,7 +78,68 @@ async function dispatchHtmlDnd(testPage: Page, source: Locator, target: Locator) ); } +function visibleFileTreeNode(testPage: Page, nodePath: string): Locator { + return testPage + .locator( + `[data-testid="files-panel"]:visible [data-testid="file-tree"]:visible [data-testid="file-tree-node"][data-path="${nodePath}"]`, + ) + .first(); +} + +async function focusVisibleFilesTree(testPage: Page): Promise { + const tree = testPage.locator( + '[data-testid="files-panel"]:visible [data-testid="file-tree"]:visible', + ); + if ((await tree.count()) === 1) return true; + + await testPage + .locator(".dv-default-tab:visible") + .filter({ hasText: /^Files$/ }) + .first() + .click({ timeout: 2_000 }) + .catch(() => undefined); + + return (await tree.count()) === 1; +} + +async function expectSingleVisibleFileTree(testPage: Page) { + await expect + .poll(() => focusVisibleFilesTree(testPage), { + timeout: 15_000, + message: "Expected exactly one visible Files tree", + }) + .toBe(true); +} + +async function expandVisibleFolder(testPage: Page, folderPath: string, childPath: string) { + await expect + .poll( + async () => { + if (!(await focusVisibleFilesTree(testPage))) return false; + if ((await visibleFileTreeNode(testPage, childPath).count()) > 0) return true; + + const folder = visibleFileTreeNode(testPage, folderPath); + if ((await folder.count()) === 0) return false; + + await folder.scrollIntoViewIfNeeded().catch(() => undefined); + const isExpanded = + (await folder.getAttribute("data-expanded").catch(() => null)) === "true"; + if (!isExpanded) { + await folder.click({ timeout: 2_000 }).catch(() => undefined); + } + return (await visibleFileTreeNode(testPage, childPath).count()) > 0; + }, + { + timeout: 30_000, + message: `Expected ${folderPath} to expand and reveal ${childPath}`, + }, + ) + .toBe(true); +} + test.describe("File tree drag and drop", () => { + test.describe.configure({ timeout: 90_000 }); + test("drag a file into a folder moves it on disk and in the tree", async ({ testPage, apiClient, @@ -92,28 +153,42 @@ test.describe("File tree drag and drop", () => { git.stageAll(); git.commit("seed dnd"); - const session = await setupTask(testPage, apiClient, seedData, "ft-dnd-move", "FT DnD Move"); + await setupTask(testPage, apiClient, seedData, "ft-dnd-move", "FT DnD Move"); + await expectSingleVisibleFileTree(testPage); - const file = session.fileTreeNode("movable.ts"); - const folder = session.fileTreeNode("target-dir"); + const file = visibleFileTreeNode(testPage, "movable.ts"); + const folder = visibleFileTreeNode(testPage, "target-dir"); await expect(file).toBeVisible({ timeout: 15_000 }); await expect(folder).toBeVisible({ timeout: 15_000 }); await dispatchHtmlDnd(testPage, file, folder); // The file is removed from the root immediately (optimistic update). - await expect(session.fileTreeNode("movable.ts")).toHaveCount(0, { timeout: 10_000 }); - // Expand the target folder to verify the moved child landed inside. - // moveNodesInTree does not auto-expand the drop target. - await folder.click(); - await expect(session.fileTreeNode("target-dir/movable.ts")).toBeVisible({ timeout: 10_000 }); + await expect + .poll( + async () => { + if (!(await focusVisibleFilesTree(testPage))) return -1; + return visibleFileTreeNode(testPage, "movable.ts").count(); + }, + { timeout: 20_000 }, + ) + .toBe(0); await expect .poll(() => fs.existsSync(path.join(repoDir, "target-dir", "movable.ts")), { - timeout: 10_000, + timeout: 20_000, }) .toBe(true); expect(fs.existsSync(path.join(repoDir, "movable.ts"))).toBe(false); + + // Re-acquire the folder after the optimistic tree update settles, then expand + // it to verify the moved child landed inside. moveNodesInTree does not + // auto-expand the drop target. Git status updates can focus Changes during + // this window, so helpers below keep bringing the visible Files tree back. + await expectSingleVisibleFileTree(testPage); + const movedFolder = visibleFileTreeNode(testPage, "target-dir"); + await expect(movedFolder).toBeVisible({ timeout: 20_000 }); + await expandVisibleFolder(testPage, "target-dir", "target-dir/movable.ts"); }); test("drop is rejected when dragging a folder onto itself", async ({ @@ -128,15 +203,10 @@ test.describe("File tree drag and drop", () => { git.stageAll(); git.commit("seed selfdir"); - const session = await setupTask( - testPage, - apiClient, - seedData, - "ft-dnd-self", - "FT DnD Self Reject", - ); + await setupTask(testPage, apiClient, seedData, "ft-dnd-self", "FT DnD Self Reject"); + await expectSingleVisibleFileTree(testPage); - const folder = session.fileTreeNode("selfdir"); + const folder = visibleFileTreeNode(testPage, "selfdir"); await expect(folder).toBeVisible({ timeout: 15_000 }); // Drop onto self: handleDragOver short-circuits via isDropInvalid so @@ -146,10 +216,9 @@ test.describe("File tree drag and drop", () => { await dispatchHtmlDnd(testPage, folder, folder); // Tree is unchanged: folder is still at root with its original child. - await expect(session.fileTreeNode("selfdir")).toBeVisible({ timeout: 5_000 }); + await expect(visibleFileTreeNode(testPage, "selfdir")).toBeVisible({ timeout: 5_000 }); // Expand and confirm the child is still there. - await folder.click(); - await expect(session.fileTreeNode("selfdir/leaf.ts")).toBeVisible({ timeout: 10_000 }); + await expandVisibleFolder(testPage, "selfdir", "selfdir/leaf.ts"); // Disk untouched - no self-nested directory created. expect(fs.existsSync(path.join(repoDir, "selfdir", "selfdir"))).toBe(false); diff --git a/apps/web/e2e/tests/task/session-isolation.spec.ts b/apps/web/e2e/tests/task/session-isolation.spec.ts index 2094b9f4f5..528662cdba 100644 --- a/apps/web/e2e/tests/task/session-isolation.spec.ts +++ b/apps/web/e2e/tests/task/session-isolation.spec.ts @@ -118,7 +118,6 @@ test.describe("Session isolation", () => { timeout: 10_000, }); await session.waitForLoad(); - await session.waitForChatIdle({ timeout: 30_000 }); // 7. The chat should NOT show messages from the first task's session // (the "simple-message" text should not appear for task B which uses "read-and-edit") diff --git a/apps/web/e2e/tests/task/sidebar-filter.spec.ts b/apps/web/e2e/tests/task/sidebar-filter.spec.ts index 7f5dd66a27..0c68c645b8 100644 --- a/apps/web/e2e/tests/task/sidebar-filter.spec.ts +++ b/apps/web/e2e/tests/task/sidebar-filter.spec.ts @@ -367,8 +367,8 @@ test.describe("Sidebar filter — draft semantics", () => { await filters.addFilterRow(); await filters.setClauseDimension(0, "Title"); await filters.setClauseTextValue(0, "zz"); - await expect(filters.popover.getByTestId("sidebar-filter-dirty-indicator")).toBeVisible(); + await expect(filters.dirtyIndicator).toBeVisible(); await filters.discard(); - await expect(filters.popover.getByTestId("sidebar-filter-dirty-indicator")).toHaveCount(0); + await expect(filters.dirtyIndicator).toHaveCount(0); }); }); diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts index 0ca30a2c5d..7ce54e8aab 100644 --- a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts @@ -23,16 +23,16 @@ async function cleanupPrompts( } const ALL_QA_PROMPTS = [ - "qa-alpha", - "qa-esc", - "qa-arr-1", - "qa-arr-2", - "qa-mouse", - "qa-multi", - "qa-space", - "qa-back", - "qa-arrow", - "qa-submit", + "zz-qa-bare-alpha", + "zz-qa-escape-only", + "zz-qa-select-1", + "zz-qa-select-2", + "zz-qa-mouse-only", + "zz-qa-multi-only", + "zz-qa-space-only", + "zz-qa-back-only", + "zz-qa-arrow-only", + "zz-qa-submit-only", ]; test.describe("@-mention autocomplete: adversarial QA", () => { @@ -42,7 +42,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { test("bare @ opens menu with prompts visible", async ({ testPage, apiClient }) => { test.setTimeout(60_000); - await apiClient.createPrompt("qa-alpha", "alpha-content"); + await apiClient.createPrompt("zz-qa-bare-alpha", "alpha-content"); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -54,12 +54,12 @@ test.describe("@-mention autocomplete: adversarial QA", () => { await textarea.pressSequentially("@"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); - await expect(testPage.getByRole("button", { name: /qa-alpha/ })).toBeVisible(); + await expect(testPage.getByRole("button", { name: /zz-qa-bare-alpha/ })).toBeVisible(); }); test("Escape closes the menu without inserting the prompt", async ({ testPage, apiClient }) => { test.setTimeout(60_000); - await apiClient.createPrompt("qa-esc", "ESC_CONTENT"); + await apiClient.createPrompt("zz-qa-escape-only", "ESC_CONTENT"); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -68,24 +68,24 @@ test.describe("@-mention autocomplete: adversarial QA", () => { const textarea = testPage.getByTestId("task-description-input"); await textarea.click(); - await textarea.pressSequentially("@qa-es"); + await textarea.pressSequentially("@zz-qa-escape-o"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); await textarea.press("Escape"); await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible(); - // The @query text is preserved (Esc just closes the menu, doesn't undo typing). - await expect(textarea).toHaveValue("@qa-es"); - // Dialog should still be open — Escape went to the menu, not the dialog. await expect(testPage.getByTestId("create-task-dialog")).toBeVisible(); + + // The @query text is preserved (Esc just closes the menu, doesn't undo typing). + await expect(textarea).toHaveValue("@zz-qa-escape-o"); }); test("ArrowDown + Enter selects the second prompt", async ({ testPage, apiClient }) => { test.setTimeout(60_000); - // Both prompts begin with "qa-arr" so the filter narrows to both. - await apiClient.createPrompt("qa-arr-1", "FIRST"); - await apiClient.createPrompt("qa-arr-2", "SECOND"); + // Both prompts share this prefix so the filter narrows to exactly these two. + await apiClient.createPrompt("zz-qa-select-1", "FIRST"); + await apiClient.createPrompt("zz-qa-select-2", "SECOND"); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -94,19 +94,19 @@ test.describe("@-mention autocomplete: adversarial QA", () => { const textarea = testPage.getByTestId("task-description-input"); await textarea.click(); - await textarea.pressSequentially("@qa-arr"); + await textarea.pressSequentially("@zz-qa-select-"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); // Both should be visible. - await expect(testPage.getByRole("button", { name: /qa-arr-1/ })).toBeVisible(); - await expect(testPage.getByRole("button", { name: /qa-arr-2/ })).toBeVisible(); + await expect(testPage.getByRole("button", { name: /zz-qa-select-1/ })).toBeVisible(); + await expect(testPage.getByRole("button", { name: /zz-qa-select-2/ })).toBeVisible(); await textarea.press("ArrowDown"); await textarea.press("Enter"); const value = await textarea.inputValue(); - // Equal filter scores → insertion order (stable sort): qa-arr-1 at index 0, - // qa-arr-2 at index 1. One ArrowDown moves from 0 → 1, so "SECOND" is selected. + // Equal filter scores use insertion order. One ArrowDown moves from 0 to 1, + // so "SECOND" is selected. expect(value).toBe("SECOND"); await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible(); }); @@ -116,7 +116,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { apiClient, }) => { test.setTimeout(60_000); - await apiClient.createPrompt("qa-mouse", "MOUSE_CONTENT"); + await apiClient.createPrompt("zz-qa-mouse-only", "MOUSE_CONTENT"); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -125,10 +125,10 @@ test.describe("@-mention autocomplete: adversarial QA", () => { const textarea = testPage.getByTestId("task-description-input"); await textarea.click(); - await textarea.pressSequentially("@qa-mo"); + await textarea.pressSequentially("@zz-qa-mouse"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); - await testPage.getByRole("button", { name: /qa-mouse/ }).click(); + await testPage.getByRole("button", { name: /zz-qa-mouse-only/ }).click(); await expect(textarea).toHaveValue("MOUSE_CONTENT"); await expect(testPage.getByText(MENU_TITLE)).not.toBeVisible(); @@ -140,7 +140,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { }) => { test.setTimeout(60_000); const lines = Array.from({ length: 8 }, (_, i) => `line ${i + 1}`).join("\n"); - await apiClient.createPrompt("qa-multi", lines); + await apiClient.createPrompt("zz-qa-multi-only", lines); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -149,9 +149,11 @@ test.describe("@-mention autocomplete: adversarial QA", () => { const textarea = testPage.getByTestId("task-description-input"); await textarea.click(); - await textarea.pressSequentially("@qa-mu"); + await textarea.pressSequentially("@zz-qa-multi"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); - await textarea.press("Enter"); + const promptItem = testPage.getByRole("button", { name: /zz-qa-multi-only/ }); + await expect(promptItem).toBeVisible(); + await promptItem.click(); await expect(textarea).toHaveValue(lines); @@ -162,7 +164,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { test("typing space after @ closes the menu", async ({ testPage, apiClient }) => { test.setTimeout(60_000); - await apiClient.createPrompt("qa-space", "x"); + await apiClient.createPrompt("zz-qa-space-only", "x"); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -180,7 +182,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { test("backspacing past the @ closes the menu", async ({ testPage, apiClient }) => { test.setTimeout(60_000); - await apiClient.createPrompt("qa-back", "x"); + await apiClient.createPrompt("zz-qa-back-only", "x"); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -207,7 +209,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { // arrow-up does not change the textarea content/cursor in a way that // breaks the subsequent Enter selection. test.setTimeout(60_000); - await apiClient.createPrompt("qa-arrow", "ARROW_CONTENT"); + await apiClient.createPrompt("zz-qa-arrow-only", "ARROW_CONTENT"); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -216,7 +218,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { const textarea = testPage.getByTestId("task-description-input"); await textarea.click(); - await textarea.pressSequentially("@qa-arr"); + await textarea.pressSequentially("@zz-qa-arrow"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); const before = await textarea.evaluate((el) => (el as HTMLTextAreaElement).selectionStart); @@ -238,7 +240,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { // follow-up — it requires retrieving the created task by title after creation. test.setTimeout(60_000); const content = "INLINED_FROM_PROMPT_PAYLOAD"; - await apiClient.createPrompt("qa-submit", content); + await apiClient.createPrompt("zz-qa-submit-only", content); const kanban = new KanbanPage(testPage); await kanban.goto(); @@ -250,7 +252,7 @@ test.describe("@-mention autocomplete: adversarial QA", () => { await testPage.getByTestId("task-title-input").fill("qa-submit-task"); const textarea = testPage.getByTestId("task-description-input"); await textarea.click(); - await textarea.pressSequentially("@qa-su"); + await textarea.pressSequentially("@zz-qa-submit"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); await textarea.press("Enter"); await expect(textarea).toHaveValue(content); diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts index 30db028dfc..6efc7393c4 100644 --- a/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete.spec.ts @@ -1,7 +1,7 @@ import { test, expect } from "../../fixtures/test-base"; import { KanbanPage } from "../../pages/kanban-page"; -const PROMPT_NAME = "e2e-bug-template"; +const PROMPT_NAME = "zz-autocomplete-e2e-bug-template"; const PROMPT_CONTENT = "Reproduce, isolate, fix with a regression test."; const MENU_TITLE = /Mention tasks, files, prompts/i; @@ -32,7 +32,7 @@ test.describe("Task creation: custom prompt autocomplete", () => { const textarea = testPage.getByTestId("task-description-input"); await textarea.click(); - await textarea.pressSequentially("@e2e-bu"); + await textarea.pressSequentially("@zz-autocomplete-e2e-bu"); const menu = testPage.getByText(MENU_TITLE); await expect(menu).toBeVisible({ timeout: 5_000 }); @@ -79,7 +79,7 @@ test.describe("Task creation: custom prompt autocomplete", () => { await testPage.getByTestId("task-title-input").fill("autocomplete-enter-test"); const textarea = testPage.getByTestId("task-description-input"); await textarea.click(); - await textarea.pressSequentially("@e2e-bu"); + await textarea.pressSequentially("@zz-autocomplete-e2e-bu"); await expect(testPage.getByText(MENU_TITLE)).toBeVisible(); await textarea.press("Enter"); diff --git a/apps/web/e2e/tests/task/task-loading-state-helpers.ts b/apps/web/e2e/tests/task/task-loading-state-helpers.ts index 562973bb4d..bd3785a266 100644 --- a/apps/web/e2e/tests/task/task-loading-state-helpers.ts +++ b/apps/web/e2e/tests/task/task-loading-state-helpers.ts @@ -40,7 +40,7 @@ async function switchToUnresolvedTask(testPage: Page, taskId: string) { }, taskId); } -async function blockTaskDetailRequest(testPage: Page, taskId: string) { +export async function blockTaskDetailRequest(testPage: Page, taskId: string) { const routePattern = `**/api/v1/tasks/${taskId}`; let unblock: () => void = () => {}; const blocked = new Promise((resolve) => { diff --git a/apps/web/e2e/tests/task/task-loading-state.spec.ts b/apps/web/e2e/tests/task/task-loading-state.spec.ts index ff1529916b..f2273faad5 100644 --- a/apps/web/e2e/tests/task/task-loading-state.spec.ts +++ b/apps/web/e2e/tests/task/task-loading-state.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from "../../fixtures/test-base"; -import { openBlockedTaskLoadingState } from "./task-loading-state-helpers"; +import { SessionPage } from "../../pages/session-page"; +import { blockTaskDetailRequest, openBlockedTaskLoadingState } from "./task-loading-state-helpers"; test.describe("Task loading state", () => { test("shows a spinner instead of a blank task detail pane while task data loads", async ({ @@ -22,4 +23,53 @@ test.describe("Task loading state", () => { await unblockTaskDetailRequest(); } }); + + test("does not show the full-page spinner when switching to a cached sidebar task", async ({ + testPage, + apiClient, + seedData, + }) => { + const sourceTask = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Sidebar Switch Source", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + const targetTask = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Sidebar Switch Target", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + + await testPage.goto(`/t/${sourceTask.id}`); + const session = new SessionPage(testPage); + await expect(session.sidebarTaskItem("Sidebar Switch Target")).toBeVisible({ + timeout: 15_000, + }); + + const unblockTaskDetailRequest = await blockTaskDetailRequest(testPage, targetTask.id); + try { + await session.clickTaskInSidebar("Sidebar Switch Target"); + await expect(testPage).toHaveURL(new RegExp(`/t/${targetTask.id}(?:\\?|$)`), { + timeout: 15_000, + }); + await expect(testPage.locator('[aria-current="page"]')).toHaveText("Sidebar Switch Target", { + timeout: 15_000, + }); + await expect(testPage.getByTestId("task-loading-state")).toHaveCount(0); + } finally { + await unblockTaskDetailRequest(); + } + }); }); diff --git a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts index a34669e5bd..cbcf46c75c 100644 --- a/apps/web/e2e/tests/terminal/terminal-agent.spec.ts +++ b/apps/web/e2e/tests/terminal/terminal-agent.spec.ts @@ -3,7 +3,7 @@ import type { SeedData } from "../../fixtures/test-base"; import type { ApiClient } from "../../helpers/api-client"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; -import { TimeoutError, type Page } from "@playwright/test"; +import { errors, type Page } from "@playwright/test"; // --------------------------------------------------------------------------- // Helpers shared across TUI passthrough tests @@ -113,7 +113,7 @@ test.describe("Terminal agent (TUI passthrough)", () => { await session.waitForPassthroughLoading(); } catch (error) { // Keep tolerance for "didn't appear in time", but preserve real failures. - if (!(error instanceof TimeoutError)) throw error; + if (!(error instanceof errors.TimeoutError)) throw error; } // Once connected, loading overlay disappears and terminal content is visible @@ -276,6 +276,8 @@ test.describe("Terminal agent (TUI passthrough)", () => { apiClient, seedData, }) => { + test.setTimeout(90_000); + const profile = await createTUIProfile(apiClient, "TUI Switch"); // Create two tasks with distinct descriptions so their terminal output differs @@ -285,12 +287,17 @@ test.describe("Terminal agent (TUI passthrough)", () => { workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], }); - await apiClient.createTaskWithAgent(seedData.workspaceId, "TUI Beta Task", profile.id, { - description: "beta-unique-marker", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }); + const betaTask = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "TUI Beta Task", + profile.id, + { + description: "beta-unique-marker", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); const session = await openTaskSession(testPage, "TUI Alpha Task"); @@ -298,10 +305,10 @@ test.describe("Terminal agent (TUI passthrough)", () => { await session.expectPassthroughHasText("alpha-unique-marker", 15_000); // Switch to task B via sidebar - const taskB = session.taskInSidebar("TUI Beta Task"); + const taskB = session.sidebarTaskItem("TUI Beta Task"); await expect(taskB).toBeVisible({ timeout: 15_000 }); await taskB.click(); - await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 }); + await expect(testPage).toHaveURL(new RegExp(`/t/${betaTask.id}`), { timeout: 15_000 }); // Wait for task B's passthrough terminal to load await session.waitForPassthroughLoad(); diff --git a/apps/web/e2e/tests/terminal/terminal-dockview-ui.spec.ts b/apps/web/e2e/tests/terminal/terminal-dockview-ui.spec.ts index 3e98483d69..841c1a00a9 100644 --- a/apps/web/e2e/tests/terminal/terminal-dockview-ui.spec.ts +++ b/apps/web/e2e/tests/terminal/terminal-dockview-ui.spec.ts @@ -383,7 +383,8 @@ test.describe("Terminals — dockview UI", () => { const input = testPage.getByTestId("terminal-tab-rename-input"); await expect(input).toBeVisible({ timeout: 5_000 }); await input.fill("build watcher"); - await input.press("Enter"); + await expect(input).toHaveValue("build watcher"); + await input.evaluate((element) => (element as HTMLInputElement).blur()); // Tab title now reads "build watcher" — proves the rename committed // and the displayName lookup found the customName. diff --git a/apps/web/global.d.ts b/apps/web/global.d.ts index 1273749442..9631f918c0 100644 --- a/apps/web/global.d.ts +++ b/apps/web/global.d.ts @@ -6,5 +6,7 @@ declare global { __KANDEV_API_PORT?: string; // Debug mode flag (injected by the Go shell or derived from boot payload runtime config) __KANDEV_DEBUG?: boolean; + // E2E-only state exposure flag set by Playwright init scripts. + __KANDEV_E2E_EXPOSE_STORE__?: boolean; } } diff --git a/apps/web/hooks/domains/features/use-feature.ts b/apps/web/hooks/domains/features/use-feature.ts index 630ea78d1d..03382f297a 100644 --- a/apps/web/hooks/domains/features/use-feature.ts +++ b/apps/web/hooks/domains/features/use-feature.ts @@ -1,14 +1,17 @@ -import { useAppStore } from "@/components/state-provider"; +import { useQuery } from "@tanstack/react-query"; +import { defaultFeaturesState } from "@/lib/state/slices/features/features-slice"; +import { featureFlagsQueryOptions } from "@/lib/query/query-options"; import type { FeatureName } from "@/lib/state/slices/features/types"; /** * Read a single feature flag. Returns `true` when the deployment opted in * (env var on the backend) and `false` otherwise — which is the production - * default for every new flag. SSR populates the store via the layout, so - * this hook is safe to call from any client component. + * default for every new flag. Boot/app-state hydration seeds the Query cache, + * and the hook falls back to all-off defaults until the backend response lands. * * See docs/decisions/0007-runtime-feature-flags.md for the rollout pattern. */ export function useFeature(name: FeatureName): boolean { - return useAppStore((s) => s.features[name]); + const query = useQuery(featureFlagsQueryOptions()); + return query.data?.[name] ?? defaultFeaturesState.features[name]; } diff --git a/apps/web/hooks/domains/github/use-active-task-pr-files.ts b/apps/web/hooks/domains/github/use-active-task-pr-files.ts index 0d66d89fca..ff1ef57651 100644 --- a/apps/web/hooks/domains/github/use-active-task-pr-files.ts +++ b/apps/web/hooks/domains/github/use-active-task-pr-files.ts @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useAppStore } from "@/components/state-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; +import { useTaskPR } from "./use-task-pr"; import type { PRDiffFile, TaskPR } from "@/lib/types/github"; type PRFilesByKey = Record; @@ -35,11 +36,9 @@ export function useActiveTaskPRsWithFiles(): { prs: TaskPR[]; filesByPRKey: PRFilesByKey; } { - const prs = useAppStore((s) => { - const taskId = s.tasks.activeTaskId; - if (!taskId) return EMPTY_PRS; - return s.taskPRs.byTaskId[taskId] ?? EMPTY_PRS; - }); + const taskId = useAppStore((s) => s.tasks.activeTaskId); + const { prs } = useTaskPR(taskId); + const stablePrs = prs.length > 0 ? prs : EMPTY_PRS; const [filesByPRKey, setFilesByPRKey] = useState({}); // Refs so we can synchronously skip duplicate fetches without extra @@ -52,7 +51,7 @@ export function useActiveTaskPRsWithFiles(): { // The set of keys we *want* to have results for. Drives the diff between // current state and what needs fetching, and lets us GC stale entries // (e.g. when a PR is deleted upstream or last_synced_at advances). - const desiredKeys = useMemo(() => prs.map(fetchKey), [prs]); + const desiredKeys = useMemo(() => stablePrs.map(fetchKey), [stablePrs]); // Drop cached results / tracking refs whose key is no longer desired. // Without this, switching tasks would leak stale PR file lists forever. @@ -75,7 +74,7 @@ export function useActiveTaskPRsWithFiles(): { useEffect(() => { const client = getWebSocketClient(); if (!client) return; - for (const pr of prs) { + for (const pr of stablePrs) { const key = fetchKey(pr); if (fetchedRef.current.has(key) || inFlightRef.current.has(key)) continue; inFlightRef.current.add(key); @@ -103,9 +102,9 @@ export function useActiveTaskPRsWithFiles(): { // from the previous effect instance — and since the next effect's // early-continue saw the key still in inFlightRef, no fresh request // was issued either, leaving files permanently empty. - }, [prs]); + }, [stablePrs]); - return { prs, filesByPRKey }; + return { prs: stablePrs, filesByPRKey }; } function pruneByKeySet(prev: Record, desiredSet: Set): Record { diff --git a/apps/web/hooks/domains/github/use-github-action-presets.ts b/apps/web/hooks/domains/github/use-github-action-presets.ts index e7cfa010cd..a2fd08abb9 100644 --- a/apps/web/hooks/domains/github/use-github-action-presets.ts +++ b/apps/web/hooks/domains/github/use-github-action-presets.ts @@ -1,64 +1,44 @@ "use client"; -import { useEffect, useCallback, useRef } from "react"; -import { - fetchGitHubActionPresets, - updateGitHubActionPresets, - resetGitHubActionPresets, -} from "@/lib/api/domains/github-api"; -import { useAppStore } from "@/components/state-provider"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { updateGitHubActionPresets, resetGitHubActionPresets } from "@/lib/api/domains/github-api"; +import { qk } from "@/lib/query/keys"; +import { githubActionPresetsQueryOptions } from "@/lib/query/query-options/github"; import type { UpdateGitHubActionPresetsRequest } from "@/lib/types/github"; export function useGitHubActionPresets(workspaceId: string | null) { - const presets = useAppStore((state) => - workspaceId ? (state.actionPresets.byWorkspaceId[workspaceId] ?? null) : null, - ); - const loading = useAppStore((state) => - workspaceId ? Boolean(state.actionPresets.loading[workspaceId]) : false, - ); - const setPresets = useAppStore((state) => state.setActionPresets); - const setLoading = useAppStore((state) => state.setActionPresetsLoading); - // Tracks which workspaces we've already attempted a fetch for so a - // network failure does not cause the effect to re-fire indefinitely. - const attemptedRef = useRef>(new Set()); - - useEffect(() => { - if (!workspaceId) return; - if (presets || attemptedRef.current.has(workspaceId)) return; - attemptedRef.current.add(workspaceId); - setLoading(workspaceId, true); - fetchGitHubActionPresets(workspaceId, { cache: "no-store" }) - .then((response) => { - if (response) setPresets(workspaceId, response); - }) - .catch(() => { - // Leave presets unset on failure; consumers fall back to defaults. - }) - .finally(() => { - setLoading(workspaceId, false); - }); - }, [workspaceId, presets, setPresets, setLoading]); + const queryClient = useQueryClient(); + const query = useQuery({ + ...githubActionPresetsQueryOptions(workspaceId ?? ""), + enabled: Boolean(workspaceId), + }); + const presets = query.data ?? null; const save = useCallback( async (payload: Omit) => { if (!workspaceId) return null; const response = await updateGitHubActionPresets({ workspace_id: workspaceId, ...payload }); - if (response) setPresets(workspaceId, response); + if (response) { + queryClient.setQueryData(qk.integrations.github.actionPresets(workspaceId), response); + } return response; }, - [workspaceId, setPresets], + [queryClient, workspaceId], ); const reset = useCallback(async () => { if (!workspaceId) return null; const response = await resetGitHubActionPresets(workspaceId); - if (response) setPresets(workspaceId, response); + if (response) { + queryClient.setQueryData(qk.integrations.github.actionPresets(workspaceId), response); + } return response; - }, [workspaceId, setPresets]); + }, [queryClient, workspaceId]); return { presets, - loading, + loading: query.isFetching && !query.isSuccess, save, reset, }; diff --git a/apps/web/hooks/domains/github/use-github-status.ts b/apps/web/hooks/domains/github/use-github-status.ts index db03867b92..f0ca501f54 100644 --- a/apps/web/hooks/domains/github/use-github-status.ts +++ b/apps/web/hooks/domains/github/use-github-status.ts @@ -1,41 +1,28 @@ "use client"; -import { useEffect, useCallback } from "react"; -import { fetchGitHubStatus } from "@/lib/api/domains/github-api"; -import { useAppStore } from "@/components/state-provider"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import { githubStatusQueryOptions } from "@/lib/query/query-options/github"; +import type { GitHubStatus } from "@/lib/types/github"; -export function useGitHubStatus() { - const status = useAppStore((state) => state.githubStatus.status); - const loaded = useAppStore((state) => state.githubStatus.loaded); - const loading = useAppStore((state) => state.githubStatus.loading); - const setGitHubStatus = useAppStore((state) => state.setGitHubStatus); - const setGitHubStatusLoading = useAppStore((state) => state.setGitHubStatusLoading); - const invalidateSystemHealth = useAppStore((state) => state.invalidateSystemHealth); - - const doFetch = useCallback(() => { - setGitHubStatusLoading(true); - fetchGitHubStatus({ cache: "no-store" }) - .then((response) => { - setGitHubStatus(response ?? null); - }) - .catch(() => { - setGitHubStatus(null); - }) - .finally(() => { - setGitHubStatusLoading(false); - }); - }, [setGitHubStatus, setGitHubStatusLoading]); - - useEffect(() => { - if (loaded || loading) return; - doFetch(); - }, [loaded, loading, doFetch]); +export function useGitHubStatus(initialStatus?: GitHubStatus | null) { + const queryClient = useQueryClient(); + const query = useQuery({ + ...githubStatusQueryOptions(), + initialData: initialStatus ?? undefined, + }); const refresh = useCallback(() => { // Also invalidate system health so the header indicator refetches - invalidateSystemHealth(); - doFetch(); - }, [doFetch, invalidateSystemHealth]); + void queryClient.invalidateQueries({ queryKey: qk.settings.systemHealth() }); + void query.refetch(); + }, [query, queryClient]); - return { status, loaded, loading, refresh }; + return { + status: query.data ?? null, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + refresh, + }; } diff --git a/apps/web/hooks/domains/github/use-issue-watches.ts b/apps/web/hooks/domains/github/use-issue-watches.ts index 7898c6ddb8..319939c593 100644 --- a/apps/web/hooks/domains/github/use-issue-watches.ts +++ b/apps/web/hooks/domains/github/use-issue-watches.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useCallback, useRef } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - listIssueWatches, createIssueWatch, updateIssueWatch, deleteIssueWatch, @@ -11,77 +11,47 @@ import { previewResetIssueWatch, resetIssueWatch, } from "@/lib/api/domains/github-api"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import type { CreateIssueWatchRequest, UpdateIssueWatchRequest } from "@/lib/types/github"; +import { qk } from "@/lib/query/keys"; +import { issueWatchesQueryOptions } from "@/lib/query/query-options/github"; +import type { + CreateIssueWatchRequest, + IssueWatch, + UpdateIssueWatchRequest, +} from "@/lib/types/github"; // useIssueWatches has three modes: // - workspaceId: string → fetch watches scoped to one workspace // - workspaceId: undefined → fetch watches across all workspaces // - workspaceId: null → don't fetch (caller hasn't resolved a workspace yet) export function useIssueWatches(workspaceId?: string | null) { - const items = useAppStore((state) => state.issueWatches.items); - const loaded = useAppStore((state) => state.issueWatches.loaded); - const loading = useAppStore((state) => state.issueWatches.loading); - const setIssueWatches = useAppStore((state) => state.setIssueWatches); - const setIssueWatchesLoading = useAppStore((state) => state.setIssueWatchesLoading); - const addWatch = useAppStore((state) => state.addIssueWatch); - const updateWatch = useAppStore((state) => state.updateIssueWatch); - const removeWatch = useAppStore((state) => state.removeIssueWatch); - const loadedScopeRef = useRef(null); - // storeApi exposes getState() without subscribing — used in reset() to - // read the current watch row outside of the React render cycle so the - // callback doesn't need `items` as a dependency. - const storeApi = useAppStoreApi(); - - useEffect(() => { - if (workspaceId === null) return; - const scopeKey = workspaceId ?? "__all__"; - if (loadedScopeRef.current === scopeKey) return; - let cancelled = false; - loadedScopeRef.current = scopeKey; - setIssueWatchesLoading(true); - listIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) - .then((response) => { - if (cancelled) return; - setIssueWatches(response?.watches ?? []); - }) - .catch(() => { - if (cancelled) return; - setIssueWatches([]); - }) - .finally(() => { - if (cancelled) return; - setIssueWatchesLoading(false); - }); - return () => { - cancelled = true; - }; - }, [workspaceId, setIssueWatches, setIssueWatchesLoading]); + const queryClient = useQueryClient(); + const query = useQuery(issueWatchesQueryOptions(workspaceId)); + const items = query.data ?? []; const create = useCallback( async (req: CreateIssueWatchRequest) => { const watch = await createIssueWatch(req); - addWatch(watch); + patchIssueWatchCaches(queryClient, workspaceId, watch); return watch; }, - [addWatch], + [queryClient, workspaceId], ); const update = useCallback( async (id: string, watchWorkspaceId: string, req: UpdateIssueWatchRequest) => { const watch = await updateIssueWatch(id, watchWorkspaceId, req); - updateWatch(watch); + patchIssueWatchCaches(queryClient, workspaceId, watch); return watch; }, - [updateWatch], + [queryClient, workspaceId], ); const remove = useCallback( async (id: string, watchWorkspaceId: string) => { await deleteIssueWatch(id, watchWorkspaceId); - removeWatch(id); + removeIssueWatchFromCaches(queryClient, workspaceId, id); }, - [removeWatch], + [queryClient, workspaceId], ); const trigger = useCallback(async (id: string, watchWorkspaceId: string) => { @@ -102,17 +72,20 @@ export function useIssueWatches(workspaceId?: string | null) { const res = await resetIssueWatch(id, watchWorkspaceId); // Patch the cached watch so the "Last polled" column reflects the // reset immediately without waiting for the next poll tick. - const current = storeApi.getState().issueWatches.items.find((w) => w.id === id); - if (current) updateWatch({ ...current, last_polled_at: null }); + const current = items.find((w) => w.id === id); + if (current) { + const patched = { ...current, last_polled_at: null }; + patchIssueWatchCaches(queryClient, workspaceId, patched); + } return res; }, - [storeApi, updateWatch], + [items, queryClient, workspaceId], ); return { items, - loaded, - loading, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, create, update, remove, @@ -122,3 +95,36 @@ export function useIssueWatches(workspaceId?: string | null) { reset, }; } + +function patchIssueWatchCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + watch: IssueWatch, +) { + const patch = (prev: IssueWatch[] | undefined) => upsertById(prev ?? [], watch); + const patchExisting = (prev: IssueWatch[] | undefined) => (prev ? upsertById(prev, watch) : prev); + queryClient.setQueryData(qk.integrations.github.issueWatches(workspaceId), patch); + queryClient.setQueryData(qk.integrations.github.issueWatches(undefined), patchExisting); + queryClient.setQueryData(qk.integrations.github.issueWatches(watch.workspace_id), patch); +} + +function removeIssueWatchFromCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + id: string, +) { + const remove = (prev: IssueWatch[] | undefined) => + (prev ?? []).filter((watch) => watch.id !== id); + const removeExisting = (prev: IssueWatch[] | undefined) => + prev ? prev.filter((watch) => watch.id !== id) : prev; + queryClient.setQueryData(qk.integrations.github.issueWatches(workspaceId), remove); + queryClient.setQueryData(qk.integrations.github.issueWatches(undefined), removeExisting); +} + +function upsertById(items: T[], next: T): T[] { + const index = items.findIndex((item) => item.id === next.id); + if (index === -1) return [...items, next]; + const copy = [...items]; + copy[index] = next; + return copy; +} diff --git a/apps/web/hooks/domains/github/use-pr-key-to-tasks.test.ts b/apps/web/hooks/domains/github/use-pr-key-to-tasks.test.ts index c858cbf793..dba5981cdd 100644 --- a/apps/web/hooks/domains/github/use-pr-key-to-tasks.test.ts +++ b/apps/web/hooks/domains/github/use-pr-key-to-tasks.test.ts @@ -1,17 +1,22 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createElement, type ReactNode } from "react"; import { act, renderHook, cleanup } from "@testing-library/react"; -import { StateProvider, useAppStore } from "@/components/state-provider"; +import { StateProvider } from "@/components/state-provider"; import { prKey, usePRKeyToTasks } from "./use-pr-key-to-tasks"; import type { TaskPR } from "@/lib/types/github"; +const workspacePRs = vi.hoisted(() => ({ + value: {} as Record, +})); + vi.mock("./use-task-pr", () => ({ - // The hook is fire-and-forget side-effect; mocking it keeps the test - // focused on the inversion logic and avoids a real network call. - useWorkspacePRs: () => undefined, + useWorkspacePRs: () => workspacePRs.value, })); -afterEach(() => cleanup()); +afterEach(() => { + cleanup(); + workspacePRs.value = {}; +}); function makeTaskPR(overrides: Partial = {}): TaskPR { return { @@ -50,17 +55,8 @@ function wrapper({ children }: { children: ReactNode }) { return createElement(StateProvider, null, children); } -// Render usePRKeyToTasks alongside the store's setTaskPRs action so tests can -// seed the store and re-read the inverted map within the same render tree. function renderUsePRKeyToTasks() { - return renderHook( - () => { - const setTaskPRs = useAppStore((s) => s.setTaskPRs); - const map = usePRKeyToTasks("ws-1"); - return { setTaskPRs, map }; - }, - { wrapper }, - ); + return renderHook(() => usePRKeyToTasks("ws-1"), { wrapper }); } describe("prKey", () => { @@ -88,54 +84,57 @@ describe("prKey", () => { describe("usePRKeyToTasks", () => { it("returns an empty map when no task PRs are loaded", () => { const { result } = renderUsePRKeyToTasks(); - expect(result.current.map.size).toBe(0); + expect(result.current.size).toBe(0); }); it("groups distinct tasks linked to the same PR under one key", () => { - const { result } = renderUsePRKeyToTasks(); + const { result, rerender } = renderUsePRKeyToTasks(); act(() => { - result.current.setTaskPRs({ + workspacePRs.value = { "task-a": [ makeTaskPR({ id: "row-1", task_id: "task-a", owner: "o", repo: "r", pr_number: 7 }), ], "task-b": [ makeTaskPR({ id: "row-2", task_id: "task-b", owner: "o", repo: "r", pr_number: 7 }), ], - }); + }; + rerender(); }); - const entries = result.current.map.get("o/r#7"); + const entries = result.current.get("o/r#7"); expect(entries?.length).toBe(2); expect(entries?.map((e) => e.task_id).sort()).toEqual(["task-a", "task-b"]); }); it("keeps PRs that belong to different keys separate", () => { - const { result } = renderUsePRKeyToTasks(); + const { result, rerender } = renderUsePRKeyToTasks(); act(() => { - result.current.setTaskPRs({ + workspacePRs.value = { "task-a": [ makeTaskPR({ id: "row-1", task_id: "task-a", owner: "o", repo: "r", pr_number: 1 }), makeTaskPR({ id: "row-2", task_id: "task-a", owner: "o", repo: "r", pr_number: 2 }), ], - }); + }; + rerender(); }); - expect(result.current.map.get("o/r#1")?.length).toBe(1); - expect(result.current.map.get("o/r#2")?.length).toBe(1); + expect(result.current.get("o/r#1")?.length).toBe(1); + expect(result.current.get("o/r#2")?.length).toBe(1); }); it("skips entries whose value is not an array (defensive against partial hydration)", () => { - const { result } = renderUsePRKeyToTasks(); + const { result, rerender } = renderUsePRKeyToTasks(); act(() => { - result.current.setTaskPRs({ + workspacePRs.value = { "task-a": [ makeTaskPR({ id: "row-1", task_id: "task-a", owner: "o", repo: "r", pr_number: 1 }), ], // Partial hydration may briefly seed byTaskId[task] with a non-array // (e.g. an empty object). The hook should ignore those rows instead of // throwing. - "task-bad": {} as unknown as TaskPR[], - }); + "task-bad": {}, + }; + rerender(); }); - expect(result.current.map.get("o/r#1")?.length).toBe(1); - expect(result.current.map.size).toBe(1); + expect(result.current.get("o/r#1")?.length).toBe(1); + expect(result.current.size).toBe(1); }); }); diff --git a/apps/web/hooks/domains/github/use-pr-key-to-tasks.ts b/apps/web/hooks/domains/github/use-pr-key-to-tasks.ts index d7bc20d5c9..7274b253ba 100644 --- a/apps/web/hooks/domains/github/use-pr-key-to-tasks.ts +++ b/apps/web/hooks/domains/github/use-pr-key-to-tasks.ts @@ -1,7 +1,6 @@ "use client"; import { useMemo } from "react"; -import { useAppStore } from "@/components/state-provider"; import { useWorkspacePRs } from "./use-task-pr"; import type { TaskPR } from "@/lib/types/github"; @@ -10,8 +9,7 @@ function prKey(owner: string, repo: string, prNumber: number): string { } export function usePRKeyToTasks(workspaceId: string | null): Map { - useWorkspacePRs(workspaceId); - const byTaskId = useAppStore((state) => state.taskPRs.byTaskId); + const byTaskId = useWorkspacePRs(workspaceId); return useMemo(() => { const map = new Map(); diff --git a/apps/web/hooks/domains/github/use-pr-watch.ts b/apps/web/hooks/domains/github/use-pr-watch.ts index 4af27f2fa1..946181b02d 100644 --- a/apps/web/hooks/domains/github/use-pr-watch.ts +++ b/apps/web/hooks/domains/github/use-pr-watch.ts @@ -1,46 +1,38 @@ "use client"; -import { useEffect, useCallback } from "react"; -import { listPRWatches, deletePRWatch } from "@/lib/api/domains/github-api"; -import { useAppStore } from "@/components/state-provider"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { deletePRWatch } from "@/lib/api/domains/github-api"; +import { qk } from "@/lib/query/keys"; +import { prWatchesQueryOptions } from "@/lib/query/query-options/github"; export function usePRWatches() { - const items = useAppStore((state) => state.prWatches.items); - const loaded = useAppStore((state) => state.prWatches.loaded); - const loading = useAppStore((state) => state.prWatches.loading); - const setPRWatches = useAppStore((state) => state.setPRWatches); - const setPRWatchesLoading = useAppStore((state) => state.setPRWatchesLoading); - const removePRWatch = useAppStore((state) => state.removePRWatch); - - useEffect(() => { - if (loaded || loading) return; - setPRWatchesLoading(true); - listPRWatches({ cache: "no-store" }) - .then((response) => { - setPRWatches(response?.watches ?? []); - }) - .catch(() => { - setPRWatches([]); - }) - .finally(() => { - setPRWatchesLoading(false); - }); - }, [loaded, loading, setPRWatches, setPRWatchesLoading]); + const queryClient = useQueryClient(); + const query = useQuery(prWatchesQueryOptions()); + const items = query.data ?? []; const remove = useCallback( async (id: string) => { await deletePRWatch(id); - removePRWatch(id); + queryClient.setQueryData(qk.integrations.github.prWatches(), (prev: typeof items) => + (prev ?? []).filter((watch) => watch.id !== id), + ); }, - [removePRWatch], + [items, queryClient], ); - return { items, loaded, loading, remove }; + return { + items, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + remove, + }; } /** Get the PR watch for a specific session. */ export function usePRWatchForSession(sessionId: string | null) { - const items = useAppStore((state) => state.prWatches.items); + const query = useQuery(prWatchesQueryOptions()); + const items = query.data ?? []; const watch = sessionId ? (items.find((w) => w.session_id === sessionId) ?? null) : null; return watch; } diff --git a/apps/web/hooks/domains/github/use-review-watches.ts b/apps/web/hooks/domains/github/use-review-watches.ts index 40890f701b..f63cd054c9 100644 --- a/apps/web/hooks/domains/github/use-review-watches.ts +++ b/apps/web/hooks/domains/github/use-review-watches.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useCallback, useRef } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - listReviewWatches, createReviewWatch, updateReviewWatch, deleteReviewWatch, @@ -11,73 +11,47 @@ import { previewResetReviewWatch, resetReviewWatch, } from "@/lib/api/domains/github-api"; -import { useAppStore } from "@/components/state-provider"; -import type { CreateReviewWatchRequest, UpdateReviewWatchRequest } from "@/lib/types/github"; +import { qk } from "@/lib/query/keys"; +import { reviewWatchesQueryOptions } from "@/lib/query/query-options/github"; +import type { + CreateReviewWatchRequest, + ReviewWatch, + UpdateReviewWatchRequest, +} from "@/lib/types/github"; // useReviewWatches has three modes: // - workspaceId: string → fetch watches scoped to one workspace // - workspaceId: undefined → fetch watches across all workspaces // - workspaceId: null → don't fetch (caller hasn't resolved a workspace yet) export function useReviewWatches(workspaceId?: string | null) { - const items = useAppStore((state) => state.reviewWatches.items); - const loaded = useAppStore((state) => state.reviewWatches.loaded); - const loading = useAppStore((state) => state.reviewWatches.loading); - const setReviewWatches = useAppStore((state) => state.setReviewWatches); - const setReviewWatchesLoading = useAppStore((state) => state.setReviewWatchesLoading); - const addWatch = useAppStore((state) => state.addReviewWatch); - const updateWatch = useAppStore((state) => state.updateReviewWatch); - const removeWatch = useAppStore((state) => state.removeReviewWatch); - const loadedScopeRef = useRef(null); - - useEffect(() => { - if (workspaceId === null) return; - const scopeKey = workspaceId ?? "__all__"; - if (loadedScopeRef.current === scopeKey) return; - let cancelled = false; - loadedScopeRef.current = scopeKey; - setReviewWatchesLoading(true); - listReviewWatches(workspaceId ?? undefined, { cache: "no-store" }) - .then((response) => { - if (cancelled) return; - setReviewWatches(response?.watches ?? []); - }) - .catch(() => { - if (cancelled) return; - setReviewWatches([]); - }) - .finally(() => { - if (cancelled) return; - setReviewWatchesLoading(false); - }); - return () => { - cancelled = true; - }; - }, [workspaceId, setReviewWatches, setReviewWatchesLoading]); + const queryClient = useQueryClient(); + const query = useQuery(reviewWatchesQueryOptions(workspaceId)); + const items = query.data ?? []; const create = useCallback( async (req: CreateReviewWatchRequest) => { const watch = await createReviewWatch(req); - addWatch(watch); + patchReviewWatchCaches(queryClient, workspaceId, watch); return watch; }, - [addWatch], + [queryClient, workspaceId], ); const update = useCallback( async (id: string, watchWorkspaceId: string, req: UpdateReviewWatchRequest) => { const watch = await updateReviewWatch(id, watchWorkspaceId, req); - updateWatch(watch); + patchReviewWatchCaches(queryClient, workspaceId, watch); return watch; }, - [updateWatch], + [queryClient, workspaceId], ); const remove = useCallback( async (id: string, watchWorkspaceId: string) => { await deleteReviewWatch(id, watchWorkspaceId); - removeWatch(id); + removeReviewWatchFromCaches(queryClient, workspaceId, id); }, - [removeWatch], + [queryClient, workspaceId], ); const trigger = useCallback(async (id: string, watchWorkspaceId: string) => { @@ -95,22 +69,23 @@ export function useReviewWatches(workspaceId?: string | null) { const reset = useCallback( async (id: string, watchWorkspaceId: string) => { - const result = await resetReviewWatch(id, watchWorkspaceId); - try { - const response = await listReviewWatches(workspaceId ?? undefined, { cache: "no-store" }); - setReviewWatches(response?.watches ?? []); - } catch { - // Reset succeeded; a stale settings table is less harmful than failing the action. + const res = await resetReviewWatch(id, watchWorkspaceId); + // Patch the cached watch so the "Last polled" column reflects the + // reset immediately without waiting for the next poll tick. + const current = items.find((w) => w.id === id); + if (current) { + const patched = { ...current, last_polled_at: null }; + patchReviewWatchCaches(queryClient, workspaceId, patched); } - return result; + return res; }, - [setReviewWatches, workspaceId], + [items, queryClient, workspaceId], ); return { items, - loaded, - loading, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, create, update, remove, @@ -120,3 +95,37 @@ export function useReviewWatches(workspaceId?: string | null) { reset, }; } + +function patchReviewWatchCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + watch: ReviewWatch, +) { + const patch = (prev: ReviewWatch[] | undefined) => upsertById(prev ?? [], watch); + const patchExisting = (prev: ReviewWatch[] | undefined) => + prev ? upsertById(prev, watch) : prev; + queryClient.setQueryData(qk.integrations.github.reviewWatches(workspaceId), patch); + queryClient.setQueryData(qk.integrations.github.reviewWatches(undefined), patchExisting); + queryClient.setQueryData(qk.integrations.github.reviewWatches(watch.workspace_id), patch); +} + +function removeReviewWatchFromCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + id: string, +) { + const remove = (prev: ReviewWatch[] | undefined) => + (prev ?? []).filter((watch) => watch.id !== id); + const removeExisting = (prev: ReviewWatch[] | undefined) => + prev ? prev.filter((watch) => watch.id !== id) : prev; + queryClient.setQueryData(qk.integrations.github.reviewWatches(workspaceId), remove); + queryClient.setQueryData(qk.integrations.github.reviewWatches(undefined), removeExisting); +} + +function upsertById(items: T[], next: T): T[] { + const index = items.findIndex((item) => item.id === next.id); + if (index === -1) return [...items, next]; + const copy = [...items]; + copy[index] = next; + return copy; +} diff --git a/apps/web/hooks/domains/github/use-task-ci-options.test.tsx b/apps/web/hooks/domains/github/use-task-ci-options.test.tsx index fd28b986fb..c4adea3385 100644 --- a/apps/web/hooks/domains/github/use-task-ci-options.test.tsx +++ b/apps/web/hooks/domains/github/use-task-ci-options.test.tsx @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createElement, type ReactNode } from "react"; +import { createElement, type ReactNode, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; -import { StateProvider, useAppStore } from "@/components/state-provider"; +import { StateProvider } from "@/components/state-provider"; import type { TaskCIAutomationOptions } from "@/lib/types/github"; const apiMocks = vi.hoisted(() => ({ @@ -17,7 +18,14 @@ vi.mock("@/lib/api/domains/github-api", () => ({ import { useTaskCIAutomationOptions } from "./use-task-ci-options"; function wrapper({ children }: { children: ReactNode }) { - return createElement(StateProvider, null, children); + const [queryClient] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + return createElement( + QueryClientProvider, + { client: queryClient }, + createElement(StateProvider, null, children), + ); } function makeOptions(overrides: Partial = {}): TaskCIAutomationOptions { @@ -50,7 +58,12 @@ describe("useTaskCIAutomationOptions", () => { const { result } = renderHook(() => useTaskCIAutomationOptions("task-1"), { wrapper }); await waitFor(() => expect(result.current.options?.auto_fix_enabled).toBe(true)); - expect(apiMocks.getOptionsMock).toHaveBeenCalledWith("task-1", { cache: "no-store" }); + expect(apiMocks.getOptionsMock).toHaveBeenCalledWith( + "task-1", + expect.objectContaining({ + init: expect.objectContaining({ signal: expect.any(AbortSignal) }), + }), + ); expect(result.current.loading).toBe(false); }); @@ -124,27 +137,45 @@ describe("useTaskCIAutomationOptions task switching", () => { .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve))) .mockReturnValueOnce(new Promise((resolve) => (resolveSecond = resolve))); - const { result, rerender } = renderHook( - ({ taskId }) => ({ - hook: useTaskCIAutomationOptions(taskId), - automation: useAppStore((state) => state.taskCIAutomation), - }), - { wrapper, initialProps: { taskId: "task-1" } }, - ); + const { result, rerender } = renderHook(({ taskId }) => useTaskCIAutomationOptions(taskId), { + wrapper, + initialProps: { taskId: "task-1" }, + }); - await waitFor(() => expect(result.current.automation.loading["task-1"]).toBe(true)); + await waitFor(() => expect(result.current.loading).toBe(true)); rerender({ taskId: "task-2" }); - await waitFor(() => expect(result.current.automation.loading["task-2"]).toBe(true)); + await waitFor(() => expect(result.current.loading).toBe(true)); resolveFirst(makeOptions({ task_id: "task-1" })); await act(async () => { await Promise.resolve(); }); - expect(result.current.automation.loading["task-1"]).toBe(false); - expect(result.current.automation.loading["task-2"]).toBe(true); + expect(result.current.loading).toBe(true); resolveSecond(makeOptions({ task_id: "task-2" })); - await waitFor(() => expect(result.current.automation.loading["task-2"]).toBe(false)); + await waitFor(() => expect(result.current.loading).toBe(false)); + }); + + it("ignores stale load errors from the previously selected task", async () => { + let rejectFirst: (error: Error) => void = () => {}; + let resolveSecond: (value: TaskCIAutomationOptions) => void = () => {}; + apiMocks.getOptionsMock + .mockReturnValueOnce(new Promise((_, reject) => (rejectFirst = reject))) + .mockReturnValueOnce(new Promise((resolve) => (resolveSecond = resolve))); + + const { result, rerender } = renderHook(({ taskId }) => useTaskCIAutomationOptions(taskId), { + wrapper, + initialProps: { taskId: "task-1" }, + }); + + await waitFor(() => expect(result.current.loading).toBe(true)); + rerender({ taskId: "task-2" }); + rejectFirst(new Error("old task failed")); + resolveSecond(makeOptions({ task_id: "task-2", auto_fix_enabled: true })); + + await waitFor(() => expect(result.current.options?.task_id).toBe("task-2")); + expect(result.current.error).toBeNull(); + expect(result.current.options?.auto_fix_enabled).toBe(true); }); }); diff --git a/apps/web/hooks/domains/github/use-task-ci-options.ts b/apps/web/hooks/domains/github/use-task-ci-options.ts index 7a3c6f3172..ad9fbb496e 100644 --- a/apps/web/hooks/domains/github/use-task-ci-options.ts +++ b/apps/web/hooks/domains/github/use-task-ci-options.ts @@ -1,11 +1,13 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getTaskCIAutomationOptions, updateTaskCIAutomationOptions, } from "@/lib/api/domains/github-api"; -import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { taskCiOptionsQueryOptions } from "@/lib/query/query-options/github"; import type { TaskCIAutomationPatch, TaskCIAutomationOptions } from "@/lib/types/github"; function errorMessage(error: unknown): string { @@ -13,84 +15,141 @@ function errorMessage(error: unknown): string { } export function useTaskCIAutomationOptions(taskId: string | null) { + const queryClient = useQueryClient(); + const query = useQuery({ + ...taskCiOptionsQueryOptions(taskId ?? ""), + enabled: Boolean(taskId), + }); + const activeTaskIdRef = useRef(taskId); + activeTaskIdRef.current = taskId; const refreshRequestRef = useRef>({}); const updateRequestRef = useRef>({}); - const options = useAppStore((state) => - taskId ? (state.taskCIAutomation.byTaskId[taskId] ?? null) : null, - ); - const loading = useAppStore((state) => - taskId ? Boolean(state.taskCIAutomation.loading[taskId]) : false, - ); - const saving = useAppStore((state) => - taskId ? Boolean(state.taskCIAutomation.saving[taskId]) : false, - ); - const error = useAppStore((state) => - taskId ? (state.taskCIAutomation.errors[taskId] ?? null) : null, - ); - const setOptions = useAppStore((state) => state.setTaskCIAutomationOptions); - const setLoading = useAppStore((state) => state.setTaskCIAutomationLoading); - const setSaving = useAppStore((state) => state.setTaskCIAutomationSaving); - const setError = useAppStore((state) => state.setTaskCIAutomationError); + const options = query.data ?? null; + const [manualLoading, setManualLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); const refresh = useCallback(async (): Promise => { if (!taskId) return null; const requestId = (refreshRequestRef.current[taskId] ?? 0) + 1; refreshRequestRef.current[taskId] = requestId; - setLoading(taskId, true); - setError(taskId, null); + await queryClient.cancelQueries({ queryKey: qk.integrations.github.taskCiOptions(taskId) }); + setManualLoading(true); + setError(null); try { const response = await getTaskCIAutomationOptions(taskId, { cache: "no-store" }); if (refreshRequestRef.current[taskId] === requestId) { - setOptions(taskId, response); + queryClient.setQueryData(qk.integrations.github.taskCiOptions(taskId), response); } return response; } catch (err) { - if (refreshRequestRef.current[taskId] === requestId) { - setError(taskId, errorMessage(err)); + if (isActiveRequest(activeTaskIdRef, refreshRequestRef.current, taskId, requestId)) { + setError(errorMessage(err)); } throw err; } finally { - if (refreshRequestRef.current[taskId] === requestId) { - setLoading(taskId, false); + if (isActiveRequest(activeTaskIdRef, refreshRequestRef.current, taskId, requestId)) { + setManualLoading(false); } } - }, [setError, setLoading, setOptions, taskId]); + }, [queryClient, taskId]); const update = useCallback( async (patch: TaskCIAutomationPatch): Promise => { if (!taskId) return null; const requestId = (updateRequestRef.current[taskId] ?? 0) + 1; updateRequestRef.current[taskId] = requestId; - setSaving(taskId, true); - setError(taskId, null); + setSaving(true); + setError(null); try { const response = await updateTaskCIAutomationOptions(taskId, patch, { cache: "no-store" }); if (updateRequestRef.current[taskId] === requestId) { - setOptions(taskId, response); + queryClient.setQueryData(qk.integrations.github.taskCiOptions(taskId), response); } return response; } catch (err) { - if (updateRequestRef.current[taskId] === requestId) { - setError(taskId, errorMessage(err)); + if (isActiveRequest(activeTaskIdRef, updateRequestRef.current, taskId, requestId)) { + setError(errorMessage(err)); } throw err; } finally { - if (updateRequestRef.current[taskId] === requestId) { - setSaving(taskId, false); + if (isActiveRequest(activeTaskIdRef, updateRequestRef.current, taskId, requestId)) { + setSaving(false); } } }, - [setError, setOptions, setSaving, taskId], + [queryClient, taskId], ); const resetPrompt = useCallback(() => update({ auto_fix_prompt_override: null }), [update]); useEffect(() => { - if (!taskId || options || loading || error) return; + setError(null); + setManualLoading(false); + setSaving(false); + }, [taskId]); + + useMirrorTaskCIQuery({ + error, + loading: manualLoading, + options, + query, + refresh, + setError, + taskId, + }); + + return { + options, + loading: manualLoading || (query.isFetching && !query.isSuccess), + saving, + error, + refresh, + update, + resetPrompt, + }; +} + +function isActiveRequest( + activeTaskIdRef: RefObject, + requests: Record, + taskId: string, + requestId: number, +): boolean { + return activeTaskIdRef.current === taskId && requests[taskId] === requestId; +} + +function useMirrorTaskCIQuery({ + error, + loading, + options, + query, + refresh, + setError, + taskId, +}: { + error: string | null; + loading: boolean; + options: TaskCIAutomationOptions | null; + query: { + data: TaskCIAutomationOptions | undefined; + error: Error | null; + isFetching: boolean; + isSuccess: boolean; + }; + refresh: () => Promise; + setError: (error: string | null) => void; + taskId: string | null; +}) { + useEffect(() => { + if (!taskId || !query.error) return; + setError(errorMessage(query.error)); + }, [query.error, setError, taskId]); + + useEffect(() => { + if (!taskId || options || loading || error || query.error || query.isFetching) return; void refresh().catch(() => { // Error state is stored for the UI; callers can retry via refresh. }); - }, [error, loading, options, refresh, taskId]); - - return { options, loading, saving, error, refresh, update, resetPrompt }; + }, [error, loading, options, query.isFetching, refresh, taskId]); } diff --git a/apps/web/hooks/domains/github/use-task-pr.test.tsx b/apps/web/hooks/domains/github/use-task-pr.test.tsx index 9a3d8b20f0..14f5b1b724 100644 --- a/apps/web/hooks/domains/github/use-task-pr.test.tsx +++ b/apps/web/hooks/domains/github/use-task-pr.test.tsx @@ -1,29 +1,88 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createElement, type ReactNode } from "react"; -import { act, cleanup, renderHook } from "@testing-library/react"; +import { createElement, type ReactNode, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import type { TaskPR } from "@/lib/types/github"; const requestMock = vi.fn(); +const listWorkspaceTaskPRsMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => ({ request: requestMock }), })); -// listWorkspaceTaskPRs is only used by useWorkspacePRs (not under test -// here). Stub it so the module import doesn't fail in jsdom. vi.mock("@/lib/api/domains/github-api", () => ({ - listWorkspaceTaskPRs: vi.fn().mockResolvedValue(null), + listWorkspaceTaskPRs: listWorkspaceTaskPRsMock, })); -import { useTaskPR } from "./use-task-pr"; +import { useActiveTaskPR, useTaskPR, useWorkspacePRs } from "./use-task-pr"; + +const CREATED_AT = "2026-06-28T00:00:00Z"; + +function createQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} function wrapper({ children }: { children: ReactNode }) { - return createElement(StateProvider, null, children); + const [queryClient] = useState(createQueryClient); + return createElement( + QueryClientProvider, + { client: queryClient }, + createElement(StateProvider, null, children), + ); +} + +function wrapperFor(queryClient: QueryClient, initialState?: Partial) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement( + QueryClientProvider, + { client: queryClient }, + createElement(StateProvider, { initialState, children }), + ); + }; +} + +function taskPR(overrides: Partial = {}): TaskPR { + return { + id: "task-pr-1", + task_id: "task-1", + owner: "kdlbs", + repo: "kandev", + pr_number: 1130, + pr_url: "https://github.com/kdlbs/kandev/pull/1130", + pr_title: "Migrate to TanStack Query", + head_branch: "feature/tanstack-migration", + base_branch: "main", + author_login: "developer", + state: "open", + review_state: "", + checks_state: "", + mergeable_state: "unknown", + review_count: 0, + pending_review_count: 0, + comment_count: 0, + unresolved_review_threads: 0, + checks_total: 0, + checks_passing: 0, + additions: 0, + deletions: 0, + created_at: CREATED_AT, + merged_at: null, + closed_at: null, + last_synced_at: null, + updated_at: CREATED_AT, + ...overrides, + }; } beforeEach(() => { vi.useFakeTimers(); requestMock.mockReset(); + listWorkspaceTaskPRsMock.mockReset(); + listWorkspaceTaskPRsMock.mockResolvedValue({ task_prs: {} }); }); afterEach(() => { cleanup(); @@ -79,4 +138,81 @@ describe("useTaskPR — permanent flag", () => { }); expect(requestMock).toHaveBeenCalledTimes(3); }); + + it("clears cached task PRs when sync returns an explicit empty list", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.integrations.github.taskPr("task-1"), [ + taskPR({ pr_number: 1512 }), + ]); + requestMock.mockResolvedValue({ prs: [] }); + + renderHook(() => useTaskPR("task-1"), { wrapper: wrapperFor(queryClient) }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(queryClient.getQueryData(qk.integrations.github.taskPr("task-1"))).toEqual([]); + }); +}); + +describe("useActiveTaskPR", () => { + it("subscribes to cached active task PRs without starting PR sync", () => { + const queryClient = createQueryClient(); + const wrapper = wrapperFor(queryClient, { + tasks: { activeTaskId: "task-1" }, + } as Partial); + + const { result } = renderHook(() => useActiveTaskPR(), { wrapper }); + + expect(result.current).toBeNull(); + + act(() => { + queryClient.setQueryData(qk.integrations.github.taskPr("task-1"), [ + taskPR({ pr_number: 1512 }), + ]); + }); + + expect(result.current?.pr_number).toBe(1512); + expect(requestMock).not.toHaveBeenCalled(); + }); +}); + +describe("useWorkspacePRs", () => { + it("clears per-task PR caches that disappear from the workspace aggregate", async () => { + vi.useRealTimers(); + const queryClient = createQueryClient(); + const taskOnePr = taskPR({ task_id: "task-1", pr_number: 1512 }); + const taskTwoPr = taskPR({ id: "task-pr-2", task_id: "task-2", pr_number: 1513 }); + listWorkspaceTaskPRsMock + .mockResolvedValueOnce({ + task_prs: { + "task-1": [taskOnePr], + "task-2": [taskTwoPr], + }, + }) + .mockResolvedValueOnce({ + task_prs: { + "task-2": [taskTwoPr], + }, + }); + + const { result } = renderHook(() => useWorkspacePRs("workspace-1"), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current["task-1"]).toEqual([taskOnePr])); + expect(queryClient.getQueryData(qk.integrations.github.taskPr("task-1"))).toEqual([taskOnePr]); + + await act(async () => { + await queryClient.invalidateQueries({ + exact: true, + queryKey: qk.integrations.github.prs("workspace-1"), + }); + }); + + await waitFor(() => expect(result.current["task-1"]).toBeUndefined()); + expect(queryClient.getQueryData(qk.integrations.github.taskPr("task-1"))).toEqual([]); + expect(queryClient.getQueryData(qk.integrations.github.taskPr("task-2"))).toEqual([taskTwoPr]); + }); }); diff --git a/apps/web/hooks/domains/github/use-task-pr.ts b/apps/web/hooks/domains/github/use-task-pr.ts index 08ac58691e..fb1ab462d6 100644 --- a/apps/web/hooks/domains/github/use-task-pr.ts +++ b/apps/web/hooks/domains/github/use-task-pr.ts @@ -1,42 +1,53 @@ "use client"; -import { useEffect, useCallback, useRef } from "react"; -import { listWorkspaceTaskPRs } from "@/lib/api/domains/github-api"; +import { useEffect, useCallback, useRef, useSyncExternalStore } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { + taskPrsQueryOptions, + workspaceTaskPrsQueryOptions, +} from "@/lib/query/query-options/github"; import type { TaskPR } from "@/lib/types/github"; /** Fetch all PR associations for a workspace. */ export function useWorkspacePRs(workspaceId: string | null) { - const setTaskPRs = useAppStore((state) => state.setTaskPRs); - const fetchedRef = useRef(null); - const requestRef = useRef(0); + const queryClient = useQueryClient(); + const taskIdsRef = useRef<{ workspaceId: string | null; taskIds: Set }>({ + workspaceId: null, + taskIds: new Set(), + }); + const query = useQuery({ + ...workspaceTaskPrsQueryOptions(workspaceId ?? ""), + enabled: Boolean(workspaceId), + }); useEffect(() => { - if (!workspaceId) { - fetchedRef.current = null; - return; + if (!query.data || !workspaceId) return; + const prsByTask = query.data.task_prs ?? {}; + const nextTaskIds = new Set(Object.keys(prsByTask)); + const previousTaskIds = + taskIdsRef.current.workspaceId === workspaceId + ? taskIdsRef.current.taskIds + : new Set(); + for (const taskId of previousTaskIds) { + if (!nextTaskIds.has(taskId)) { + queryClient.setQueryData(qk.integrations.github.taskPr(taskId), []); + } } - if (fetchedRef.current === workspaceId) return; - - const requestId = ++requestRef.current; - fetchedRef.current = workspaceId; + for (const [taskId, prs] of Object.entries(prsByTask)) { + queryClient.setQueryData(qk.integrations.github.taskPr(taskId), prs); + } + taskIdsRef.current = { workspaceId, taskIds: nextTaskIds }; + }, [query.data, queryClient, workspaceId]); - listWorkspaceTaskPRs(workspaceId, { cache: "no-store" }) - .then((response) => { - if (requestRef.current !== requestId) return; - setTaskPRs(response?.task_prs ?? {}); - }) - .catch(() => { - if (requestRef.current === requestId) { - fetchedRef.current = null; // allow retry on failure - } - }); - }, [workspaceId, setTaskPRs]); + return query.data?.task_prs ?? {}; } const SYNC_RETRY_DELAY = 5_000; // 5 seconds const SYNC_MAX_RETRIES = 6; // Up to 30 seconds of retries +const EMPTY_TASK_PRS: TaskPR[] = []; /** * Returns the primary PR (first by created_at) for a task. Multi-repo tasks @@ -73,9 +84,11 @@ type SyncResponse = { prs?: TaskPR[]; permanent?: boolean } | TaskPR | null | un /** Fetch a single task's PR associations, with on-demand sync via WS. */ export function useTaskPR(taskId: string | null) { - const prs = useAppStore((state) => (taskId ? (state.taskPRs.byTaskId[taskId] ?? null) : null)); + const queryClient = useQueryClient(); + const taskPrsQuery = useQuery(taskPrsQueryOptions(taskId ?? "")); + const prs = Array.isArray(taskPrsQuery.data) ? taskPrsQuery.data : []; const pr = getPrimaryTaskPR(prs ?? undefined); - const setTaskPR = useAppStore((state) => state.setTaskPR); + const clearPendingPrUrlForTaskPR = useAppStore((state) => state.clearPendingPrUrlForTaskPR); const retryRef = useRef(0); const permanentRef = useRef(false); // Monotonic counter incremented before each WS request, snapshotted in @@ -117,16 +130,17 @@ export function useTaskPR(taskId: string | null) { retryRef.current = SYNC_MAX_RETRIES; } const list = normalizeSyncResponse(result); - if (list.length === 0) return; - for (const pr of list) { - if (pr.task_id) setTaskPR(requestedTaskId, pr); + if (list.length === 0) { + queryClient.setQueryData(qk.integrations.github.taskPr(requestedTaskId), EMPTY_TASK_PRS); + return; } + queryClient.setQueryData(qk.integrations.github.taskPr(requestedTaskId), list); retryRef.current = 0; }) .catch(() => { // Ignore - sync may fail if no watch exists }); - }, [taskId, setTaskPR]); + }, [queryClient, taskId]); // Reset retry/permanent state when taskId changes. Bumping requestRef // here invalidates any still-in-flight .then() closure from the prior @@ -137,6 +151,13 @@ export function useTaskPR(taskId: string | null) { requestRef.current++; }, [taskId]); + useEffect(() => { + if (!taskId || prs.length === 0) return; + for (const taskPR of prs) { + clearPendingPrUrlForTaskPR(taskId, taskPR); + } + }, [clearPendingPrUrlForTaskPR, prs, taskId]); + // Sync once when the task becomes active (freshness check). // Intentionally excludes `pr` so WS-driven store updates don't re-trigger. useEffect(() => { @@ -169,11 +190,25 @@ export function useTaskPR(taskId: string | null) { }; } -/** Read the active task's primary PR from the store (no fetching). */ +function useCachedTaskPRs(taskId: string | null): TaskPR[] { + const queryClient = useQueryClient(); + const subscribe = useCallback( + (onStoreChange: () => void) => queryClient.getQueryCache().subscribe(onStoreChange), + [queryClient], + ); + const getSnapshot = useCallback(() => { + if (!taskId) return EMPTY_TASK_PRS; + const prs = queryClient.getQueryData(qk.integrations.github.taskPr(taskId)); + return Array.isArray(prs) ? prs : EMPTY_TASK_PRS; + }, [queryClient, taskId]); + + return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_TASK_PRS); +} + +/** Read the active task's primary PR from the cache (no fetching or sync). */ export function useActiveTaskPR(): TaskPR | null { - return useAppStore((s) => { - const taskId = s.tasks.activeTaskId; - if (!taskId) return null; - return getPrimaryTaskPR(s.taskPRs.byTaskId[taskId]); - }); + const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); + const prs = useCachedTaskPRs(activeTaskId); + if (!activeTaskId) return null; + return getPrimaryTaskPR(prs); } diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts b/apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts index 1354e02171..0210d18d1f 100644 --- a/apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts +++ b/apps/web/hooks/domains/gitlab/use-gitlab-action-presets.ts @@ -1,12 +1,10 @@ "use client"; -import { useEffect, useCallback, useRef } from "react"; -import { - getActionPresets, - updateActionPresets, - resetActionPresets, -} from "@/lib/api/domains/gitlab-api"; -import { useAppStore } from "@/components/state-provider"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { updateActionPresets, resetActionPresets } from "@/lib/api/domains/gitlab-api"; +import { qk } from "@/lib/query/keys"; +import { gitlabActionPresetsQueryOptions } from "@/lib/query/query-options/gitlab"; import type { GitLabActionPreset } from "@/lib/types/gitlab"; /** @@ -16,43 +14,38 @@ import type { GitLabActionPreset } from "@/lib/types/gitlab"; * unreachable. */ export function useGitLabActionPresets(workspaceId: string | null | undefined) { - const presets = useAppStore((state) => - workspaceId ? state.gitlabActionPresets.byWorkspaceId[workspaceId] : null, - ); - const loading = useAppStore((state) => state.gitlabActionPresets.loading); - const set = useAppStore((state) => state.setGitLabActionPresets); - const setLoading = useAppStore((state) => state.setGitLabActionPresetsLoading); - const attemptedRef = useRef>(new Set()); - - useEffect(() => { - if (!workspaceId || presets || loading) return; - if (attemptedRef.current.has(workspaceId)) return; - attemptedRef.current.add(workspaceId); - setLoading(true); - getActionPresets(workspaceId) - .then((res) => { - if (res) set(workspaceId, res); - }) - .catch(() => {}) - .finally(() => setLoading(false)); - }, [workspaceId, presets, loading, set, setLoading]); + const queryClient = useQueryClient(); + const query = useQuery({ + ...gitlabActionPresetsQueryOptions(workspaceId ?? ""), + enabled: Boolean(workspaceId), + }); + const presets = query.data ?? null; const update = useCallback( async (body: { mr?: GitLabActionPreset[]; issue?: GitLabActionPreset[] }) => { if (!workspaceId) return null; const result = await updateActionPresets(workspaceId, body); - if (result) set(workspaceId, result); + if (result) { + queryClient.setQueryData(qk.integrations.gitlab.actionPresets(workspaceId), result); + } return result; }, - [workspaceId, set], + [queryClient, workspaceId], ); const reset = useCallback(async () => { if (!workspaceId) return null; const result = await resetActionPresets(workspaceId); - if (result) set(workspaceId, result); + if (result) { + queryClient.setQueryData(qk.integrations.gitlab.actionPresets(workspaceId), result); + } return result; - }, [workspaceId, set]); + }, [queryClient, workspaceId]); - return { presets, loading, update, reset }; + return { + presets, + loading: query.isFetching && !query.isSuccess, + update, + reset, + }; } diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts b/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts index d11b246744..478a8d7ad7 100644 --- a/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts +++ b/apps/web/hooks/domains/gitlab/use-gitlab-issue-watches.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useCallback, useRef } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - listIssueWatches, createIssueWatch, updateIssueWatch, deleteIssueWatch, @@ -11,63 +11,85 @@ import { type CreateIssueWatchRequest, type UpdateIssueWatchRequest, } from "@/lib/api/domains/gitlab-api"; -import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { gitlabIssueWatchesQueryOptions } from "@/lib/query/query-options/gitlab"; +import type { IssueWatch } from "@/lib/types/gitlab"; -/** - * useGitLabIssueWatches mirrors useGitLabReviewWatches — the per-instance - * lastFetchedRef triggers a refetch on workspace switch, working around the - * fact that the slice-level `loaded` flag is shared across all consumers. - */ export function useGitLabIssueWatches(workspaceId?: string | null) { - const items = useAppStore((state) => state.gitlabIssueWatches.items); - const loaded = useAppStore((state) => state.gitlabIssueWatches.loaded); - const loading = useAppStore((state) => state.gitlabIssueWatches.loading); - const set = useAppStore((state) => state.setGitLabIssueWatches); - const setLoading = useAppStore((state) => state.setGitLabIssueWatchesLoading); - const add = useAppStore((state) => state.addGitLabIssueWatch); - const upd = useAppStore((state) => state.updateGitLabIssueWatchInStore); - const rm = useAppStore((state) => state.removeGitLabIssueWatch); - const lastFetchedRef = useRef(undefined); - - useEffect(() => { - if (workspaceId === null || loading) return; - if (loaded && lastFetchedRef.current === workspaceId) return; - lastFetchedRef.current = workspaceId; - setLoading(true); - listIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) - .then((response) => set(response?.watches ?? [])) - .catch(() => set([])) - .finally(() => setLoading(false)); - }, [workspaceId, loaded, loading, set, setLoading]); + const queryClient = useQueryClient(); + const query = useQuery(gitlabIssueWatchesQueryOptions(workspaceId)); + const items = query.data ?? []; const create = useCallback( async (req: CreateIssueWatchRequest) => { const watch = await createIssueWatch(req); - add(watch); + patchGitLabIssueWatchCaches(queryClient, workspaceId, watch); return watch; }, - [add], + [queryClient, workspaceId], ); const update = useCallback( async (id: string, req: UpdateIssueWatchRequest) => { const watch = await updateIssueWatch(id, req); - upd(watch); + patchGitLabIssueWatchCaches(queryClient, workspaceId, watch); return watch; }, - [upd], + [queryClient, workspaceId], ); const remove = useCallback( async (id: string) => { await deleteIssueWatch(id); - rm(id); + removeGitLabIssueWatchFromCaches(queryClient, workspaceId, id); }, - [rm], + [queryClient, workspaceId], ); const trigger = useCallback((id: string) => triggerIssueWatch(id), []); const triggerAll = useCallback(() => triggerAllIssueWatches(), []); - return { items, loaded, loading, create, update, remove, trigger, triggerAll }; + return { + items, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + create, + update, + remove, + trigger, + triggerAll, + }; +} + +function patchGitLabIssueWatchCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + watch: IssueWatch, +) { + const patch = (prev: IssueWatch[] | undefined) => upsertById(prev ?? [], watch); + const patchExisting = (prev: IssueWatch[] | undefined) => (prev ? upsertById(prev, watch) : prev); + queryClient.setQueryData(qk.integrations.gitlab.issueWatches(workspaceId), patch); + queryClient.setQueryData(qk.integrations.gitlab.issueWatches(undefined), patchExisting); + queryClient.setQueryData(qk.integrations.gitlab.issueWatches(watch.workspace_id), patch); +} + +function removeGitLabIssueWatchFromCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + id: string, +) { + const remove = (prev: IssueWatch[] | undefined) => + (prev ?? []).filter((watch) => watch.id !== id); + const removeExisting = (prev: IssueWatch[] | undefined) => + prev ? prev.filter((watch) => watch.id !== id) : prev; + queryClient.setQueryData(qk.integrations.gitlab.issueWatches(workspaceId), remove); + queryClient.setQueryData(qk.integrations.gitlab.issueWatches(undefined), removeExisting); +} + +function upsertById(items: T[], next: T): T[] { + const index = items.findIndex((item) => item.id === next.id); + if (index === -1) return [...items, next]; + const copy = [...items]; + copy[index] = next; + return copy; } diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts b/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts index d36b8019a4..ffcc1e22b9 100644 --- a/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts +++ b/apps/web/hooks/domains/gitlab/use-gitlab-review-watches.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useCallback, useRef } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - listReviewWatches, createReviewWatch, updateReviewWatch, deleteReviewWatch, @@ -11,69 +11,92 @@ import { type CreateReviewWatchRequest, type UpdateReviewWatchRequest, } from "@/lib/api/domains/gitlab-api"; -import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { gitlabReviewWatchesQueryOptions } from "@/lib/query/query-options/gitlab"; +import type { ReviewWatch } from "@/lib/types/gitlab"; /** * useGitLabReviewWatches — three modes: * - workspaceId: string → fetch watches scoped to one workspace * - workspaceId: undefined → fetch watches across all workspaces * - workspaceId: null → don't fetch (caller hasn't resolved a workspace yet) - * - * The internal `loaded` flag lives on the slice and is shared across all - * useGitLabReviewWatches instances, so it can't double as a per-workspace - * cache key. We track the last-fetched workspace key here and re-fetch when - * it changes (workspace switch). */ export function useGitLabReviewWatches(workspaceId?: string | null) { - const items = useAppStore((state) => state.gitlabReviewWatches.items); - const loaded = useAppStore((state) => state.gitlabReviewWatches.loaded); - const loading = useAppStore((state) => state.gitlabReviewWatches.loading); - const set = useAppStore((state) => state.setGitLabReviewWatches); - const setLoading = useAppStore((state) => state.setGitLabReviewWatchesLoading); - const add = useAppStore((state) => state.addGitLabReviewWatch); - const upd = useAppStore((state) => state.updateGitLabReviewWatchInStore); - const rm = useAppStore((state) => state.removeGitLabReviewWatch); - const lastFetchedRef = useRef(undefined); - - useEffect(() => { - if (workspaceId === null || loading) return; - if (loaded && lastFetchedRef.current === workspaceId) return; - lastFetchedRef.current = workspaceId; - setLoading(true); - listReviewWatches(workspaceId ?? undefined, { cache: "no-store" }) - .then((response) => set(response?.watches ?? [])) - .catch(() => set([])) - .finally(() => setLoading(false)); - }, [workspaceId, loaded, loading, set, setLoading]); + const queryClient = useQueryClient(); + const query = useQuery(gitlabReviewWatchesQueryOptions(workspaceId)); + const items = query.data ?? []; const create = useCallback( async (req: CreateReviewWatchRequest) => { const watch = await createReviewWatch(req); - add(watch); + patchGitLabReviewWatchCaches(queryClient, workspaceId, watch); return watch; }, - [add], + [queryClient, workspaceId], ); const update = useCallback( async (id: string, req: UpdateReviewWatchRequest) => { const watch = await updateReviewWatch(id, req); - upd(watch); + patchGitLabReviewWatchCaches(queryClient, workspaceId, watch); return watch; }, - [upd], + [queryClient, workspaceId], ); const remove = useCallback( async (id: string) => { await deleteReviewWatch(id); - rm(id); + removeGitLabReviewWatchFromCaches(queryClient, workspaceId, id); }, - [rm], + [queryClient, workspaceId], ); const trigger = useCallback((id: string) => triggerReviewWatch(id), []); const triggerAll = useCallback(() => triggerAllReviewWatches(), []); - return { items, loaded, loading, create, update, remove, trigger, triggerAll }; + return { + items, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + create, + update, + remove, + trigger, + triggerAll, + }; +} + +function patchGitLabReviewWatchCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + watch: ReviewWatch, +) { + const patch = (prev: ReviewWatch[] | undefined) => upsertById(prev ?? [], watch); + const patchExisting = (prev: ReviewWatch[] | undefined) => + prev ? upsertById(prev, watch) : prev; + queryClient.setQueryData(qk.integrations.gitlab.reviewWatches(workspaceId), patch); + queryClient.setQueryData(qk.integrations.gitlab.reviewWatches(undefined), patchExisting); + queryClient.setQueryData(qk.integrations.gitlab.reviewWatches(watch.workspace_id), patch); +} + +function removeGitLabReviewWatchFromCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + id: string, +) { + const remove = (prev: ReviewWatch[] | undefined) => + (prev ?? []).filter((watch) => watch.id !== id); + const removeExisting = (prev: ReviewWatch[] | undefined) => + prev ? prev.filter((watch) => watch.id !== id) : prev; + queryClient.setQueryData(qk.integrations.gitlab.reviewWatches(workspaceId), remove); + queryClient.setQueryData(qk.integrations.gitlab.reviewWatches(undefined), removeExisting); +} + +function upsertById(items: T[], next: T): T[] { + const index = items.findIndex((item) => item.id === next.id); + if (index === -1) return [...items, next]; + const copy = [...items]; + copy[index] = next; + return copy; } diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-stats.ts b/apps/web/hooks/domains/gitlab/use-gitlab-stats.ts index eb00599a15..359811e2e9 100644 --- a/apps/web/hooks/domains/gitlab/use-gitlab-stats.ts +++ b/apps/web/hooks/domains/gitlab/use-gitlab-stats.ts @@ -1,31 +1,14 @@ "use client"; -import { useEffect, useRef } from "react"; -import { fetchGitLabStats } from "@/lib/api/domains/gitlab-api"; -import { useAppStore } from "@/components/state-provider"; +import { useQuery } from "@tanstack/react-query"; +import { gitlabStatsQueryOptions } from "@/lib/query/query-options/gitlab"; /** * useGitLabStats subscribes to the open-MRs / awaiting-review / open-issues - * counts surfaced on the /gitlab page header. Per-mount attempted flag - * prevents an infinite re-fetch loop when GitLab is unreachable. + * counts surfaced on the /gitlab page header. */ export function useGitLabStats() { - const stats = useAppStore((state) => state.gitlabStats.data); - const loading = useAppStore((state) => state.gitlabStats.loading); - const loadedAt = useAppStore((state) => state.gitlabStats.loadedAt); - const setStats = useAppStore((state) => state.setGitLabStats); - const setStatsLoading = useAppStore((state) => state.setGitLabStatsLoading); - const attemptedRef = useRef(false); + const query = useQuery(gitlabStatsQueryOptions()); - useEffect(() => { - if (loading || loadedAt !== null || attemptedRef.current) return; - attemptedRef.current = true; - setStatsLoading(true); - fetchGitLabStats() - .then((res) => setStats(res ?? null)) - .catch(() => setStats(null)) - .finally(() => setStatsLoading(false)); - }, [loading, loadedAt, setStats, setStatsLoading]); - - return { stats, loading }; + return { stats: query.data ?? null, loading: query.isFetching && !query.isSuccess }; } diff --git a/apps/web/hooks/domains/gitlab/use-gitlab-status.ts b/apps/web/hooks/domains/gitlab/use-gitlab-status.ts index fc000b5b64..bf901f7b7c 100644 --- a/apps/web/hooks/domains/gitlab/use-gitlab-status.ts +++ b/apps/web/hooks/domains/gitlab/use-gitlab-status.ts @@ -1,46 +1,29 @@ "use client"; -import { useEffect, useRef } from "react"; -import { fetchGitLabStatus } from "@/lib/api/domains/gitlab-api"; -import { useAppStore } from "@/components/state-provider"; +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { gitlabStatusQueryOptions } from "@/lib/query/query-options/gitlab"; +import type { GitLabStatus } from "@/lib/types/gitlab"; /** - * useGitLabStatus subscribes the slice to the latest GitLab connection status. - * Fetches on mount, retries are caller-driven via the returned `refresh`. - * - * Guards against an infinite re-fetch loop when GitLab is unreachable: a - * fetch failure leaves `status` null, so without a per-mount attempted flag - * the effect would re-run every render and hammer the backend. + * useGitLabStatus reads the shared GitLab connection status query. Fetches on + * mount; explicit retries are caller-driven through `refresh`. */ -export function useGitLabStatus() { - const status = useAppStore((state) => state.gitlabStatus.data); - const loading = useAppStore((state) => state.gitlabStatus.loading); - const loadedAt = useAppStore((state) => state.gitlabStatus.loadedAt); - const setStatus = useAppStore((state) => state.setGitLabStatus); - const setStatusLoading = useAppStore((state) => state.setGitLabStatusLoading); - const attemptedRef = useRef(false); +export function useGitLabStatus(initialStatus?: GitLabStatus | null) { + const query = useQuery({ + ...gitlabStatusQueryOptions(), + initialData: initialStatus ?? undefined, + }); + const refetch = query.refetch; - useEffect(() => { - if (loading || loadedAt !== null || attemptedRef.current) return; - attemptedRef.current = true; - setStatusLoading(true); - fetchGitLabStatus({ cache: "no-store" }) - .then((res) => setStatus(res ?? null)) - .catch(() => setStatus(null)) - .finally(() => setStatusLoading(false)); - }, [loading, loadedAt, setStatus, setStatusLoading]); + const refresh = useCallback(async () => { + await refetch(); + }, [refetch]); - const refresh = async () => { - setStatusLoading(true); - try { - const res = await fetchGitLabStatus({ cache: "no-store" }); - setStatus(res ?? null); - } catch { - setStatus(null); - } finally { - setStatusLoading(false); - } + return { + status: query.data ?? null, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + refresh, }; - - return { status, loading, refresh }; } diff --git a/apps/web/hooks/domains/gitlab/use-task-mr.test.ts b/apps/web/hooks/domains/gitlab/use-task-mr.test.ts index d7c5dee348..789041dcb3 100644 --- a/apps/web/hooks/domains/gitlab/use-task-mr.test.ts +++ b/apps/web/hooks/domains/gitlab/use-task-mr.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createElement, type ReactNode } from "react"; +import { createElement, type ReactNode, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; -import { StateProvider, useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; import type { GitLabStatus, TaskMR } from "@/lib/types/gitlab"; const fetchGitLabStatusMock = vi.fn<[], Promise>(); @@ -17,8 +18,15 @@ vi.mock("@/lib/api/domains/gitlab-api", () => ({ import { useGitLabAvailable, useTaskMRs, useWorkspaceMRs } from "./use-task-mr"; -function wrapper({ children }: { children: ReactNode }) { - return createElement(StateProvider, null, children); +function createQueryHarness() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, + }); + function wrapper({ children }: { children: ReactNode }) { + const [client] = useState(queryClient); + return createElement(QueryClientProvider, { client }, children); + } + return { queryClient, wrapper }; } afterEach(() => cleanup()); @@ -67,23 +75,52 @@ describe("useWorkspaceMRs", () => { listWorkspaceTaskMRsMock.mockReset(); }); - it("hydrates the store with the workspace's task MRs", async () => { + it("returns the workspace task MRs and seeds per-task query caches", async () => { + const { wrapper } = createQueryHarness(); const mr = makeMR({ task_id: "task-1" }); listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: { "task-1": [mr] } }); const { result } = renderHook( () => { - useWorkspaceMRs("ws-1"); - return useAppStore((s) => s.taskMRs.byTaskId); + const mrsByTask = useWorkspaceMRs("ws-1"); + const taskMrs = useTaskMRs("task-1"); + return { mrsByTask, taskMrs }; }, { wrapper }, ); - await waitFor(() => expect(result.current["task-1"]).toEqual([mr])); + await waitFor(() => expect(result.current.mrsByTask["task-1"]).toEqual([mr])); + await waitFor(() => expect(result.current.taskMrs).toEqual([mr])); expect(listWorkspaceTaskMRsMock).toHaveBeenCalledWith("ws-1"); }); + it("clears per-task caches for MRs missing from the latest workspace result", async () => { + const { queryClient, wrapper } = createQueryHarness(); + const mr = makeMR({ task_id: "task-1" }); + listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: { "task-1": [mr] } }); + + const { result } = renderHook( + () => { + const mrsByTask = useWorkspaceMRs("ws-1"); + const taskMrs = useTaskMRs("task-1"); + return { mrsByTask, taskMrs }; + }, + { wrapper }, + ); + + await waitFor(() => expect(result.current.taskMrs).toEqual([mr])); + + act(() => { + queryClient.setQueryData(qk.integrations.gitlab.mrs("ws-1"), { task_mrs: {} }); + }); + + await waitFor(() => expect(result.current.mrsByTask).toEqual({})); + await waitFor(() => expect(result.current.taskMrs).toEqual([])); + expect(queryClient.getQueryData(qk.integrations.gitlab.taskMr("task-1"))).toEqual([]); + }); + it("does not refetch when the workspace id stays the same", async () => { + const { wrapper } = createQueryHarness(); listWorkspaceTaskMRsMock.mockResolvedValue({ task_mrs: {} }); const { rerender } = renderHook(({ ws }: { ws: string | null }) => useWorkspaceMRs(ws), { wrapper, @@ -96,9 +133,8 @@ describe("useWorkspaceMRs", () => { expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1); }); - it("clears MRs and invalidates in-flight requests when workspace becomes null", async () => { - // First fetch is slow — we will switch to null before it resolves to - // verify the in-flight result is dropped by the request-id guard. + it("does not expose stale MRs when workspace becomes null", async () => { + const { wrapper } = createQueryHarness(); let resolveFirst: (v: { task_mrs: Record }) => void = () => {}; const firstPromise = new Promise<{ task_mrs: Record }>((res) => { resolveFirst = res; @@ -106,33 +142,22 @@ describe("useWorkspaceMRs", () => { listWorkspaceTaskMRsMock.mockReturnValueOnce(firstPromise); const { result, rerender } = renderHook( - ({ ws }: { ws: string | null }) => { - useWorkspaceMRs(ws); - return useAppStore((s) => s.taskMRs.byTaskId); - }, + ({ ws }: { ws: string | null }) => useWorkspaceMRs(ws), { wrapper, initialProps: { ws: "ws-1" as string | null } }, ); - // Pre-populate the store so we can observe it being cleared. - const setInitial = renderHook(() => useAppStore((s) => s.setTaskMRs), { wrapper }); - act(() => { - setInitial.result.current({ "task-1": [makeMR()] }); - }); - rerender({ ws: null }); - await waitFor(() => expect(result.current).toEqual({})); + expect(result.current).toEqual({}); - // The first fetch resolves *after* the null switch — its data must - // NOT land in the store. - const mr = makeMR({ task_id: "task-1" }); await act(async () => { - resolveFirst({ task_mrs: { "task-1": [mr] } }); + resolveFirst({ task_mrs: { "task-1": [makeMR({ task_id: "task-1" })] } }); }); await new Promise((r) => setTimeout(r, 10)); expect(result.current).toEqual({}); }); - it("clears fetchedRef on failure so a workspace switch can retry that id later", async () => { + it("fetches again when switching away from and back to a workspace after failure", async () => { + const { wrapper } = createQueryHarness(); listWorkspaceTaskMRsMock.mockRejectedValueOnce(new Error("boom")); const { rerender } = renderHook(({ ws }: { ws: string | null }) => useWorkspaceMRs(ws), { wrapper, @@ -140,9 +165,6 @@ describe("useWorkspaceMRs", () => { }); await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(1)); - // Bounce through another workspace and back to ws-1. Without the - // failure-path reset of fetchedRef, the second visit to ws-1 would - // be a no-op because the hook would still think the fetch succeeded. listWorkspaceTaskMRsMock.mockResolvedValueOnce({ task_mrs: {} }); rerender({ ws: "ws-2" }); await waitFor(() => expect(listWorkspaceTaskMRsMock).toHaveBeenCalledTimes(2)); @@ -155,29 +177,24 @@ describe("useWorkspaceMRs", () => { describe("useTaskMRs", () => { it("returns the same array reference across renders when empty", () => { + const { wrapper } = createQueryHarness(); const { result, rerender } = renderHook(() => useTaskMRs("task-empty"), { wrapper }); const first = result.current; rerender(); rerender(); - // If we returned `[]` literal each call, this would fail and the - // zustand selector would loop forever. expect(result.current).toBe(first); }); - it("reads the task's MRs from the store", async () => { + it("reads the task's MRs from the query cache", async () => { + const { queryClient, wrapper } = createQueryHarness(); const mr = makeMR({ task_id: "task-1" }); - const { result } = renderHook( - () => { - const setTaskMRs = useAppStore((s) => s.setTaskMRs); - const mrs = useTaskMRs("task-1"); - return { setTaskMRs, mrs }; - }, - { wrapper }, - ); + + const { result } = renderHook(() => useTaskMRs("task-1"), { wrapper }); act(() => { - result.current.setTaskMRs({ "task-1": [mr] }); + queryClient.setQueryData(qk.integrations.gitlab.taskMr("task-1"), [mr]); }); - expect(result.current.mrs).toEqual([mr]); + + await waitFor(() => expect(result.current).toEqual([mr])); }); }); @@ -187,14 +204,14 @@ describe("useGitLabAvailable", () => { }); it("returns true when GitLab is authenticated", async () => { + const { wrapper } = createQueryHarness(); fetchGitLabStatusMock.mockResolvedValue(makeStatus({ authenticated: true })); const { result } = renderHook(() => useGitLabAvailable(), { wrapper }); await waitFor(() => expect(result.current).toBe(true)); }); it("returns true when a token is configured but probe says unauthenticated", async () => { - // token_configured is a softer signal — the integration is set up but - // the probe might be stale. We want the integration to appear in menus. + const { wrapper } = createQueryHarness(); fetchGitLabStatusMock.mockResolvedValue( makeStatus({ authenticated: false, token_configured: true }), ); @@ -203,16 +220,17 @@ describe("useGitLabAvailable", () => { }); it("returns false when neither flag is set", async () => { + const { wrapper } = createQueryHarness(); fetchGitLabStatusMock.mockResolvedValue( makeStatus({ authenticated: false, token_configured: false }), ); const { result } = renderHook(() => useGitLabAvailable(), { wrapper }); - // The probe runs once on mount — give it a tick to land. await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalled()); expect(result.current).toBe(false); }); it("returns false when the probe rejects (offline / no client)", async () => { + const { wrapper } = createQueryHarness(); fetchGitLabStatusMock.mockRejectedValue(new Error("network down")); const { result } = renderHook(() => useGitLabAvailable(), { wrapper }); await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalled()); @@ -220,8 +238,7 @@ describe("useGitLabAvailable", () => { }); it("does not re-probe when the window regains focus", async () => { - // Regression guard: previously the hook re-fetched on every focus event, - // which hammered GET /api/v1/gitlab/status on every browser tab switch. + const { wrapper } = createQueryHarness(); fetchGitLabStatusMock.mockResolvedValue(makeStatus()); renderHook(() => useGitLabAvailable(), { wrapper }); await waitFor(() => expect(fetchGitLabStatusMock).toHaveBeenCalledTimes(1)); diff --git a/apps/web/hooks/domains/gitlab/use-task-mr.ts b/apps/web/hooks/domains/gitlab/use-task-mr.ts index 4055bba3ae..624efea04e 100644 --- a/apps/web/hooks/domains/gitlab/use-task-mr.ts +++ b/apps/web/hooks/domains/gitlab/use-task-mr.ts @@ -1,67 +1,62 @@ "use client"; import { useEffect, useRef } from "react"; -import { listWorkspaceTaskMRs } from "@/lib/api/domains/gitlab-api"; -import { useAppStore } from "@/components/state-provider"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import { + taskMrsQueryOptions, + workspaceTaskMrsQueryOptions, +} from "@/lib/query/query-options/gitlab"; import type { TaskMR } from "@/lib/types/gitlab"; import { useGitLabStatus } from "./use-gitlab-status"; /** - * Hydrate the gitlab task-MRs slice for a workspace. Fetches once per - * workspaceId switch and clears the cache on null. Mirrors useWorkspacePRs - * for GitHub but stays minimal (no WS subscription yet — that lands with - * the poller in a follow-up phase). + * Fetch all MR associations for a workspace and seed per-task MR query caches. */ export function useWorkspaceMRs(workspaceId: string | null) { - const setTaskMRs = useAppStore((state) => state.setTaskMRs); - const resetTaskMRs = useAppStore((state) => state.resetTaskMRs); - const fetchedRef = useRef(null); - const requestRef = useRef(0); + const queryClient = useQueryClient(); + const taskIdsRef = useRef<{ workspaceId: string | null; taskIds: Set }>({ + workspaceId: null, + taskIds: new Set(), + }); + const query = useQuery({ + ...workspaceTaskMrsQueryOptions(workspaceId ?? ""), + enabled: Boolean(workspaceId), + }); useEffect(() => { - if (!workspaceId) { - // Invalidate any in-flight request and clear the cached MRs so a - // workspace switch / sign-out doesn't leave the previous workspace's - // MRs visible until the next fetch. - requestRef.current += 1; - fetchedRef.current = null; - resetTaskMRs(); - return; + if (!workspaceId || !query.data) return; + const mrsByTask = query.data.task_mrs ?? {}; + const nextTaskIds = new Set(Object.keys(mrsByTask)); + const previousTaskIds = + taskIdsRef.current.workspaceId === workspaceId + ? taskIdsRef.current.taskIds + : new Set(); + for (const taskId of previousTaskIds) { + if (!nextTaskIds.has(taskId)) { + queryClient.setQueryData(qk.integrations.gitlab.taskMr(taskId), []); + } } - if (fetchedRef.current === workspaceId) return; - const requestId = ++requestRef.current; - fetchedRef.current = workspaceId; - listWorkspaceTaskMRs(workspaceId, { cache: "no-store" }) - .then((response) => { - if (requestRef.current !== requestId) return; - setTaskMRs(response?.task_mrs ?? {}); - }) - .catch(() => { - if (requestRef.current === requestId) { - fetchedRef.current = null; // allow retry on failure - } - }); - }, [workspaceId, setTaskMRs, resetTaskMRs]); + for (const [taskId, mrs] of Object.entries(mrsByTask)) { + queryClient.setQueryData(qk.integrations.gitlab.taskMr(taskId), mrs); + } + taskIdsRef.current = { workspaceId, taskIds: nextTaskIds }; + }, [query.data, queryClient, workspaceId]); + + return query.data?.task_mrs ?? {}; } -// Stable empty array so the zustand selector output stays referentially -// equal across renders when a task has no MRs. Returning a fresh [] each -// call triggers an infinite re-render loop. const EMPTY_MRS: TaskMR[] = []; -/** Return MRs linked to a task. Reads directly from the store. */ +/** Return MRs linked to a task. */ export function useTaskMRs(taskId: string | null): TaskMR[] { - return useAppStore((state) => - taskId ? (state.taskMRs.byTaskId[taskId] ?? EMPTY_MRS) : EMPTY_MRS, - ); + const query = useQuery(taskMrsQueryOptions(taskId ?? "")); + return Array.isArray(query.data) ? query.data : EMPTY_MRS; } /** * Returns whether GitLab is configured enough to surface in the integrations - * menu. Token-configured or authenticated counts as "available" — same bar - * as useGitHubStatus's `ready` flag. Backed by the store-cached - * useGitLabStatus hook, so multiple consumers share a single fetch and the - * status doesn't re-probe on every window focus. + * menu. Token-configured or authenticated counts as "available". */ export function useGitLabAvailable(): boolean { const { status } = useGitLabStatus(); diff --git a/apps/web/hooks/domains/integrations/use-integration-availability.test.ts b/apps/web/hooks/domains/integrations/use-integration-availability.test.ts index 579403ac10..106e41c8ec 100644 --- a/apps/web/hooks/domains/integrations/use-integration-availability.test.ts +++ b/apps/web/hooks/domains/integrations/use-integration-availability.test.ts @@ -1,4 +1,6 @@ import { act, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useIntegrationAuthed, type IntegrationConfigStatus } from "./use-integration-availability"; @@ -14,6 +16,15 @@ function deferred() { return { promise, resolve }; } +function wrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function TestWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + describe("useIntegrationAuthed", () => { it("reports authed once a healthy config resolves", async () => { const fetchConfig = vi.fn( @@ -23,7 +34,10 @@ describe("useIntegrationAuthed", () => { }), ); - const { result } = renderHook(() => useIntegrationAuthed(fetchConfig)); + const { result } = renderHook( + () => useIntegrationAuthed({ fetchConfig, queryKey: ["integration", "healthy"] }), + { wrapper: wrapper() }, + ); await waitFor(() => expect(result.current).toBe(true)); }); @@ -38,8 +52,17 @@ describe("useIntegrationAuthed", () => { ); const { result, rerender } = renderHook( - ({ fetchConfig }) => useIntegrationAuthed(fetchConfig), - { initialProps: { fetchConfig: first } }, + ({ + fetchConfig, + queryKey, + }: { + fetchConfig: () => Promise; + queryKey: readonly unknown[]; + }) => useIntegrationAuthed({ fetchConfig, queryKey }), + { + initialProps: { fetchConfig: first, queryKey: ["integration", "first"] }, + wrapper: wrapper(), + }, ); await waitFor(() => expect(result.current).toBe(true)); @@ -50,7 +73,7 @@ describe("useIntegrationAuthed", () => { const pending = deferred(); const second = vi.fn(() => pending.promise); - rerender({ fetchConfig: second }); + rerender({ fetchConfig: second, queryKey: ["integration", "second"] }); expect(result.current).toBe(false); @@ -72,8 +95,9 @@ describe("useIntegrationAuthed", () => { ); const { result, rerender } = renderHook( - ({ active }) => useIntegrationAuthed(fetchConfig, undefined, active), - { initialProps: { active: true } }, + ({ active }) => + useIntegrationAuthed({ active, fetchConfig, queryKey: ["integration", "inactive"] }), + { initialProps: { active: true }, wrapper: wrapper() }, ); await waitFor(() => expect(result.current).toBe(true)); diff --git a/apps/web/hooks/domains/integrations/use-integration-availability.ts b/apps/web/hooks/domains/integrations/use-integration-availability.ts index 950564fd17..f24e398c3b 100644 --- a/apps/web/hooks/domains/integrations/use-integration-availability.ts +++ b/apps/web/hooks/domains/integrations/use-integration-availability.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; // The backend poller probes credentials roughly every 90s. Refreshing at the // same cadence keeps the UI no more than ~one cycle stale. @@ -13,60 +13,42 @@ export type IntegrationConfigStatus = { lastOk?: boolean; }; +export type IntegrationAuthOptions = { + active?: boolean; + fetchConfig: () => Promise; + queryKey: readonly unknown[]; + refreshMs?: number; +}; + // Reads the backend-recorded auth health for the install-wide integration. // Returns true only when a config exists, has a secret, and the most recent // probe succeeded. Pass `active=false` to skip fetching entirely (e.g. while // the user toggle is off) — this avoids the polling overhead on disabled // integrations. -export function useIntegrationAuthed( - fetchConfig: () => Promise, - refreshMs: number = INTEGRATION_STATUS_REFRESH_MS, - active: boolean = true, -): boolean { - const [authed, setAuthed] = useState(false); - useEffect(() => { - if (!active) { - setAuthed(false); - return; - } - // Drop any auth state carried over from a previous `fetchConfig` before the - // new probe resolves. `fetchConfig` is keyed by workspace for per-workspace - // integrations, so a workspace switch must not keep showing the previous - // workspace's "authed" result during the in-flight recheck. - setAuthed(false); - let cancelled = false; - // Monotonic request id: if a slow earlier probe finishes after a newer - // one we ignore it, otherwise an old "auth ok" could clobber a fresh - // "auth failed" (or vice versa) and the UI would flap until the next - // tick. - let requestId = 0; - async function refresh() { - const current = ++requestId; - try { - const cfg = await fetchConfig(); - if (cancelled || current !== requestId) return; - setAuthed(!!cfg?.hasSecret && !!cfg.lastOk); - } catch { - if (cancelled || current !== requestId) return; - setAuthed(false); - } - } - void refresh(); - const id = setInterval(() => void refresh(), refreshMs); - return () => { - cancelled = true; - clearInterval(id); - }; - }, [active, fetchConfig, refreshMs]); - return authed; +export function useIntegrationAuthed({ + active = true, + fetchConfig, + queryKey, + refreshMs = INTEGRATION_STATUS_REFRESH_MS, +}: IntegrationAuthOptions): boolean { + const query = useQuery({ + queryKey, + queryFn: fetchConfig, + enabled: active, + refetchInterval: active ? refreshMs : false, + retry: false, + }); + return active && !!query.data?.hasSecret && !!query.data.lastOk; } export type IntegrationAvailabilityOptions = { + active?: boolean; // Install-wide enabled toggle that has settled. `loaded` gates the // probe so we don't waste a fetch on the first render when the toggle is // off. useEnabled: () => { enabled: boolean; loaded: boolean }; fetchConfig: () => Promise; + queryKey: readonly unknown[]; refreshMs?: number; }; @@ -75,12 +57,14 @@ export type IntegrationAvailabilityOptions = { // off (or hasn't loaded yet) the auth probe is skipped — disabled // integrations don't poll the backend. export function useIntegrationAvailable({ + active: activeOverride = true, useEnabled, fetchConfig, + queryKey, refreshMs, }: IntegrationAvailabilityOptions): boolean { const { enabled, loaded } = useEnabled(); - const active = loaded && enabled; - const authed = useIntegrationAuthed(fetchConfig, refreshMs, active); + const active = activeOverride && loaded && enabled; + const authed = useIntegrationAuthed({ active, fetchConfig, queryKey, refreshMs }); return active && authed; } diff --git a/apps/web/hooks/domains/jira/use-jira-availability.test.ts b/apps/web/hooks/domains/jira/use-jira-availability.test.ts index 72520461be..bc18a7b4ed 100644 --- a/apps/web/hooks/domains/jira/use-jira-availability.test.ts +++ b/apps/web/hooks/domains/jira/use-jira-availability.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createElement, type ReactNode, useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import type { JiraConfig } from "@/lib/types/jira"; @@ -10,6 +12,16 @@ vi.mock("@/lib/api/domains/jira-api", () => ({ import { useJiraAvailable } from "./use-jira-availability"; +function wrapper({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } }, + }), + ); + return createElement(QueryClientProvider, { client: queryClient }, children); +} + function makeLocalStorageMock() { const store = new Map(); return { @@ -58,14 +70,14 @@ describe("useJiraAvailable", () => { it("returns true when enabled, configured, and auth is healthy", async () => { getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: true, lastOk: true })); - const { result } = renderHook(() => useJiraAvailable()); + const { result } = renderHook(() => useJiraAvailable(), { wrapper }); await waitFor(() => expect(result.current).toBe(true)); }); it("returns false when the user toggle is disabled", async () => { window.localStorage.setItem("kandev:jira:enabled:v1", "false"); getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: true, lastOk: true })); - const { result } = renderHook(() => useJiraAvailable()); + const { result } = renderHook(() => useJiraAvailable(), { wrapper }); // The toggle is install-wide now; an off-toggle keeps `enabled` at false // while the auth probe still runs in the background. await waitFor(() => expect(result.current).toBe(false)); @@ -73,7 +85,7 @@ describe("useJiraAvailable", () => { it("returns false when no secret is configured", async () => { getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: false, lastOk: true })); - const { result } = renderHook(() => useJiraAvailable()); + const { result } = renderHook(() => useJiraAvailable(), { wrapper }); await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); expect(result.current).toBe(false); }); @@ -82,14 +94,14 @@ describe("useJiraAvailable", () => { getJiraConfigMock.mockResolvedValue( makeConfig({ hasSecret: true, lastOk: false, lastError: "401 Unauthorized" }), ); - const { result } = renderHook(() => useJiraAvailable()); + const { result } = renderHook(() => useJiraAvailable(), { wrapper }); await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); expect(result.current).toBe(false); }); it("returns false when the config request rejects", async () => { getJiraConfigMock.mockRejectedValue(new Error("network down")); - const { result } = renderHook(() => useJiraAvailable()); + const { result } = renderHook(() => useJiraAvailable(), { wrapper }); await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); expect(result.current).toBe(false); }); @@ -99,11 +111,14 @@ describe("useJiraAvailable", () => { try { getJiraConfigMock.mockResolvedValue(makeConfig({ hasSecret: true, lastOk: true })); const seen: boolean[] = []; - const { result } = renderHook(() => { - const v = useJiraAvailable(); - seen.push(v); - return v; - }); + const { result } = renderHook( + () => { + const v = useJiraAvailable(); + seen.push(v); + return v; + }, + { wrapper }, + ); // Wait for the first probe to resolve and flip the value to true. await vi.waitFor(() => expect(result.current).toBe(true)); const beforeTick = [...seen]; @@ -121,7 +136,7 @@ describe("useJiraAvailable", () => { it("returns false when no config exists yet (backend 204)", async () => { getJiraConfigMock.mockResolvedValue(null); - const { result } = renderHook(() => useJiraAvailable()); + const { result } = renderHook(() => useJiraAvailable(), { wrapper }); await waitFor(() => expect(getJiraConfigMock).toHaveBeenCalled()); expect(result.current).toBe(false); }); diff --git a/apps/web/hooks/domains/jira/use-jira-availability.ts b/apps/web/hooks/domains/jira/use-jira-availability.ts index 564842bf31..5d55c084f4 100644 --- a/apps/web/hooks/domains/jira/use-jira-availability.ts +++ b/apps/web/hooks/domains/jira/use-jira-availability.ts @@ -6,6 +6,7 @@ import { useIntegrationAuthed, useIntegrationAvailable, } from "../integrations/use-integration-availability"; +import { qk } from "@/lib/query/keys"; import { useJiraEnabled } from "./use-jira-enabled"; export function useJiraAuthed(workspaceId?: string | null): boolean { @@ -13,7 +14,11 @@ export function useJiraAuthed(workspaceId?: string | null): boolean { () => getJiraConfig(workspaceId ? { workspaceId } : undefined), [workspaceId], ); - return useIntegrationAuthed(fetchConfig); + return useIntegrationAuthed({ + active: workspaceId !== null, + fetchConfig, + queryKey: qk.integrations.jira.config(workspaceId), + }); } export function useJiraAvailable(workspaceId?: string | null): boolean { @@ -22,7 +27,9 @@ export function useJiraAvailable(workspaceId?: string | null): boolean { [workspaceId], ); return useIntegrationAvailable({ + active: workspaceId !== null, useEnabled: useJiraEnabled, fetchConfig, + queryKey: qk.integrations.jira.config(workspaceId), }); } diff --git a/apps/web/hooks/domains/jira/use-jira-issue-watches.ts b/apps/web/hooks/domains/jira/use-jira-issue-watches.ts index cc7b254002..289fc7386d 100644 --- a/apps/web/hooks/domains/jira/use-jira-issue-watches.ts +++ b/apps/web/hooks/domains/jira/use-jira-issue-watches.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useCallback, useRef } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - listJiraIssueWatches, createJiraIssueWatch, updateJiraIssueWatch, deleteJiraIssueWatch, @@ -10,8 +10,13 @@ import { previewResetJiraIssueWatch, resetJiraIssueWatch, } from "@/lib/api/domains/jira-api"; -import { useAppStore } from "@/components/state-provider"; -import type { CreateJiraIssueWatchInput, UpdateJiraIssueWatchInput } from "@/lib/types/jira"; +import { qk } from "@/lib/query/keys"; +import { jiraIssueWatchesQueryOptions } from "@/lib/query/query-options/jira"; +import type { + CreateJiraIssueWatchInput, + JiraIssueWatch, + UpdateJiraIssueWatchInput, +} from "@/lib/types/jira"; // WORKSPACE_REQUIRED is thrown by per-row mutation callbacks when the // install-wide listing case forgets to forward the row's workspaceId @@ -31,46 +36,17 @@ const WORKSPACE_REQUIRED = "workspaceId required"; * workspace's stale rows during the swap. */ export function useJiraIssueWatches(workspaceId?: string | null) { - const items = useAppStore((s) => s.jiraIssueWatches.items); - const loaded = useAppStore((s) => s.jiraIssueWatches.loaded); - const loading = useAppStore((s) => s.jiraIssueWatches.loading); - const setWatches = useAppStore((s) => s.setJiraIssueWatches); - const resetWatches = useAppStore((s) => s.resetJiraIssueWatches); - const setLoading = useAppStore((s) => s.setJiraIssueWatchesLoading); - const addWatch = useAppStore((s) => s.addJiraIssueWatch); - const updateWatch = useAppStore((s) => s.updateJiraIssueWatch); - const removeWatch = useAppStore((s) => s.removeJiraIssueWatch); - - const lastScope = useRef(undefined); - const scope: string | null = workspaceId ?? null; - - useEffect(() => { - if (workspaceId === null) return; - // Scope changed (workspace switch or all↔scoped flip) — invalidate so the - // fetch effect re-runs. setWatches([]) would keep loaded=true; resetWatches - // clears loaded so the fetch isn't short-circuited by the stale guard. - if (lastScope.current !== undefined && lastScope.current !== scope) { - resetWatches(); - } - lastScope.current = scope; - }, [workspaceId, scope, resetWatches]); - - useEffect(() => { - if (workspaceId === null || loaded || loading) return; - setLoading(true); - listJiraIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) - .then((res) => setWatches(res ?? [])) - .catch(() => setWatches([])) - .finally(() => setLoading(false)); - }, [workspaceId, loaded, loading, setWatches, setLoading]); + const queryClient = useQueryClient(); + const query = useQuery(jiraIssueWatchesQueryOptions(workspaceId)); + const items = query.data ?? []; const create = useCallback( async (req: CreateJiraIssueWatchInput) => { const watch = await createJiraIssueWatch(req); - addWatch(watch); + patchJiraIssueWatchCaches(queryClient, workspaceId, watch); return watch; }, - [addWatch], + [queryClient, workspaceId], ); // Per-row mutations require the row's own workspace_id to satisfy the @@ -81,10 +57,10 @@ export function useJiraIssueWatches(workspaceId?: string | null) { const ws = rowWorkspaceId ?? workspaceId; if (!ws) throw new Error(WORKSPACE_REQUIRED); const watch = await updateJiraIssueWatch(ws, id, req); - updateWatch(watch); + patchJiraIssueWatchCaches(queryClient, workspaceId, watch); return watch; }, - [workspaceId, updateWatch], + [queryClient, workspaceId], ); const remove = useCallback( @@ -92,9 +68,9 @@ export function useJiraIssueWatches(workspaceId?: string | null) { const ws = rowWorkspaceId ?? workspaceId; if (!ws) throw new Error(WORKSPACE_REQUIRED); await deleteJiraIssueWatch(ws, id); - removeWatch(id); + removeJiraIssueWatchFromCaches(queryClient, workspaceId, id); }, - [workspaceId, removeWatch], + [queryClient, workspaceId], ); const trigger = useCallback( @@ -123,13 +99,57 @@ export function useJiraIssueWatches(workspaceId?: string | null) { const ws = rowWorkspaceId ?? workspaceId; if (!ws) throw new Error(WORKSPACE_REQUIRED); const res = await resetJiraIssueWatch(ws, id); - // Wipe the dedup row cache so the next list/refresh doesn't surface - // stale state — the watch is now in "freshly created" mode. - resetWatches(); + queryClient.invalidateQueries({ queryKey: qk.integrations.jira.issueWatches(workspaceId) }); + queryClient.invalidateQueries({ queryKey: qk.integrations.jira.issueWatches(undefined) }); + queryClient.invalidateQueries({ queryKey: qk.integrations.jira.issueWatches(ws) }); return res; }, - [workspaceId, resetWatches], + [queryClient, workspaceId], ); - return { items, loaded, loading, create, update, remove, trigger, previewReset, reset }; + return { + items, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + create, + update, + remove, + trigger, + previewReset, + reset, + }; +} + +function patchJiraIssueWatchCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + watch: JiraIssueWatch, +) { + const patch = (prev: JiraIssueWatch[] | undefined) => upsertById(prev ?? [], watch); + const patchExisting = (prev: JiraIssueWatch[] | undefined) => + prev ? upsertById(prev, watch) : prev; + queryClient.setQueryData(qk.integrations.jira.issueWatches(workspaceId), patch); + queryClient.setQueryData(qk.integrations.jira.issueWatches(undefined), patchExisting); + queryClient.setQueryData(qk.integrations.jira.issueWatches(watch.workspaceId), patch); +} + +function removeJiraIssueWatchFromCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + id: string, +) { + const remove = (prev: JiraIssueWatch[] | undefined) => + (prev ?? []).filter((watch) => watch.id !== id); + const removeExisting = (prev: JiraIssueWatch[] | undefined) => + prev ? prev.filter((watch) => watch.id !== id) : prev; + queryClient.setQueryData(qk.integrations.jira.issueWatches(workspaceId), remove); + queryClient.setQueryData(qk.integrations.jira.issueWatches(undefined), removeExisting); +} + +function upsertById(items: T[], next: T): T[] { + const index = items.findIndex((item) => item.id === next.id); + if (index === -1) return [...items, next]; + const copy = [...items]; + copy[index] = next; + return copy; } diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots-inflight.test.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots-inflight.test.ts deleted file mode 100644 index 50736bf2d2..0000000000 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots-inflight.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; - -type Workflow = { id: string; workspaceId: string; name: string }; -type SnapshotTask = { - id: string; - workflowStepId: string; - title: string; - position: number; - state: "IN_PROGRESS"; - parentTaskId?: string; -}; -type MockState = { - connection: { status: string }; - workflows: { items: Workflow[] }; - kanbanMulti: { - snapshots: Record< - string, - { - workflowId: string; - workflowName: string; - steps: []; - tasks: SnapshotTask[]; - isPlaceholder?: boolean; - } - >; - isLoading: boolean; - }; - clearKanbanMulti: ReturnType; - setKanbanMultiLoading: ReturnType; - setWorkflowSnapshot: ReturnType; -}; - -const WORKFLOW_ID = "wf-A"; -const WORKSPACE_ID = "ws-A"; -const STEP_ID = "step-1"; -const PARENT_TASK_ID = "parent-task"; - -const mocks = vi.hoisted(() => ({ - clearKanbanMulti: vi.fn(), - fetchWorkflowSnapshot: vi.fn(), - setKanbanMultiLoading: vi.fn(), - setWorkflowSnapshot: vi.fn(), - state: undefined as MockState | undefined, -})); - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (state: MockState) => unknown) => selector(mocks.state!), - useAppStoreApi: () => ({ getState: () => mocks.state! }), -})); - -vi.mock("@/lib/api", () => ({ - fetchWorkflowSnapshot: (...args: unknown[]) => mocks.fetchWorkflowSnapshot(...args), -})); - -import { useAllWorkflowSnapshots } from "./use-all-workflow-snapshots"; - -function resetState() { - vi.clearAllMocks(); - mocks.state = { - connection: { status: "connected" }, - workflows: { items: [{ id: WORKFLOW_ID, workspaceId: WORKSPACE_ID, name: "A" }] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - clearKanbanMulti: mocks.clearKanbanMulti, - setKanbanMultiLoading: mocks.setKanbanMultiLoading, - setWorkflowSnapshot: mocks.setWorkflowSnapshot, - }; -} - -function staleSnapshotResponse() { - return { - steps: [{ id: STEP_ID, name: "Doing", color: null, position: 0 }], - tasks: [], - }; -} - -function setLightweightSnapshot(task: SnapshotTask) { - mocks.state!.kanbanMulti.snapshots[WORKFLOW_ID] = { - workflowId: WORKFLOW_ID, - workflowName: "A", - steps: [], - tasks: [task], - isPlaceholder: true, - }; -} - -describe("useAllWorkflowSnapshots lightweight snapshots", () => { - it("does not treat a lightweight websocket snapshot as boot-hydrated", async () => { - resetState(); - mocks.fetchWorkflowSnapshot.mockResolvedValueOnce(staleSnapshotResponse()); - setLightweightSnapshot({ - id: "child-before-hydration", - workflowStepId: STEP_ID, - title: "Child before hydration", - position: 0, - state: "IN_PROGRESS", - }); - - renderHook(() => useAllWorkflowSnapshots(WORKSPACE_ID)); - - await waitFor(() => expect(mocks.fetchWorkflowSnapshot).toHaveBeenCalledTimes(1)); - }); - - it("preserves tasks already present in a lightweight snapshot before fetch starts", async () => { - resetState(); - mocks.fetchWorkflowSnapshot.mockResolvedValueOnce(staleSnapshotResponse()); - setLightweightSnapshot({ - id: "child-before-fetch", - workflowStepId: STEP_ID, - title: "Child before fetch", - position: 0, - state: "IN_PROGRESS", - parentTaskId: PARENT_TASK_ID, - }); - - renderHook(() => useAllWorkflowSnapshots(WORKSPACE_ID)); - - await waitFor(() => - expect(mocks.setWorkflowSnapshot).toHaveBeenCalledWith( - WORKFLOW_ID, - expect.objectContaining({ - tasks: [ - expect.objectContaining({ - id: "child-before-fetch", - parentTaskId: PARENT_TASK_ID, - }), - ], - }), - ), - ); - }); -}); - -describe("useAllWorkflowSnapshots in-flight websocket tasks", () => { - it("preserves tasks created while the workflow snapshot fetch is in flight", async () => { - resetState(); - let resolveFetch: (value: unknown) => void = () => {}; - mocks.fetchWorkflowSnapshot.mockReturnValueOnce( - new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - - renderHook(() => useAllWorkflowSnapshots(WORKSPACE_ID)); - await waitFor(() => - expect(mocks.fetchWorkflowSnapshot).toHaveBeenCalledWith(WORKFLOW_ID, expect.anything()), - ); - - setLightweightSnapshot({ - id: "child-created-during-fetch", - workflowStepId: STEP_ID, - title: "Child created during fetch", - position: 0, - state: "IN_PROGRESS", - parentTaskId: PARENT_TASK_ID, - }); - resolveFetch(staleSnapshotResponse()); - - await waitFor(() => - expect(mocks.setWorkflowSnapshot).toHaveBeenCalledWith( - WORKFLOW_ID, - expect.objectContaining({ - tasks: [ - expect.objectContaining({ - id: "child-created-during-fetch", - parentTaskId: PARENT_TASK_ID, - }), - ], - }), - ), - ); - }); -}); diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.query.test.tsx b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.query.test.tsx new file mode 100644 index 0000000000..d9565e4899 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.query.test.tsx @@ -0,0 +1,105 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import type { Workflow, WorkflowSnapshot } from "@/lib/types/http"; +import { useAllWorkflowSnapshots } from "./use-all-workflow-snapshots"; + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchWorkflowSnapshot: vi.fn(), +})); + +const WORKFLOW_ID = "workflow-1"; +const WORKSPACE_ID = "workspace-1"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function workflow(): Workflow { + return { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 0, + hidden: false, + } as Workflow; +} + +function snapshot(): WorkflowSnapshot { + return { + workflow: workflow(), + steps: [ + { + id: "step-1", + workflow_id: WORKFLOW_ID, + name: "Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks: [ + { + id: "task-1", + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: "step-1", + position: 0, + title: "Cached task", + description: "from query", + state: "TODO", + priority: 0, + repositories: [], + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + }, + ], + } as unknown as WorkflowSnapshot; +} + +describe("useAllWorkflowSnapshots query ownership", () => { + afterEach(() => { + cleanup(); + }); + + it("returns converted snapshots from Query cache without a Zustand store", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [workflow()]); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), snapshot()); + + const { result } = renderHook(() => useAllWorkflowSnapshots(WORKSPACE_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current).toMatchObject({ + isLoading: false, + snapshots: { + [WORKFLOW_ID]: { + workflowId: WORKFLOW_ID, + workflowName: "Build", + tasks: [ + expect.objectContaining({ + id: "task-1", + title: "Cached task", + }), + ], + }, + }, + }); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts index 3edebc347b..ded7788533 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.test.ts @@ -1,201 +1,147 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import type { Workflow, WorkflowSnapshot } from "@/lib/types/http"; +import { useAllWorkflowSnapshots } from "./use-all-workflow-snapshots"; -const mockClearKanbanMulti = vi.fn(); -const mockSetKanbanMultiLoading = vi.fn(); -const mockSetWorkflowSnapshot = vi.fn(); const mockFetchWorkflowSnapshot = vi.fn(); -type Workflow = { id: string; workspaceId: string; name: string }; -type MockState = { - connection: { status: string }; - workflows: { items: Workflow[] }; - kanbanMulti: { snapshots: Record; isLoading: boolean }; - clearKanbanMulti: typeof mockClearKanbanMulti; - setKanbanMultiLoading: typeof mockSetKanbanMultiLoading; - setWorkflowSnapshot: typeof mockSetWorkflowSnapshot; -}; - -let mockState: MockState = { - connection: { status: "connected" }, - workflows: { items: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - clearKanbanMulti: mockClearKanbanMulti, - setKanbanMultiLoading: mockSetKanbanMultiLoading, - setWorkflowSnapshot: mockSetWorkflowSnapshot, -}; - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (s: MockState) => unknown) => selector(mockState), - useAppStoreApi: () => ({ getState: () => mockState }), -})); - -vi.mock("@/lib/api", () => ({ +vi.mock("@/lib/api/domains/kanban-api", () => ({ fetchWorkflowSnapshot: (...args: unknown[]) => mockFetchWorkflowSnapshot(...args), })); -import { useAllWorkflowSnapshots } from "./use-all-workflow-snapshots"; - -function resetMocks(workflows: Workflow[] = []) { - vi.clearAllMocks(); - mockFetchWorkflowSnapshot.mockResolvedValue({ steps: [], tasks: [] }); - mockState = { - connection: { status: "connected" }, - workflows: { items: workflows }, - kanbanMulti: { snapshots: {}, isLoading: false }, - clearKanbanMulti: mockClearKanbanMulti, - setKanbanMultiLoading: mockSetKanbanMultiLoading, - setWorkflowSnapshot: mockSetWorkflowSnapshot, - }; +const WORKSPACE_A = "workspace-A"; +const WORKSPACE_B = "workspace-B"; + +function workflow(id: string, workspaceId: string, name = id): Workflow { + return { + id, + workspace_id: workspaceId, + name, + sort_order: 0, + hidden: false, + } as Workflow; } -describe("useAllWorkflowSnapshots — workspace scoping", () => { - beforeEach(() => { - resetMocks([{ id: "wf-A", workspaceId: "ws-A", name: "A" }]); - }); - - it("does not clear snapshots on initial mount (SSR preservation)", async () => { - renderHook( - ({ workspaceId }: { workspaceId: string | null }) => useAllWorkflowSnapshots(workspaceId), +function snapshot(workflowId: string, workspaceId: string, taskId = "task-1"): WorkflowSnapshot { + return { + workflow: workflow(workflowId, workspaceId), + steps: [ { - initialProps: { workspaceId: "ws-A" }, + id: `${workflowId}-step`, + workflow_id: workflowId, + name: "Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, }, - ); + ], + tasks: [ + { + id: taskId, + workspace_id: workspaceId, + workflow_id: workflowId, + workflow_step_id: `${workflowId}-step`, + position: 0, + title: taskId, + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + }, + ], + } as unknown as WorkflowSnapshot; +} - // Allow the effect + Promise.all to settle. - await waitFor(() => expect(mockSetKanbanMultiLoading).toHaveBeenCalledWith(true)); - expect(mockClearKanbanMulti).not.toHaveBeenCalled(); +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, }); +} - it("does not fetch on initial mount when all workflow snapshots are boot-hydrated", () => { - mockState.kanbanMulti.snapshots = { - "wf-A": { workflowId: "wf-A", workflowName: "A", steps: [], tasks: [] }, - }; - - renderHook( - ({ workspaceId }: { workspaceId: string | null }) => useAllWorkflowSnapshots(workspaceId), - { - initialProps: { workspaceId: "ws-A" }, - }, - ); +function wrapperFor(queryClient: QueryClient) { + return function QueryWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} - expect(mockFetchWorkflowSnapshot).not.toHaveBeenCalled(); - expect(mockSetKanbanMultiLoading).not.toHaveBeenCalledWith(true); - expect(mockClearKanbanMulti).not.toHaveBeenCalled(); +describe("useAllWorkflowSnapshots", () => { + beforeEach(() => { + mockFetchWorkflowSnapshot.mockReset(); }); - it("clears snapshots when workspaceId changes", async () => { - const { rerender } = renderHook( - ({ workspaceId }: { workspaceId: string | null }) => useAllWorkflowSnapshots(workspaceId), - { initialProps: { workspaceId: "ws-A" } }, - ); - await waitFor(() => expect(mockSetKanbanMultiLoading).toHaveBeenCalledWith(true)); - expect(mockClearKanbanMulti).not.toHaveBeenCalled(); + afterEach(() => { + cleanup(); + }); - // Switch to workspace B — must clear A's snapshots. - mockState.workflows = { items: [{ id: "wf-B", workspaceId: "ws-B", name: "B" }] }; - rerender({ workspaceId: "ws-B" }); + it("returns converted snapshots for workflows scoped to the active workspace", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_A, { includeHidden: true }), [ + workflow("wf-A", WORKSPACE_A, "Alpha"), + ]); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_B, { includeHidden: true }), [ + workflow("wf-B", WORKSPACE_B, "Beta"), + ]); + queryClient.setQueryData(qk.workflows.snapshot("wf-A"), snapshot("wf-A", WORKSPACE_A, "t-a")); + queryClient.setQueryData(qk.workflows.snapshot("wf-B"), snapshot("wf-B", WORKSPACE_B, "t-b")); + + const { result } = renderHook(() => useAllWorkflowSnapshots(WORKSPACE_A), { + wrapper: wrapperFor(queryClient), + }); - await waitFor(() => expect(mockClearKanbanMulti).toHaveBeenCalledTimes(1)); + expect(Object.keys(result.current.snapshots)).toEqual(["wf-A"]); + expect(result.current.snapshots["wf-A"]?.tasks[0]?.id).toBe("t-a"); + expect(result.current.isLoading).toBe(false); }); - it("skips refetch when workspace + workflow set is unchanged across renders", async () => { - const workflows = [{ id: "wf-A", workspaceId: "ws-A", name: "A" }]; - const { rerender } = renderHook( - ({ workspaceId }: { workspaceId: string | null }) => useAllWorkflowSnapshots(workspaceId), - { initialProps: { workspaceId: "ws-A" } }, + it("switches workspace scope without returning stale snapshots", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_A, { includeHidden: true }), [ + workflow("wf-A", WORKSPACE_A, "Alpha"), + ]); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_B, { includeHidden: true }), [ + workflow("wf-B", WORKSPACE_B, "Beta"), + ]); + queryClient.setQueryData(qk.workflows.snapshot("wf-A"), snapshot("wf-A", WORKSPACE_A, "t-a")); + queryClient.setQueryData(qk.workflows.snapshot("wf-B"), snapshot("wf-B", WORKSPACE_B, "t-b")); + + const { result, rerender } = renderHook( + ({ workspaceId }: { workspaceId: string }) => useAllWorkflowSnapshots(workspaceId), + { + initialProps: { workspaceId: WORKSPACE_A }, + wrapper: wrapperFor(queryClient), + }, ); - await waitFor(() => expect(mockFetchWorkflowSnapshot).toHaveBeenCalledTimes(1)); - - // Same-workflow rerender — dedup key unchanged, must not refetch. - mockState.workflows = { items: [...workflows] }; - rerender({ workspaceId: "ws-A" }); - - // Positive signal: follow up with a DIFFERENT workflow set, which must - // trigger a fetch. If dedup worked, the total is 2 (initial + this one). - // If dedup failed, the same-key rerender would have fired a fetch before - // this one, making the total 3. Waiting for count==2 proves both: - // the dedup rerender was skipped AND the next real change still fetches. - mockState.workflows = { items: [{ id: "wf-A2", workspaceId: "ws-A", name: "A2" }] }; - rerender({ workspaceId: "ws-A" }); - await waitFor(() => expect(mockFetchWorkflowSnapshot).toHaveBeenCalledTimes(2)); - expect(mockFetchWorkflowSnapshot.mock.calls[1][0]).toBe("wf-A2"); - expect(mockClearKanbanMulti).not.toHaveBeenCalled(); - }); -}); -describe("useAllWorkflowSnapshots — fetch guards", () => { - beforeEach(() => { - resetMocks([{ id: "wf-A", workspaceId: "ws-A", name: "A" }]); + expect(Object.keys(result.current.snapshots)).toEqual(["wf-A"]); + rerender({ workspaceId: WORKSPACE_B }); + expect(Object.keys(result.current.snapshots)).toEqual(["wf-B"]); }); - it("discards a stale in-flight fetch when workspace switches mid-fetch", async () => { - // Hold the first fetch open so it resolves after the workspace switch. - let resolveStale: (v: { steps: []; tasks: [] }) => void = () => {}; + it("reports loading only before the first snapshot is available", async () => { + let resolveSnapshot: (value: WorkflowSnapshot) => void = () => {}; mockFetchWorkflowSnapshot.mockImplementationOnce( () => - new Promise((res) => { - resolveStale = res; + new Promise((resolve) => { + resolveSnapshot = resolve; }), ); + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_A, { includeHidden: true }), [ + workflow("wf-A", WORKSPACE_A, "Alpha"), + ]); - const { rerender } = renderHook( - ({ workspaceId }: { workspaceId: string | null }) => useAllWorkflowSnapshots(workspaceId), - { initialProps: { workspaceId: "ws-A" } }, - ); - await waitFor(() => - expect(mockFetchWorkflowSnapshot).toHaveBeenCalledWith("wf-A", expect.anything()), - ); - - // Switch to workspace B before A's fetch resolves. Wait for B's fetch - // to settle (positive signal) so the new-gen effect is fully in place. - mockFetchWorkflowSnapshot.mockResolvedValueOnce({ steps: [], tasks: [] }); - mockState.workflows = { items: [{ id: "wf-B", workspaceId: "ws-B", name: "B" }] }; - rerender({ workspaceId: "ws-B" }); - await waitFor(() => - expect(mockSetWorkflowSnapshot).toHaveBeenCalledWith("wf-B", expect.anything()), - ); - - // Resolve A's stale fetch and drain the microtask queue so its .then - // and .finally callbacks run. Flushing microtasks is deterministic — - // unlike setTimeout, it doesn't depend on CI wall-clock speed. - resolveStale({ steps: [], tasks: [] }); - for (let i = 0; i < 5; i++) await Promise.resolve(); - - const writtenIds = mockSetWorkflowSnapshot.mock.calls.map((args) => args[0]); - expect(writtenIds).not.toContain("wf-A"); - }); -}); - -describe("useAllWorkflowSnapshots — snapshot mapping", () => { - beforeEach(() => { - resetMocks([{ id: "wf-A", workspaceId: "ws-A", name: "A" }]); - }); - - it("preserves workflow step WIP fields in snapshots", async () => { - mockFetchWorkflowSnapshot.mockResolvedValueOnce({ - steps: [ - { - id: "step-1", - name: "Review", - position: 1, - color: "bg-blue-500", - wip_limit: 2, - pull_from_step_id: "step-0", - }, - ], - tasks: [], + const { result } = renderHook(() => useAllWorkflowSnapshots(WORKSPACE_A), { + wrapper: wrapperFor(queryClient), }); - renderHook( - ({ workspaceId }: { workspaceId: string | null }) => useAllWorkflowSnapshots(workspaceId), - { initialProps: { workspaceId: "ws-A" } }, - ); - - await waitFor(() => expect(mockSetWorkflowSnapshot).toHaveBeenCalled()); - expect(mockSetWorkflowSnapshot.mock.calls[0][1].steps[0]).toMatchObject({ - wip_limit: 2, - pull_from_step_id: "step-0", - }); + expect(result.current).toEqual({ snapshots: {}, isLoading: true }); + resolveSnapshot(snapshot("wf-A", WORKSPACE_A, "t-a")); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.snapshots["wf-A"]?.tasks[0]?.id).toBe("t-a"); }); }); diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts index 08356f25c2..8e46011a7f 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts @@ -1,152 +1,41 @@ -import { useEffect, useRef, type MutableRefObject } from "react"; -import { fetchWorkflowSnapshot } from "@/lib/api"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { toKanbanTask } from "@/lib/kanban/map-task"; -import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; -import type { Task } from "@/lib/types/http"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; - -type KanbanTask = KanbanState["tasks"][number]; -type Workflow = { id: string; name: string }; - -function isBootHydratedSnapshot(snapshot: WorkflowSnapshotData | undefined): boolean { - return !!snapshot && snapshot.isPlaceholder !== true; -} - -async function fetchAndWriteSnapshot( - wf: Workflow, - store: StoreApi, - fetchGenRef: MutableRefObject, - myGen: number, -): Promise { - try { - const snapshotAtFetchStart = store.getState().kanbanMulti.snapshots[wf.id]; - const taskIdsAtFetchStart = new Set((snapshotAtFetchStart?.tasks ?? []).map((t) => t.id)); - const snapshot = await fetchWorkflowSnapshot(wf.id, { cache: "no-store" }); - if (fetchGenRef.current !== myGen) return; - - const steps = snapshot.steps.map((step) => ({ - id: step.id, - title: step.name, - color: step.color ?? "bg-neutral-400", - position: step.position, - events: step.events, - allow_manual_move: step.allow_manual_move, - prompt: step.prompt, - is_start_step: step.is_start_step, - show_in_command_panel: step.show_in_command_panel, - agent_profile_id: step.agent_profile_id, - wip_limit: step.wip_limit, - pull_from_step_id: step.pull_from_step_id ?? null, - stage_type: step.stage_type, - })); - const stepIds = new Set(steps.map((s) => s.id)); - - // Preserve runtime fields (e.g., primarySessionId) from existing snapshot - // tasks when the fresh API response omits them (backend uses omitempty). - const existingSnapshot = store.getState().kanbanMulti.snapshots[wf.id]; - const existingById = new Map((existingSnapshot?.tasks ?? []).map((t) => [t.id, t])); - - const tasks: KanbanTask[] = snapshot.tasks - .filter((task) => !task.is_ephemeral) - .map((task) => { - const mapped = mapSnapshotTask(task, stepIds); - if (!mapped) return null; - const existing = existingById.get(mapped.id); - if (existing) { - mapped.primarySessionId = mapped.primarySessionId || existing.primarySessionId; - mapped.primarySessionState = mapped.primarySessionState || existing.primarySessionState; - } - return mapped; - }) - .filter((t): t is KanbanTask => t !== null); - const snapshotTaskIds = new Set(tasks.map((t) => t.id)); - const preserveExistingPlaceholderTasks = snapshotAtFetchStart?.isPlaceholder === true; - const inFlightCreatedTasks = (existingSnapshot?.tasks ?? []).filter( - (task) => - (preserveExistingPlaceholderTasks || !taskIdsAtFetchStart.has(task.id)) && - !snapshotTaskIds.has(task.id) && - stepIds.has(task.workflowStepId), - ); - - store.getState().setWorkflowSnapshot(wf.id, { - workflowId: wf.id, - workflowName: wf.name, - steps, - tasks: [...tasks, ...inFlightCreatedTasks], - }); - } catch (err) { - console.error( - `[useAllWorkflowSnapshots] Failed to fetch snapshot for workflow "${wf.name}" (${wf.id}):`, - err, - ); - } -} - -function mapSnapshotTask(task: Task, stepIds: Set): KanbanTask | null { - if (!task.workflow_step_id || !stepIds.has(task.workflow_step_id)) return null; - return toKanbanTask(task); -} - -export function useAllWorkflowSnapshots(workspaceId: string | null) { - const store = useAppStoreApi(); - const connectionStatus = useAppStore((state) => state.connection.status); - const workflows = useAppStore((state) => state.workflows.items); - const lastFetchedRef = useRef(""); - const lastWorkspaceIdRef = useRef(null); - const fetchGenRef = useRef(0); - - useEffect(() => { - // Skip clear on initial mount to preserve SSR-hydrated snapshots. - if (lastWorkspaceIdRef.current !== workspaceId) { - if (lastWorkspaceIdRef.current !== null) { - store.getState().clearKanbanMulti(); - lastFetchedRef.current = ""; - fetchGenRef.current += 1; - } - lastWorkspaceIdRef.current = workspaceId; - } - - if (!workspaceId) { - return; - } - - const workspaceWorkflows = workflows.filter((w) => w.workspaceId === workspaceId); - if (workspaceWorkflows.length === 0) { - return; - } - - // Deduplicate: skip if same set of workflow IDs already fetched for this connection status - const key = - workspaceWorkflows - .map((w) => w.id) - .sort() - .join(",") + - ":" + - connectionStatus; - if (lastFetchedRef.current === key) { - return; +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import { useCachedWorkflows } from "@/hooks/use-workflow-cache"; +import { workflowSnapshotToKanbanData } from "@/lib/kanban/snapshot"; +import { workflowSnapshotQueryOptions } from "@/lib/query/query-options"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; + +export type AllWorkflowSnapshotsResult = { + snapshots: Record; + isLoading: boolean; +}; + +export function useAllWorkflowSnapshots(workspaceId: string | null): AllWorkflowSnapshotsResult { + const workflows = useCachedWorkflows(workspaceId); + const workspaceWorkflows = useMemo( + () => (workspaceId ? workflows : []), + [workflows, workspaceId], + ); + const queries = useQueries({ + queries: workspaceWorkflows.map((workflow) => ({ + ...workflowSnapshotQueryOptions(workflow.id), + meta: { workflowName: workflow.name }, + })), + }); + + const snapshots = useMemo>(() => { + const result: Record = {}; + for (let index = 0; index < workspaceWorkflows.length; index++) { + const workflow = workspaceWorkflows[index]; + const data = queries[index]?.data; + if (!workflow || !data) continue; + result[workflow.id] = workflowSnapshotToKanbanData(data, result[workflow.id]); } - if ( - lastFetchedRef.current === "" && - workspaceWorkflows.every((wf) => - isBootHydratedSnapshot(store.getState().kanbanMulti.snapshots[wf.id]), - ) - ) { - lastFetchedRef.current = key; - return; - } - lastFetchedRef.current = key; - - const myGen = fetchGenRef.current; - store.getState().setKanbanMultiLoading(true); + return result; + }, [queries, workspaceWorkflows]); - Promise.all( - workspaceWorkflows.map((wf) => fetchAndWriteSnapshot(wf, store, fetchGenRef, myGen)), - ).finally(() => { - if (fetchGenRef.current !== myGen) return; - store.getState().setKanbanMultiLoading(false); - }); - }, [workspaceId, workflows, connectionStatus, store]); + return { + snapshots, + isLoading: queries.some((query) => query.isFetching) && Object.keys(snapshots).length === 0, + }; } diff --git a/apps/web/hooks/domains/kanban/use-implement-fresh.test.ts b/apps/web/hooks/domains/kanban/use-implement-fresh.test.ts index 7ea2e8525c..c9d27e71df 100644 --- a/apps/web/hooks/domains/kanban/use-implement-fresh.test.ts +++ b/apps/web/hooks/domains/kanban/use-implement-fresh.test.ts @@ -1,5 +1,7 @@ +import { createElement, type ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; import { agentProfileId as toAgentProfileId, sessionId as toSessionId, @@ -7,6 +9,8 @@ import { type TaskSession, } from "@/lib/types/http"; import type { MessageAttachment } from "@/components/task/chat/chat-input-container"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { TaskPlan } from "@/lib/types/http-agents"; const mockLaunchSession = vi.fn(); @@ -106,6 +110,20 @@ function makeChatRef(opts: { value?: string; attachments?: MessageAttachment[] } return { ref, clear }; } +function queryWrapper(queryClient: QueryClient) { + return function QueryWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +function renderImplementFresh(...args: Parameters) { + const queryClient = makeQueryClient(); + return { + queryClient, + ...renderHook(() => useImplementFresh(...args), { wrapper: queryWrapper(queryClient) }), + }; +} + function setup(session: TaskSession | undefined = makeSession()) { vi.clearAllMocks(); mockLaunchSession.mockResolvedValue({ @@ -128,7 +146,7 @@ describe("useImplementFresh", () => { it("launches a fresh session inheriting agent + executor profile from the planning session", async () => { const { ref } = makeChatRef({ value: "double-check the migration" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -153,7 +171,7 @@ describe("useImplementFresh", () => { { type: "image", data: "base64data", mime_type: "image/png" }, ]; const { ref } = makeChatRef({ value: "look at this", attachments }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -164,7 +182,7 @@ describe("useImplementFresh", () => { it("omits attachments key when none are present", async () => { const { ref } = makeChatRef({ value: "just text" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -175,7 +193,7 @@ describe("useImplementFresh", () => { it("marks the fresh session as primary via WS after launch", async () => { const { ref } = makeChatRef({ value: "implement" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -192,20 +210,20 @@ describe("useImplementFresh", () => { const markedPlan = makePlan({ implementation_started_session_id: SESS_FRESH }); mockMarkPlanImplementationStarted.mockResolvedValueOnce(markedPlan); const { ref } = makeChatRef({ value: "implement" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result, queryClient } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); }); expect(mockMarkPlanImplementationStarted).toHaveBeenCalledWith(TASK_ID, SESS_FRESH); - expect(mockSetTaskPlan).toHaveBeenCalledWith(TASK_ID, markedPlan); + expect(queryClient.getQueryData(qk.taskPlan.detail(TASK_ID))).toBe(markedPlan); }); it("continues focusing and clearing when the marker write fails after launch", async () => { mockMarkPlanImplementationStarted.mockRejectedValueOnce(new Error("marker offline")); const { ref, clear } = makeChatRef({ value: "implement" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -223,7 +241,7 @@ describe("useImplementFresh post-launch side effects", () => { it("focuses the fresh session as active in the UI after launch", async () => { const { ref } = makeChatRef({ value: "implement" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -234,7 +252,7 @@ describe("useImplementFresh post-launch side effects", () => { it("clears composer + draft only on successful launch", async () => { const { ref, clear } = makeChatRef({ value: "ship" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -247,7 +265,7 @@ describe("useImplementFresh post-launch side effects", () => { it("preserves composer + draft when launch fails so user can retry", async () => { mockLaunchSession.mockRejectedValueOnce(new Error("network down")); const { ref, clear } = makeChatRef({ value: "important" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -260,7 +278,7 @@ describe("useImplementFresh post-launch side effects", () => { it("shows an error toast when launch fails", async () => { mockLaunchSession.mockRejectedValueOnce(new Error("timeout")); const { ref } = makeChatRef({ value: "implement" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -272,7 +290,7 @@ describe("useImplementFresh post-launch side effects", () => { it("continues on set_primary failure to avoid losing the launch", async () => { mockWsRequest.mockRejectedValueOnce(new Error("WS unavailable")); const { ref, clear } = makeChatRef({ value: "continue anyway" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -289,7 +307,7 @@ describe("useImplementFresh guards", () => { it("no-ops when session ID is missing", async () => { const { ref } = makeChatRef(); - const { result } = renderHook(() => useImplementFresh(null, TASK_ID, ref)); + const { result } = renderImplementFresh(null, TASK_ID, ref); await act(async () => { await result.current(); @@ -301,7 +319,7 @@ describe("useImplementFresh guards", () => { it("no-ops when planning session has no agent profile", async () => { setup(makeSession({ agent_profile_id: undefined })); const { ref } = makeChatRef(); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -313,7 +331,7 @@ describe("useImplementFresh guards", () => { it("launches without executor_id when planning session is missing one (backend resolves)", async () => { setup(makeSession({ executor_id: undefined })); const { ref } = makeChatRef({ value: "go" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -326,7 +344,7 @@ describe("useImplementFresh guards", () => { it("does not clear composer when launchSession resolves without a session_id", async () => { mockLaunchSession.mockResolvedValueOnce({ success: true, task_id: TASK_ID, state: "RUNNING" }); const { ref, clear } = makeChatRef({ value: "preserve me" }); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); @@ -345,7 +363,7 @@ describe("useImplementFresh guards", () => { }; // Empty store mockLaunchSession.mockClear(); const { ref } = makeChatRef(); - const { result } = renderHook(() => useImplementFresh(SESS_PLAN, TASK_ID, ref)); + const { result } = renderImplementFresh(SESS_PLAN, TASK_ID, ref); await act(async () => { await result.current(); diff --git a/apps/web/hooks/domains/kanban/use-implement-fresh.ts b/apps/web/hooks/domains/kanban/use-implement-fresh.ts index 70bd9e5029..613c906100 100644 --- a/apps/web/hooks/domains/kanban/use-implement-fresh.ts +++ b/apps/web/hooks/domains/kanban/use-implement-fresh.ts @@ -1,5 +1,6 @@ import type React from "react"; import { useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { setChatDraftContent } from "@/lib/local-storage"; @@ -49,7 +50,7 @@ export function useImplementFresh( resolvedSessionId ? s.taskSessions.items[resolvedSessionId] : undefined, ); const setActiveSession = useAppStore((s) => s.setActiveSession); - const setTaskPlan = useAppStore((s) => s.setTaskPlan); + const queryClient = useQueryClient(); const { toast } = useToast(); return useCallback(async () => { @@ -78,7 +79,7 @@ export function useImplementFresh( if (!newSessionId) return false; await setupFreshSession(newSessionId); - await markPlanImplementationStartedBestEffort(taskId, newSessionId, setTaskPlan); + await markPlanImplementationStartedBestEffort(taskId, newSessionId, queryClient); setActiveSession(taskId, newSessionId); // Clear composer + draft only when a fresh session was actually created. @@ -95,7 +96,7 @@ export function useImplementFresh( resolvedSessionId, planningSession, chatInputRef, - setTaskPlan, + queryClient, setActiveSession, toast, ]); diff --git a/apps/web/hooks/domains/kanban/use-kanban-actions.test.tsx b/apps/web/hooks/domains/kanban/use-kanban-actions.test.tsx new file mode 100644 index 0000000000..3e6b11a6b0 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-kanban-actions.test.tsx @@ -0,0 +1,148 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import { + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, +} from "@/lib/types/ids"; +import type { Task, Workflow, WorkflowSnapshot } from "@/lib/types/http"; +import { useKanbanActions } from "./use-kanban-actions"; + +const WORKSPACE_ID = toWorkspaceId("workspace-1"); +const WORKFLOW_ID = toWorkflowId("workflow-1"); +const STEP_ID = "step-1"; +const CREATED_AT = "2026-06-24T00:00:00Z"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + const initialState = { + workspaces: { activeId: WORKSPACE_ID }, + workflows: { activeId: WORKFLOW_ID }, + kanban: { + workflowId: null, + steps: [], + tasks: [], + isLoading: false, + }, + } as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function workflow(): Workflow { + return { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 0, + hidden: false, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function task(id: string, title = id): Task { + return { + id: toTaskId(id), + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: STEP_ID, + position: 0, + title, + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: CREATED_AT, + updated_at: CREATED_AT, + } as Task; +} + +function snapshot(tasks: Task[]): WorkflowSnapshot { + return { + workflow: workflow(), + steps: [ + { + id: STEP_ID, + workflow_id: WORKFLOW_ID, + name: "Todo", + position: 0, + color: "bg-blue-500", + }, + ], + tasks, + } as WorkflowSnapshot; +} + +function seedWorkflows(queryClient: QueryClient) { + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [workflow()]); +} + +describe("useKanbanActions", () => { + afterEach(() => { + cleanup(); + }); + + it("adds created tasks to the workflow snapshot query cache", () => { + const queryClient = createQueryClient(); + seedWorkflows(queryClient); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), snapshot([])); + const { result } = renderHook( + () => + useKanbanActions({ + workspaceState: { activeId: WORKSPACE_ID }, + workflowsState: { activeId: WORKFLOW_ID }, + }), + { wrapper: wrapperFor(queryClient) }, + ); + + act(() => { + result.current.handleDialogSuccess(task("task-1", "Created from dialog"), "create"); + }); + + expect( + queryClient + .getQueryData(qk.workflows.snapshot(WORKFLOW_ID)) + ?.tasks.map((item) => item.title), + ).toEqual(["Created from dialog"]); + }); + + it("updates edited task metadata in the workflow snapshot query cache", () => { + const queryClient = createQueryClient(); + seedWorkflows(queryClient); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), snapshot([task("task-1", "Old")])); + const { result } = renderHook( + () => + useKanbanActions({ + workspaceState: { activeId: WORKSPACE_ID }, + workflowsState: { activeId: WORKFLOW_ID }, + }), + { wrapper: wrapperFor(queryClient) }, + ); + + act(() => { + result.current.handleDialogSuccess(task("task-1", "Edited"), "edit"); + }); + + expect( + queryClient.getQueryData(qk.workflows.snapshot(WORKFLOW_ID))?.tasks[0] + ?.title, + ).toBe("Edited"); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-kanban-actions.ts b/apps/web/hooks/domains/kanban/use-kanban-actions.ts index 924dbad124..12f7dd97cd 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-actions.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-actions.ts @@ -1,19 +1,21 @@ "use client"; import { useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useRouter } from "@/lib/routing/client-router"; import { useAppStoreApi } from "@/components/state-provider"; +import { useAllCachedWorkflows } from "@/hooks/use-workflow-cache"; import { useTaskCRUD } from "@/hooks/use-task-crud"; +import { qk } from "@/lib/query/keys"; +import { updateWorkflowSnapshotQuery } from "@/lib/query/workflow-snapshot-cache"; import type { Task as BackendTask } from "@/lib/types/http"; -import type { KanbanState, WorkspaceState, WorkflowsState } from "@/lib/state/slices"; +import type { WorkspaceState, WorkflowsState } from "@/lib/state/slices"; type UseKanbanActionsOptions = { workspaceState: WorkspaceState; workflowsState: WorkflowsState; }; -type KanbanTask = KanbanState["tasks"][number]; - function isTaskDetailPath(): boolean { if (typeof window === "undefined") return false; return /^\/(?:t|tasks)\/[^/]+\/?$/.test(window.location.pathname); @@ -30,74 +32,50 @@ function kanbanWorkspaceHref(workspaceId: string): string { } /** Handle creating a new task in the kanban board, merging with any WS-provided data. */ -function hydrateCreatedTask( - store: ReturnType, - task: BackendTask, - currentKanban: KanbanState, -) { - const repoId = task.repositories?.[0]?.repository_id ?? undefined; - const existing = currentKanban.tasks.find((t: KanbanTask) => t.id === task.id); - if (existing) { - if (repoId && !existing.repositoryId) { - store.getState().hydrate({ - kanban: { - ...currentKanban, - tasks: currentKanban.tasks.map((t: KanbanTask) => - t.id === task.id ? { ...t, repositoryId: repoId } : t, - ), - }, - }); - } - } else { - store.getState().hydrate({ - kanban: { - ...currentKanban, - tasks: [ - ...currentKanban.tasks, - { - id: task.id, - workflowStepId: task.workflow_step_id, - title: task.title, - description: task.description ?? undefined, - position: task.position ?? 0, - state: task.state, - repositoryId: repoId, - }, - ], - }, - }); - } - if (task.workspace_id) { - store.getState().invalidateRepositories(task.workspace_id); - } +function hydrateCreatedTask(queryClient: ReturnType, task: BackendTask) { + queryClient.setQueryData(qk.tasks.detail(task.id), task); + updateWorkflowSnapshotQuery(queryClient, task.workflow_id, (snapshot) => { + const existing = snapshot.tasks.find((item) => item.id === task.id); + return { + ...snapshot, + tasks: existing + ? snapshot.tasks.map((item) => (item.id === task.id ? { ...item, ...task } : item)) + : [...snapshot.tasks, task], + }; + }); } /** Handle editing an existing task - only update dialog-editable fields. */ -function hydrateEditedTask( - store: ReturnType, - task: BackendTask, - currentKanban: KanbanState, -) { - store.getState().hydrate({ - kanban: { - ...currentKanban, - tasks: currentKanban.tasks.map((item: KanbanTask) => +function hydrateEditedTask(queryClient: ReturnType, task: BackendTask) { + queryClient.setQueryData(qk.tasks.detail(task.id), (current: BackendTask | undefined) => ({ + ...(current ?? task), + title: task.title, + description: task.description, + repositories: task.repositories, + })); + updateWorkflowSnapshotQuery(queryClient, task.workflow_id, (snapshot) => { + if (!snapshot.tasks.some((item) => item.id === task.id)) return snapshot; + return { + ...snapshot, + tasks: snapshot.tasks.map((item) => item.id === task.id ? { ...item, title: task.title, - description: task.description ?? undefined, - repositoryId: task.repositories?.[0]?.repository_id ?? item.repositoryId, + description: task.description, + repositories: task.repositories, } : item, ), - }, + }; }); } export function useKanbanActions({ workspaceState, workflowsState }: UseKanbanActionsOptions) { const router = useRouter(); const store = useAppStoreApi(); + const queryClient = useQueryClient(); + const workflows = useAllCachedWorkflows(); // CRUD operations from existing hook const { @@ -119,14 +97,13 @@ export function useKanbanActions({ workspaceState, workflowsState }: UseKanbanAc // overwriting WebSocket-driven updates that arrived while the dialog was open. const handleDialogSuccess = useCallback( (task: BackendTask, mode: "create" | "edit") => { - const currentKanban = store.getState().kanban; if (mode === "create") { - hydrateCreatedTask(store, task, currentKanban); + hydrateCreatedTask(queryClient, task); return; } - hydrateEditedTask(store, task, currentKanban); + hydrateEditedTask(queryClient, task); }, - [store], + [queryClient], ); // Handle workspace change with navigation @@ -159,14 +136,14 @@ export function useKanbanActions({ workspaceState, workflowsState }: UseKanbanAc return; } if (nextWorkflowId) { - const workspaceId = workflowsState.items.find( - (workflow: WorkflowsState["items"][number]) => workflow.id === nextWorkflowId, + const workspaceId = workflows.find( + (workflow) => workflow.id === nextWorkflowId, )?.workspaceId; const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ""; router.push(`/?workflowId=${nextWorkflowId}${workspaceParam}`); } }, - [router, store, workflowsState.activeId, workflowsState.items], + [router, store, workflows, workflowsState.activeId], ); return { diff --git a/apps/web/hooks/domains/kanban/use-kanban-data.test.tsx b/apps/web/hooks/domains/kanban/use-kanban-data.test.tsx new file mode 100644 index 0000000000..baedf6e771 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-kanban-data.test.tsx @@ -0,0 +1,154 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice"; +import type { AppState } from "@/lib/state/store"; +import { + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, + type Task, + type Workflow, + type WorkflowSnapshot, +} from "@/lib/types/http"; +import { useKanbanData } from "./use-kanban-data"; + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchWorkflowSnapshot: vi.fn(), + listWorkflows: vi.fn(), +})); + +vi.mock("@/lib/api/domains/workspace-api", () => ({ + listRepositories: vi.fn(), +})); + +const WORKSPACE_ID = toWorkspaceId("workspace-1"); +const WORKFLOW_ID = toWorkflowId("workflow-1"); +const STEP_ID = "step-1"; +const CREATED_AT = "2026-06-24T00:00:00Z"; + +function queryClientWithBoardData() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [workflow()]); + queryClient.setQueryData( + qk.workflows.snapshot(WORKFLOW_ID), + snapshot([task("task-1", "Query task"), task("task-2", "Other task")]), + ); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), []); + return queryClient; +} + +function wrapperFor(queryClient: QueryClient) { + const initialState = { + workspaces: { + activeId: WORKSPACE_ID, + items: [ + { + id: WORKSPACE_ID, + name: "Workspace", + owner_id: "owner-1", + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + ], + }, + workflows: { activeId: WORKFLOW_ID }, + kanban: { + workflowId: WORKFLOW_ID, + steps: [{ id: "legacy-step", title: "Legacy", color: "bg-red-500", position: 0 }], + tasks: [{ id: "legacy-task", workflowStepId: "legacy-step", title: "Legacy", position: 0 }], + isLoading: false, + }, + userSettings: { + ...defaultSettingsState.userSettings, + loaded: true, + workspaceId: WORKSPACE_ID, + workflowId: WORKFLOW_ID, + }, + } as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function workflow(): Workflow { + return { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 0, + hidden: false, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function task(id: string, title: string): Task { + return { + id: toTaskId(id), + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: STEP_ID, + position: 0, + title, + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function snapshot(tasks: Task[]): WorkflowSnapshot { + return { + workflow: workflow(), + steps: [ + { + id: STEP_ID, + workflow_id: WORKFLOW_ID, + name: "Query Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks, + }; +} + +describe("useKanbanData", () => { + afterEach(() => { + cleanup(); + }); + + it("uses workflow snapshot query data instead of the active kanban store mirror", () => { + const { result } = renderHook( + () => + useKanbanData({ + onWorkspaceChange: vi.fn(), + onWorkflowChange: vi.fn(), + searchQuery: "query", + }), + { wrapper: wrapperFor(queryClientWithBoardData()) }, + ); + + expect(result.current.boardState.workflowId).toBe(WORKFLOW_ID); + expect(result.current.activeSteps).toEqual([ + expect.objectContaining({ id: STEP_ID, title: "Query Todo" }), + ]); + expect(result.current.filteredTasks).toEqual([ + expect.objectContaining({ id: "task-1", title: "Query task" }), + ]); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-kanban-data.ts b/apps/web/hooks/domains/kanban/use-kanban-data.ts index 05338816be..36e7dd41bf 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-data.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-data.ts @@ -4,8 +4,10 @@ import { useMemo, useSyncExternalStore } from "react"; import { useAppStore } from "@/components/state-provider"; import { useWorkflowSnapshot } from "@/hooks/use-workflow-snapshot"; import { useUserDisplaySettings } from "@/hooks/use-user-display-settings"; +import { useWorkflows } from "@/hooks/use-workflows"; import { filterTasksByRepositories } from "@/lib/kanban/filters"; import type { WorkflowStep } from "@/components/kanban-column"; +import type { KanbanState } from "@/lib/state/slices"; type KanbanDataOptions = { onWorkspaceChange: (workspaceId: string | null) => void; @@ -19,28 +21,40 @@ export function useKanbanData({ searchQuery = "", }: KanbanDataOptions) { // Store selectors - const kanban = useAppStore((state) => state.kanban); const workspaceState = useAppStore((state) => state.workspaces); - const workflowsState = useAppStore((state) => state.workflows); - const enablePreviewOnClick = useAppStore((state) => state.userSettings.enablePreviewOnClick); - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const storeWorkflowsState = useAppStore((state) => state.workflows); - // Data fetching hooks. `state.workflows.items` is loaded by `AppSidebar` via - // `useEnsureWorkspaceWorkflows` (unconditional, above any collapsible), so - // the kanban page only needs the workflow snapshot here. - useWorkflowSnapshot(workflowsState.activeId); + // Data fetching hooks + const workflowsQuery = useWorkflows(workspaceState.activeId, true); + const snapshotQuery = useWorkflowSnapshot(storeWorkflowsState.activeId); // User settings hook const { settings: userSettings, commitSettings, + repositories, selectedRepositoryIds, } = useUserDisplaySettings({ workspaceId: workspaceState.activeId, - workflowId: workflowsState.activeId, + workflowId: storeWorkflowsState.activeId, onWorkspaceChange, onWorkflowChange, }); + const enablePreviewOnClick = userSettings.enablePreviewOnClick; + const boardState = useMemo( + () => + snapshotQuery.snapshotState ?? { + workflowId: storeWorkflowsState.activeId, + steps: [], + tasks: [], + isLoading: Boolean(storeWorkflowsState.activeId && snapshotQuery.isFetching), + }, + [snapshotQuery.isFetching, snapshotQuery.snapshotState, storeWorkflowsState.activeId], + ); + const workflowsState = useMemo( + () => ({ ...storeWorkflowsState, items: workflowsQuery.workflows }), + [storeWorkflowsState, workflowsQuery.workflows], + ); // SSR safety check const isMounted = useSyncExternalStore( @@ -52,7 +66,7 @@ export function useKanbanData({ // Derived data const steps = useMemo( () => - [...kanban.steps] + [...boardState.steps] .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) .map((step) => ({ id: step.id, @@ -60,7 +74,7 @@ export function useKanbanData({ color: step.color || "bg-neutral-400", events: step.events, })), - [kanban.steps], + [boardState.steps], ); // Memoized so a fresh array of card objects isn't produced on every store @@ -69,7 +83,7 @@ export function useKanbanData({ // pegging the CPU on large boards. const tasks = useMemo( () => - kanban.tasks.map((task) => ({ + boardState.tasks.map((task) => ({ id: task.id, title: task.title, workflowStepId: task.workflowStepId, @@ -80,10 +94,10 @@ export function useKanbanData({ repositories: task.repositories, primarySessionId: task.primarySessionId, })), - [kanban.tasks], + [boardState.tasks], ); - const activeSteps = kanban.workflowId ? steps : []; + const activeSteps = boardState.workflowId ? steps : []; const visibleTasks = useMemo( () => filterTasksByRepositories(tasks, selectedRepositoryIds), @@ -95,10 +109,6 @@ export function useKanbanData({ if (!searchQuery) return visibleTasks; // Get repositories for the current workspace for search filtering - const repositories = workspaceState.activeId - ? (repositoriesByWorkspace[workspaceState.activeId] ?? []) - : []; - const query = searchQuery.toLowerCase(); return visibleTasks.filter((task) => { // Match task title or description @@ -114,11 +124,11 @@ export function useKanbanData({ return false; }); - }, [visibleTasks, searchQuery, workspaceState.activeId, repositoriesByWorkspace]); + }, [visibleTasks, searchQuery, repositories]); return { // State - kanban, + boardState, workspaceState, workflowsState, enablePreviewOnClick, diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.runner.test.ts b/apps/web/hooks/domains/kanban/use-plan-actions.runner.test.ts index 3ab3ad455f..aea49b1348 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.runner.test.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.runner.test.ts @@ -1,5 +1,9 @@ +import { createElement, type ReactNode } from "react"; import { act, renderHook } from "@testing-library/react"; +import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { TaskPlan } from "@/lib/types/http-agents"; import type { TaskSession } from "@/lib/types/http"; @@ -94,6 +98,20 @@ function makeChatRef(value = "ship it") { }; } +function queryWrapper(queryClient: QueryClient) { + return function QueryWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +function renderImplementPlanRunner(args: Parameters[0]) { + const queryClient = makeQueryClient(); + return { + queryClient, + ...renderHook(() => useImplementPlanRunner(args), { wrapper: queryWrapper(queryClient) }), + }; +} + function setup() { vi.clearAllMocks(); mockStoreState = { @@ -113,14 +131,12 @@ describe("useImplementPlanRunner same-session path", () => { mockMarkPlanImplementationStarted.mockResolvedValueOnce(plan); const handlePlanModeChange = vi.fn(); const { ref, clear } = makeChatRef(); - const { result } = renderHook(() => - useImplementPlanRunner({ - resolvedSessionId: SESSION_ID, - taskId: TASK_ID, - handlePlanModeChange, - chatInputRef: ref, - }), - ); + const { result, queryClient } = renderImplementPlanRunner({ + resolvedSessionId: SESSION_ID, + taskId: TASK_ID, + handlePlanModeChange, + chatInputRef: ref, + }); let ok = false; await act(async () => { @@ -140,7 +156,7 @@ describe("useImplementPlanRunner same-session path", () => { 10000, ); expect(mockMarkPlanImplementationStarted).toHaveBeenCalledWith(TASK_ID, SESSION_ID); - expect(mockSetTaskPlan).toHaveBeenCalledWith(TASK_ID, plan); + expect(queryClient.getQueryData(qk.taskPlan.detail(TASK_ID))).toBe(plan); expect(handlePlanModeChange).toHaveBeenCalledWith(false); expect(clear).toHaveBeenCalledTimes(1); expect(mockSetChatDraftContent).toHaveBeenCalledWith(SESSION_ID, null); @@ -156,14 +172,12 @@ describe("useImplementPlanRunner same-session path", () => { mockMarkPlanImplementationStarted.mockRejectedValueOnce(new Error("marker offline")); const handlePlanModeChange = vi.fn(); const { ref, clear } = makeChatRef(); - const { result } = renderHook(() => - useImplementPlanRunner({ - resolvedSessionId: SESSION_ID, - taskId: TASK_ID, - handlePlanModeChange, - chatInputRef: ref, - }), - ); + const { result } = renderImplementPlanRunner({ + resolvedSessionId: SESSION_ID, + taskId: TASK_ID, + handlePlanModeChange, + chatInputRef: ref, + }); let ok = false; await act(async () => { @@ -185,14 +199,12 @@ describe("useImplementPlanRunner same-session path", () => { mockWsRequest.mockRejectedValueOnce(new Error("send failed")); const handlePlanModeChange = vi.fn(); const { ref, clear } = makeChatRef(); - const { result } = renderHook(() => - useImplementPlanRunner({ - resolvedSessionId: SESSION_ID, - taskId: TASK_ID, - handlePlanModeChange, - chatInputRef: ref, - }), - ); + const { result } = renderImplementPlanRunner({ + resolvedSessionId: SESSION_ID, + taskId: TASK_ID, + handlePlanModeChange, + chatInputRef: ref, + }); let ok = true; await act(async () => { @@ -214,14 +226,12 @@ describe("useImplementPlanRunner toolbar path", () => { const plan = makePlan(); mockMarkPlanImplementationStarted.mockResolvedValueOnce(plan); const handlePlanModeChange = vi.fn(); - const { result } = renderHook(() => - useImplementPlanRunner({ - resolvedSessionId: SESSION_ID, - taskId: TASK_ID, - handlePlanModeChange, - clearPlanModeAfterSend: false, - }), - ); + const { result, queryClient } = renderImplementPlanRunner({ + resolvedSessionId: SESSION_ID, + taskId: TASK_ID, + handlePlanModeChange, + clearPlanModeAfterSend: false, + }); let ok = false; await act(async () => { @@ -230,7 +240,7 @@ describe("useImplementPlanRunner toolbar path", () => { expect(ok).toBe(true); expect(mockMarkPlanImplementationStarted).toHaveBeenCalledWith(TASK_ID, SESSION_ID); - expect(mockSetTaskPlan).toHaveBeenCalledWith(TASK_ID, plan); + expect(queryClient.getQueryData(qk.taskPlan.detail(TASK_ID))).toBe(plan); expect(handlePlanModeChange).not.toHaveBeenCalled(); expect(mockSetChatDraftContent).not.toHaveBeenCalled(); expect(mockWsRequest).toHaveBeenCalledTimes(1); diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.test.ts b/apps/web/hooks/domains/kanban/use-plan-actions.test.ts index c8a40567f3..2ede4fc392 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.test.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.test.ts @@ -1,13 +1,24 @@ +import { createElement, type ReactNode } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, renderHook } from "@testing-library/react"; +import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { ContextFile } from "@/lib/state/context-files-store"; +const ids = vi.hoisted(() => ({ + PLAN_STEP_ID: "plan-step", + SESSION_ID: "session-1", + TASK_ID: "task-1", + WORKFLOW_ID: "workflow-1", + WORK_STEP_ID: "work-step", +})); const mockGetState = vi.fn(); const mockMoveTask = vi.hoisted(() => vi.fn()); const mockAppState = vi.hoisted(() => ({ value: { - kanban: { workflowId: "workflow-1", steps: [], tasks: [] }, - tasks: { activeSessionId: "session-1" }, + kanban: { workflowId: ids.WORKFLOW_ID, steps: [], tasks: [] }, + tasks: { activeSessionId: ids.SESSION_ID }, taskSessions: { items: {} }, setActiveSession: vi.fn(), setPlanMode: vi.fn(), @@ -99,6 +110,41 @@ function file(path: string, name: string, pinned?: boolean): ContextFile { return { path, name, pinned }; } +function queryWrapper(queryClient: QueryClient) { + return function QueryWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +function createPlanActionsQueryClient() { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.tasks.detail(ids.TASK_ID), { + id: ids.TASK_ID, + workflow_id: ids.WORKFLOW_ID, + workflow_step_id: ids.PLAN_STEP_ID, + }); + queryClient.setQueryData(qk.workflows.steps(ids.WORKFLOW_ID), [ + { + id: ids.PLAN_STEP_ID, + name: "Plan", + position: 1, + events: { on_enter: [{ type: "enable_plan_mode" }] }, + }, + { + id: ids.WORK_STEP_ID, + name: "Work", + position: 2, + events: { on_enter: [{ type: "auto_start_agent" }] }, + }, + ]); + return queryClient; +} + +function renderPlanActions(args: Parameters[0]) { + const queryClient = createPlanActionsQueryClient(); + return renderHook(() => usePlanActions(args), { wrapper: queryWrapper(queryClient) }); +} + describe("readContextFilesMeta", () => { const sessionId = "sess-1"; const appFilePath = "src/app.ts"; @@ -162,22 +208,22 @@ describe("usePlanActions", () => { mockAppState.value = { ...mockAppState.value, kanban: { - workflowId: "workflow-1", + workflowId: ids.WORKFLOW_ID, steps: [ { - id: "plan-step", + id: ids.PLAN_STEP_ID, title: "Plan", position: 1, events: { on_enter: [{ type: "enable_plan_mode" }] }, }, { - id: "work-step", + id: ids.WORK_STEP_ID, title: "Work", position: 2, events: { on_enter: [{ type: "auto_start_agent" }] }, }, ], - tasks: [{ id: "task-1", workflowStepId: "plan-step" }], + tasks: [{ id: ids.TASK_ID, workflowStepId: ids.PLAN_STEP_ID }], }, }; }); @@ -191,54 +237,48 @@ describe("usePlanActions", () => { }, }; - const { result } = renderHook(() => - usePlanActions({ - resolvedSessionId: "session-1", - taskId: "task-1", - planModeEnabled: true, - handlePlanModeChange: vi.fn(), - chatInputRef: chatInputRef as never, - }), - ); + const { result } = renderPlanActions({ + resolvedSessionId: ids.SESSION_ID, + taskId: ids.TASK_ID, + planModeEnabled: true, + handlePlanModeChange: vi.fn(), + chatInputRef: chatInputRef as never, + }); expect(result.current.implementPlanHandler).toEqual(expect.any(Function)); }); it("routes implement through the next auto-start work step", async () => { - const { result } = renderHook(() => - usePlanActions({ - resolvedSessionId: "session-1", - taskId: "task-1", - planModeEnabled: true, - handlePlanModeChange: vi.fn(), - chatInputRef: { current: null }, - }), - ); + const { result } = renderPlanActions({ + resolvedSessionId: ids.SESSION_ID, + taskId: ids.TASK_ID, + planModeEnabled: true, + handlePlanModeChange: vi.fn(), + chatInputRef: { current: null }, + }); await act(async () => { await result.current.implementPlanHandler?.(false); }); - expect(mockMoveTask).toHaveBeenCalledWith("task-1", { - workflow_id: "workflow-1", - workflow_step_id: "work-step", + expect(mockMoveTask).toHaveBeenCalledWith(ids.TASK_ID, { + workflow_id: ids.WORKFLOW_ID, + workflow_step_id: ids.WORK_STEP_ID, position: 0, }); - expect(mockAppState.value.setPlanMode).toHaveBeenCalledWith("session-1", false); + expect(mockAppState.value.setPlanMode).toHaveBeenCalledWith(ids.SESSION_ID, false); }); it("keeps plan mode enabled when moving to the work step fails", async () => { mockMoveTask.mockRejectedValueOnce(new Error("move failed")); vi.spyOn(console, "error").mockImplementation(() => undefined); - const { result } = renderHook(() => - usePlanActions({ - resolvedSessionId: "session-1", - taskId: "task-1", - planModeEnabled: true, - handlePlanModeChange: vi.fn(), - chatInputRef: { current: null }, - }), - ); + const { result } = renderPlanActions({ + resolvedSessionId: ids.SESSION_ID, + taskId: ids.TASK_ID, + planModeEnabled: true, + handlePlanModeChange: vi.fn(), + chatInputRef: { current: null }, + }); await act(async () => { await result.current.implementPlanHandler?.(false); @@ -249,15 +289,13 @@ describe("usePlanActions", () => { }); it("does not expose the implement handler outside plan mode", () => { - const { result } = renderHook(() => - usePlanActions({ - resolvedSessionId: "session-1", - taskId: "task-1", - planModeEnabled: false, - handlePlanModeChange: vi.fn(), - chatInputRef: { current: null }, - }), - ); + const { result } = renderPlanActions({ + resolvedSessionId: ids.SESSION_ID, + taskId: ids.TASK_ID, + planModeEnabled: false, + handlePlanModeChange: vi.fn(), + chatInputRef: { current: null }, + }); expect(result.current.implementPlanHandler).toBeUndefined(); }); diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.test.tsx b/apps/web/hooks/domains/kanban/use-plan-actions.test.tsx new file mode 100644 index 0000000000..e82e6d7d2a --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-plan-actions.test.tsx @@ -0,0 +1,98 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { taskId, workflowId, workspaceId, type Task, type WorkflowStep } from "@/lib/types/http"; +import type { KanbanState } from "@/lib/state/slices"; + +type MockState = { + kanban: { workflowId: string | null; steps: KanbanState["steps"]; tasks: KanbanState["tasks"] }; + tasks: { activeSessionId: string | null }; +}; + +let mockState: MockState; +const TIMESTAMP = "2026-01-01T00:00:00Z"; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: vi.fn() }), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(() => new Promise(() => {})), + moveTask: vi.fn(), +})); + +vi.mock("@/lib/api/domains/workflow-api", () => ({ + listWorkflowSteps: vi.fn(() => new Promise(() => {})), +})); + +import { useNextWorkflowStep } from "./use-plan-actions"; + +function makeTask(): Task { + return { + id: taskId("task-1"), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-current", + position: 1, + title: "Query task", + description: "", + state: "CREATED", + priority: 0, + repositories: [], + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function makeStep(id: string, name: string, position: number): WorkflowStep { + return { + id, + workflow_id: workflowId("wf-1"), + name, + position, + color: "bg-blue-500", + events: {}, + allow_manual_move: true, + prompt: "", + is_start_step: position === 1, + show_in_command_panel: true, + created_at: TIMESTAMP, + updated_at: TIMESTAMP, + }; +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + +describe("useNextWorkflowStep", () => { + beforeEach(() => { + mockState = { + kanban: { workflowId: null, steps: [], tasks: [] }, + tasks: { activeSessionId: "session-1" }, + }; + }); + + it("derives the next step from task detail and workflow-step Query caches", () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(qk.tasks.detail("task-1"), makeTask()); + client.setQueryData(qk.workflows.steps("wf-1"), [ + makeStep("step-current", "Current", 1), + makeStep("step-next", "Next", 2), + ]); + + const { result } = renderHook(() => useNextWorkflowStep("task-1"), { + wrapper: wrapper(client), + }); + + expect(result.current.proceedStepName).toBe("Next"); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-plan-actions.ts b/apps/web/hooks/domains/kanban/use-plan-actions.ts index bcc5d37755..1853ba487d 100644 --- a/apps/web/hooks/domains/kanban/use-plan-actions.ts +++ b/apps/web/hooks/domains/kanban/use-plan-actions.ts @@ -1,9 +1,12 @@ import React, { useCallback, useMemo, useState } from "react"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { getWebSocketClient } from "@/lib/ws/connection"; import { setChatDraftContent } from "@/lib/local-storage"; import { moveTask } from "@/lib/api/domains/kanban-api"; +import { taskQueryOptions, workflowStepsQueryOptions } from "@/lib/query/query-options"; +import { qk } from "@/lib/query/keys"; import { useContextFilesStore } from "@/lib/state/context-files-store"; import { useLayoutStore } from "@/lib/state/layout-store"; import { useDockviewStore } from "@/lib/state/dockview-store"; @@ -20,12 +23,15 @@ const AUTO_TRANSITION_ACTIONS = ["move_to_next", "move_to_previous", "move_to_st export function useNextWorkflowStep(taskId: string | null) { const { toast } = useToast(); - const workflowId = useAppStore((s) => s.kanban.workflowId); - const steps = useAppStore((s) => s.kanban.steps); - const taskStepId = useAppStore((s) => { - if (!taskId) return null; - const task = s.kanban.tasks.find((t) => t.id === taskId); - return task?.workflowStepId ?? null; + const taskQuery = useQuery({ + ...taskQueryOptions(taskId ?? ""), + enabled: Boolean(taskId), + }); + const workflowId = taskQuery.data?.workflow_id ?? null; + const taskStepId = taskQuery.data?.workflow_step_id ?? null; + const stepsQuery = useQuery({ + ...workflowStepsQueryOptions(workflowId ?? ""), + enabled: Boolean(workflowId), }); // Track agent switching: isMoving stays true from "proceed" click until the @@ -34,6 +40,14 @@ export function useNextWorkflowStep(taskId: string | null) { const activeSessionId = useAppStore((s) => s.tasks.activeSessionId); const isMoving = moveFromSessionId != null && activeSessionId === moveFromSessionId; + const steps = useMemo( + () => + (stepsQuery.data ?? []).map((step) => ({ + ...step, + title: step.name, + })), + [stepsQuery.data], + ); const sortedSteps = useMemo(() => [...steps].sort((a, b) => a.position - b.position), [steps]); const { currentStep, nextStep } = useMemo(() => { @@ -135,14 +149,11 @@ export function collectImplementPlanInput( export async function markPlanImplementationStartedBestEffort( taskId: string, sessionId: string, - setTaskPlan: ( - taskId: string, - plan: Awaited>, - ) => void, + queryClient: QueryClient, ) { try { const markedPlan = await markPlanImplementationStarted(taskId, sessionId); - setTaskPlan(taskId, markedPlan); + queryClient.setQueryData(qk.taskPlan.detail(taskId), markedPlan); } catch (err) { console.error("Failed to mark plan implementation started:", err); } @@ -155,7 +166,7 @@ function useImplementPlan( clearPlanModeAfterSend: boolean, chatInputRef?: React.RefObject, ) { - const setTaskPlan = useAppStore((s) => s.setTaskPlan); + const queryClient = useQueryClient(); const { toast } = useToast(); return useCallback(async (): Promise => { if (!resolvedSessionId || !taskId) return false; @@ -183,7 +194,7 @@ function useImplementPlan( }, attachments.length > 0 ? 30000 : 10000, ); - await markPlanImplementationStartedBestEffort(taskId, resolvedSessionId, setTaskPlan); + await markPlanImplementationStartedBestEffort(taskId, resolvedSessionId, queryClient); // Exit plan mode + clear composer only on success so a failed send // leaves the layout and input intact for retry. if (clearPlanModeAfterSend) { @@ -214,7 +225,7 @@ function useImplementPlan( resolvedSessionId, taskId, chatInputRef, - setTaskPlan, + queryClient, clearPlanModeAfterSend, handlePlanModeChange, toast, diff --git a/apps/web/hooks/domains/kanban/use-swimlane-move.test.tsx b/apps/web/hooks/domains/kanban/use-swimlane-move.test.tsx new file mode 100644 index 0000000000..cc2717dc54 --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-swimlane-move.test.tsx @@ -0,0 +1,165 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import { + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, +} from "@/lib/types/ids"; +import type { Task, WorkflowSnapshot } from "@/lib/types/http"; +import { useSwimlaneMove } from "./use-swimlane-move"; + +const actionMocks = vi.hoisted(() => ({ + moveTaskById: vi.fn(), +})); + +vi.mock("@/hooks/use-task-actions", () => ({ + useTaskActions: () => ({ + moveTaskById: actionMocks.moveTaskById, + }), +})); + +const WORKSPACE_ID = toWorkspaceId("workspace-1"); +const WORKFLOW_ID = toWorkflowId("workflow-1"); +const STEP_TODO = "step-todo"; +const STEP_DONE = "step-done"; +const CREATED_AT = "2026-06-24T00:00:00Z"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + const initialState = { + kanbanMulti: { snapshots: {} }, + } as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function rawTask(id: string, stepId = STEP_TODO, position = 0): Task { + return { + id: toTaskId(id), + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: stepId, + position, + title: id, + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: CREATED_AT, + updated_at: CREATED_AT, + } as Task; +} + +function snapshot(tasks: Task[]): WorkflowSnapshot { + return { + workflow: { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 0, + hidden: false, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + steps: [ + { + id: STEP_TODO, + workflow_id: WORKFLOW_ID, + name: "Todo", + position: 0, + color: "bg-blue-500", + }, + { + id: STEP_DONE, + workflow_id: WORKFLOW_ID, + name: "Done", + position: 1, + color: "bg-green-500", + }, + ], + tasks, + } as WorkflowSnapshot; +} + +function cardTask(id: string, stepId = STEP_TODO, position = 0) { + return { + id, + title: id, + workflowStepId: stepId, + position, + primarySessionId: null, + }; +} + +describe("useSwimlaneMove", () => { + beforeEach(() => { + actionMocks.moveTaskById.mockReset(); + actionMocks.moveTaskById.mockResolvedValue({}); + }); + + afterEach(() => { + cleanup(); + }); + + it("moves tasks in the workflow snapshot query cache without kanbanMulti", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), snapshot([rawTask("task-1")])); + const { result } = renderHook(() => useSwimlaneMove(WORKFLOW_ID, { onMoveError: vi.fn() }), { + wrapper: wrapperFor(queryClient), + }); + + await act(async () => { + await result.current.moveTask(cardTask("task-1"), STEP_DONE); + }); + + expect(actionMocks.moveTaskById).toHaveBeenCalledWith("task-1", { + workflow_id: WORKFLOW_ID, + workflow_step_id: STEP_DONE, + position: 0, + }); + expect( + queryClient.getQueryData(qk.workflows.snapshot(WORKFLOW_ID))?.tasks[0] + ?.workflow_step_id, + ).toBe(STEP_DONE); + }); + + it("rolls back the workflow snapshot query cache when a swimlane move fails", async () => { + actionMocks.moveTaskById.mockRejectedValueOnce(new Error("move failed")); + const onMoveError = vi.fn(); + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), snapshot([rawTask("task-1")])); + const { result } = renderHook(() => useSwimlaneMove(WORKFLOW_ID, { onMoveError }), { + wrapper: wrapperFor(queryClient), + }); + + await act(async () => { + await result.current.moveTask(cardTask("task-1"), STEP_DONE); + }); + + expect( + queryClient.getQueryData(qk.workflows.snapshot(WORKFLOW_ID))?.tasks[0] + ?.workflow_step_id, + ).toBe(STEP_TODO); + expect(onMoveError).toHaveBeenCalledWith({ + message: "move failed", + taskId: "task-1", + sessionId: null, + }); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-swimlane-move.ts b/apps/web/hooks/domains/kanban/use-swimlane-move.ts index cfc3bf2788..0f4217c32a 100644 --- a/apps/web/hooks/domains/kanban/use-swimlane-move.ts +++ b/apps/web/hooks/domains/kanban/use-swimlane-move.ts @@ -1,9 +1,12 @@ import { useCallback } from "react"; -import { useAppStoreApi } from "@/components/state-provider"; +import { useQueryClient } from "@tanstack/react-query"; import { useTaskActions } from "@/hooks/use-task-actions"; import type { Task } from "@/components/kanban-card"; -import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; -import type { KanbanState } from "@/lib/state/slices/kanban/types"; +import type { MoveTaskError } from "@/lib/kanban/move-task-error"; +import { + updateWorkflowSnapshotQuery, + workflowSnapshotQueryDataForWorkflow, +} from "@/lib/query/workflow-snapshot-cache"; export function useSwimlaneMove( workflowId: string, @@ -11,37 +14,31 @@ export function useSwimlaneMove( onMoveError?: (error: MoveTaskError) => void; }, ) { - const store = useAppStoreApi(); + const queryClient = useQueryClient(); const { moveTaskById } = useTaskActions(); const moveTask = useCallback( async (task: Task, targetStepId: string) => { if (task.workflowStepId === targetStepId) return; - const state = store.getState(); - const snapshot = state.kanbanMulti.snapshots[workflowId]; + const snapshot = workflowSnapshotQueryDataForWorkflow(queryClient, workflowId); if (!snapshot) return; const targetTasks = snapshot.tasks - .filter( - (t: KanbanState["tasks"][number]) => - t.workflowStepId === targetStepId && t.id !== task.id, - ) - .sort( - (a: KanbanState["tasks"][number], b: KanbanState["tasks"][number]) => - a.position - b.position, - ); + .filter((item) => item.workflow_step_id === targetStepId && item.id !== task.id) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); const nextPosition = targetTasks.length; - const originalTasks = snapshot.tasks; + const originalSnapshot = snapshot; - // Optimistic update - state.setWorkflowSnapshot(workflowId, { - ...snapshot, - tasks: snapshot.tasks.map((t: KanbanState["tasks"][number]) => - t.id === task.id ? { ...t, workflowStepId: targetStepId, position: nextPosition } : t, + updateWorkflowSnapshotQuery(queryClient, workflowId, (current) => ({ + ...current, + tasks: current.tasks.map((item) => + item.id === task.id + ? { ...item, workflow_step_id: targetStepId, position: nextPosition } + : item, ), - }); + })); try { await moveTaskById(task.id, { @@ -52,14 +49,7 @@ export function useSwimlaneMove( // Backend handles on_enter actions (auto_start_agent, plan_mode, etc.) // via the task.moved event → orchestrator processOnEnter() } catch (error) { - // Rollback - const currentSnapshot = store.getState().kanbanMulti.snapshots[workflowId]; - if (currentSnapshot) { - store.getState().setWorkflowSnapshot(workflowId, { - ...currentSnapshot, - tasks: originalTasks, - }); - } + updateWorkflowSnapshotQuery(queryClient, workflowId, () => originalSnapshot); const message = error instanceof Error ? error.message : "Failed to move task"; opts.onMoveError?.({ message, @@ -68,7 +58,7 @@ export function useSwimlaneMove( }); } }, - [workflowId, store, moveTaskById, opts], + [workflowId, queryClient, moveTaskById, opts], ); return { moveTask }; diff --git a/apps/web/hooks/domains/kanban/use-task-by-id.ts b/apps/web/hooks/domains/kanban/use-task-by-id.ts index 46e6712068..52ccdc983d 100644 --- a/apps/web/hooks/domains/kanban/use-task-by-id.ts +++ b/apps/web/hooks/domains/kanban/use-task-by-id.ts @@ -1,22 +1,20 @@ -import { useAppStore } from "@/components/state-provider"; -import { findTaskInSnapshots } from "@/lib/kanban/find-task"; +import { useQuery } from "@tanstack/react-query"; +import { toKanbanTask } from "@/lib/kanban/map-task"; +import { taskQueryOptions } from "@/lib/query/query-options"; import type { KanbanState } from "@/lib/state/slices"; type Task = KanbanState["tasks"][number]; /** - * Read-only lookup of a task by ID across the active workflow and any loaded - * cross-workflow snapshots. Unlike useTask, this hook does not subscribe to - * task updates over WebSocket — use it where the caller only needs whatever - * task data is already cached (e.g. rendering a sender badge for a message - * that came from another task; if the sender task isn't loaded we fall back - * to the snapshotted title in metadata). + * Read-only lookup of a task by ID from TanStack Query. Unlike useTask, this + * hook does not subscribe to task updates over WebSocket — use it where the + * caller only needs fetched/cached task data. */ export function useTaskById(taskId: string | null | undefined): Task | null { - return useAppStore((state) => { - if (!taskId) return null; - const fromActive = state.kanban.tasks.find((item: Task) => item.id === taskId); - if (fromActive) return fromActive; - return findTaskInSnapshots(taskId, state.kanbanMulti.snapshots); + const query = useQuery({ + ...taskQueryOptions(taskId ?? ""), + enabled: Boolean(taskId), }); + + return query.data ? toKanbanTask(query.data) : null; } diff --git a/apps/web/hooks/domains/kanban/use-task-repositories.ts b/apps/web/hooks/domains/kanban/use-task-repositories.ts index b86ef74a86..8290152f5c 100644 --- a/apps/web/hooks/domains/kanban/use-task-repositories.ts +++ b/apps/web/hooks/domains/kanban/use-task-repositories.ts @@ -1,11 +1,11 @@ "use client"; import { useMemo } from "react"; -import { useAppStore } from "@/components/state-provider"; import type { KanbanState } from "@/lib/state/slices"; +import { useTaskById } from "./use-task-by-id"; /** - * Slim per-task repository shape carried in the kanban store. Mirrors + * Slim per-task repository shape carried by task detail data. Mirrors * KanbanState["tasks"][number]["repositories"][number]. */ export type KanbanTaskRepository = NonNullable< @@ -14,16 +14,12 @@ export type KanbanTaskRepository = NonNullable< /** * Returns the repositories linked to a task, ordered by Position. Empty - * array for repo-less tasks. The hook reads from the kanban tasks slice; + * array for repo-less tasks. The hook reads from the task detail Query; * use this instead of poking task.repositories directly so multi-repo * consumers all share one source of truth. */ export function useTaskRepositories(taskId: string | null | undefined): KanbanTaskRepository[] { - const task = useAppStore((state) => - taskId - ? (state.kanban.tasks.find((t: KanbanState["tasks"][number]) => t.id === taskId) ?? null) - : null, - ); + const task = useTaskById(taskId); return useMemo(() => { const repos = task?.repositories ?? []; return [...repos].sort((a, b) => a.position - b.position); diff --git a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts index 81af0e751f..01b9444e6c 100644 --- a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts +++ b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { renderHook } from "@testing-library/react"; const mockUseAllWorkflowSnapshots = vi.fn(); +let mockWorkflows: Array<{ id: string; workspaceId: string; name: string; hidden?: boolean }> = []; type Snapshot = { workflowId: string; @@ -10,42 +11,19 @@ type Snapshot = { tasks: Array<{ id: string; workflowStepId: string; title: string; position: number }>; }; -type MockState = { - kanbanMulti: { - snapshots: Record; - isLoading: boolean; - }; - workflows: { items: Array<{ id: string; workspaceId: string; name: string; hidden?: boolean }> }; - kanban: { - workflowId: string | null; - tasks: Array<{ id: string; workflowStepId: string; title: string; position: number }>; - steps: Array<{ id: string; title: string; color: string; position: number }>; - }; -}; - -let mockState: MockState = { - kanbanMulti: { snapshots: {}, isLoading: false }, - workflows: { items: [] }, - kanban: { workflowId: null, tasks: [], steps: [] }, -}; - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (s: MockState) => unknown) => selector(mockState), - useAppStoreApi: () => ({ getState: () => mockState }), -})); - vi.mock("@/hooks/domains/kanban/use-all-workflow-snapshots", () => ({ useAllWorkflowSnapshots: (workspaceId: string | null) => mockUseAllWorkflowSnapshots(workspaceId), })); +vi.mock("@/hooks/use-workflow-cache", () => ({ + useCachedWorkflows: (workspaceId: string | null) => + workspaceId ? mockWorkflows.filter((workflow) => workflow.workspaceId === workspaceId) : [], +})); + import { useWorkspaceSidebarTasks } from "./use-workspace-sidebar-tasks"; -function setMockState(patch: Partial) { - mockState = { - kanbanMulti: { ...mockState.kanbanMulti, ...(patch.kanbanMulti ?? {}) }, - workflows: { ...mockState.workflows, ...(patch.workflows ?? {}) }, - kanban: { ...mockState.kanban, ...(patch.kanban ?? {}) }, - }; +function setSnapshotResult(snapshots: Record, isLoading = false) { + mockUseAllWorkflowSnapshots.mockReturnValue({ snapshots, isLoading }); } function makeSnapshot( @@ -65,11 +43,8 @@ function makeSnapshot( describe("useWorkspaceSidebarTasks", () => { beforeEach(() => { vi.clearAllMocks(); - mockState = { - kanbanMulti: { snapshots: {}, isLoading: false }, - workflows: { items: [] }, - kanban: { workflowId: null, tasks: [], steps: [] }, - }; + setSnapshotResult({}); + mockWorkflows = []; }); it("fires useAllWorkflowSnapshots with the workspaceId", () => { @@ -78,21 +53,14 @@ describe("useWorkspaceSidebarTasks", () => { }); it("aggregates tasks from every workflow snapshot scoped to the workspace", () => { - setMockState({ - workflows: { - items: [ - { id: "wf-A", workspaceId: "ws-1", name: "Alpha" }, - { id: "wf-B", workspaceId: "ws-1", name: "Beta" }, - ], - }, - kanbanMulti: { - snapshots: { - "wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1", "t-a2"]), - "wf-B": makeSnapshot("wf-B", "Beta", ["t-b1"]), - }, - isLoading: false, - }, + setSnapshotResult({ + "wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1", "t-a2"]), + "wf-B": makeSnapshot("wf-B", "Beta", ["t-b1"]), }); + mockWorkflows = [ + { id: "wf-A", workspaceId: "ws-1", name: "Alpha" }, + { id: "wf-B", workspaceId: "ws-1", name: "Beta" }, + ]; const { result } = renderHook(() => useWorkspaceSidebarTasks("ws-1")); const ids = result.current.allTasks.map((t) => t.id); @@ -105,21 +73,14 @@ describe("useWorkspaceSidebarTasks", () => { }); it("returns an empty scope when workspaceId is null (no cross-workspace leak)", () => { - setMockState({ - workflows: { - items: [ - { id: "wf-A", workspaceId: "ws-1", name: "Alpha" }, - { id: "wf-B", workspaceId: "ws-2", name: "Beta" }, - ], - }, - kanbanMulti: { - snapshots: { - "wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1"]), - "wf-B": makeSnapshot("wf-B", "Beta", ["t-b1"]), - }, - isLoading: false, - }, + setSnapshotResult({ + "wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1"]), + "wf-B": makeSnapshot("wf-B", "Beta", ["t-b1"]), }); + mockWorkflows = [ + { id: "wf-A", workspaceId: "ws-1", name: "Alpha" }, + { id: "wf-B", workspaceId: "ws-2", name: "Beta" }, + ]; const { result } = renderHook(() => useWorkspaceSidebarTasks(null)); expect(result.current.allTasks).toEqual([]); @@ -127,69 +88,42 @@ describe("useWorkspaceSidebarTasks", () => { }); it("filters out snapshots from other workspaces (stale hydration)", () => { - setMockState({ - workflows: { - items: [ - { id: "wf-A", workspaceId: "ws-1", name: "Alpha" }, - { id: "wf-X", workspaceId: "ws-other", name: "Stale" }, - ], - }, - kanbanMulti: { - snapshots: { - "wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1"]), - "wf-X": makeSnapshot("wf-X", "Stale", ["t-x1"]), - }, - isLoading: false, - }, + setSnapshotResult({ + "wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1"]), + "wf-X": makeSnapshot("wf-X", "Stale", ["t-x1"]), }); + mockWorkflows = [ + { id: "wf-A", workspaceId: "ws-1", name: "Alpha" }, + { id: "wf-X", workspaceId: "ws-other", name: "Stale" }, + ]; const { result } = renderHook(() => useWorkspaceSidebarTasks("ws-1")); expect(result.current.allTasks.map((t) => t.id)).toEqual(["t-a1"]); expect(result.current.workflows.map((w) => w.id)).toEqual(["wf-A"]); }); - it("falls back to the active kanban slice for tasks not yet in snapshots", () => { - // Snapshot for wf-A hasn't loaded yet, but the page-level useTasks call - // already populated `kanban.tasks` for the current workflow. - setMockState({ - workflows: { items: [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - kanban: { - workflowId: "wf-A", - tasks: [{ id: "t-a1", workflowStepId: "step-1", title: "A1", position: 0 }], - steps: [{ id: "step-1", title: "Step 1", color: "bg-blue-500", position: 0 }], - }, - }); + it("waits for Query snapshots instead of falling back to the old active kanban mirror", () => { + mockWorkflows = [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }]; const { result } = renderHook(() => useWorkspaceSidebarTasks("ws-1")); - expect(result.current.allTasks.map((t) => t.id)).toEqual(["t-a1"]); - expect(result.current.allTasks[0]._workflowId).toBe("wf-A"); + + expect(result.current.allTasks).toEqual([]); }); }); describe("useWorkspaceSidebarTasks — loading", () => { beforeEach(() => { vi.clearAllMocks(); - mockState = { - kanbanMulti: { snapshots: {}, isLoading: false }, - workflows: { items: [] }, - kanban: { workflowId: null, tasks: [], steps: [] }, - }; + setSnapshotResult({}); + mockWorkflows = []; }); it("reports loading only on the first fetch, not on refreshes", () => { - setMockState({ - workflows: { items: [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }] }, - kanbanMulti: { snapshots: {}, isLoading: true }, - }); + setSnapshotResult({}, true); + mockWorkflows = [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }]; expect(renderHook(() => useWorkspaceSidebarTasks("ws-1")).result.current.isLoading).toBe(true); - setMockState({ - kanbanMulti: { - snapshots: { "wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1"]) }, - isLoading: true, - }, - }); + setSnapshotResult({ "wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1"]) }, true); expect(renderHook(() => useWorkspaceSidebarTasks("ws-1")).result.current.isLoading).toBe(false); }); }); diff --git a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts index d3a5d58044..4056b1656a 100644 --- a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts +++ b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { useAppStore } from "@/components/state-provider"; import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; +import { useCachedWorkflows } from "@/hooks/use-workflow-cache"; import { aggregateSidebarTasks, type AggregatedSidebarTasks, @@ -15,35 +15,24 @@ export type WorkspaceSidebarTasksResult = AggregatedSidebarTasks & { /** * Shared data source for the desktop sidebar and the mobile task-switcher sheet. * - * Fires `useAllWorkflowSnapshots` to populate `kanbanMulti.snapshots` for every - * workflow in the workspace, then aggregates them (with a fallback to the - * single active `kanban` slice for tasks that arrived via WS before their - * snapshot resolved). Snapshots from other workspaces are filtered out so a - * stale hydration doesn't leak across workspace switches. + * Fires `useAllWorkflowSnapshots` for every workflow in the workspace, then + * aggregates Query-owned snapshots. Snapshots from other workspaces are + * filtered out so stale hydration doesn't leak across workspace switches. * - * Assumes `state.workflows.items` is kept in sync with the active workspace by - * an always-mounted caller (`useEnsureWorkspaceWorkflows` from `AppSidebar`). - * Do not add the fetch back here — this hook only runs when the Tasks section - * accordion is expanded, so co-locating the fetch would recreate the original - * "sidebar stale after workspace switch" bug for collapsed-section users. + * Assumes the active workspace workflow query is kept warm by an always-mounted + * caller (`useEnsureWorkspaceWorkflows` from `AppSidebar`). Do not add the fetch + * back here — this hook only runs when the Tasks section accordion is expanded, + * so co-locating the fetch would recreate the collapsed-section staleness bug. */ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceSidebarTasksResult { - useAllWorkflowSnapshots(workspaceId); + const { snapshots, isLoading: snapshotsLoading } = useAllWorkflowSnapshots(workspaceId); - const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); - const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading); - const workflows = useAppStore((state) => state.workflows.items); - const activeKanbanWorkflowId = useAppStore((state) => state.kanban.workflowId); - const activeKanbanTasks = useAppStore((state) => state.kanban.tasks); - const activeKanbanSteps = useAppStore((state) => state.kanban.steps); + const workflows = useCachedWorkflows(workspaceId); // While `workspaceId` is unresolved (initial SSR / pre-hydration), return an // empty scope rather than every workflow in the store — otherwise snapshots // from previously-active workspaces would briefly bleed into the sidebar. - const filteredWorkflows = useMemo( - () => (workspaceId ? workflows.filter((w) => w.workspaceId === workspaceId) : []), - [workflows, workspaceId], - ); + const filteredWorkflows = useMemo(() => (workspaceId ? workflows : []), [workflows, workspaceId]); const workspaceWorkflowIds = useMemo( () => new Set(filteredWorkflows.map((w) => w.id)), [filteredWorkflows], @@ -57,21 +46,7 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS return result; }, [snapshots, workspaceWorkflowIds]); - const fallbackWorkflowId = - activeKanbanWorkflowId && workspaceWorkflowIds.has(activeKanbanWorkflowId) - ? activeKanbanWorkflowId - : null; - - const aggregated = useMemo( - () => - aggregateSidebarTasks( - scopedSnapshots, - fallbackWorkflowId, - activeKanbanTasks, - activeKanbanSteps, - ), - [scopedSnapshots, fallbackWorkflowId, activeKanbanTasks, activeKanbanSteps], - ); + const aggregated = useMemo(() => aggregateSidebarTasks(scopedSnapshots), [scopedSnapshots]); const workspaceWorkflows = useMemo( () => filteredWorkflows.map((w) => ({ id: w.id, name: w.name, hidden: w.hidden })), @@ -80,7 +55,7 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS // Only flash a skeleton on the very first fetch (no snapshots yet); refreshes // shouldn't blow away the existing list. - const isLoading = isMultiLoading && Object.keys(scopedSnapshots).length === 0; + const isLoading = snapshotsLoading && Object.keys(scopedSnapshots).length === 0; return { ...aggregated, diff --git a/apps/web/hooks/domains/linear/use-linear-availability.ts b/apps/web/hooks/domains/linear/use-linear-availability.ts index 162745154a..e3d1518bb6 100644 --- a/apps/web/hooks/domains/linear/use-linear-availability.ts +++ b/apps/web/hooks/domains/linear/use-linear-availability.ts @@ -6,6 +6,7 @@ import { useIntegrationAuthed, useIntegrationAvailable, } from "../integrations/use-integration-availability"; +import { qk } from "@/lib/query/keys"; import { useLinearEnabled } from "./use-linear-enabled"; export function useLinearAuthed(workspaceId?: string | null): boolean { @@ -13,7 +14,11 @@ export function useLinearAuthed(workspaceId?: string | null): boolean { () => getLinearConfig(workspaceId ? { workspaceId } : undefined), [workspaceId], ); - return useIntegrationAuthed(fetchConfig); + return useIntegrationAuthed({ + active: workspaceId !== null, + fetchConfig, + queryKey: qk.integrations.linear.config(workspaceId), + }); } export function useLinearAvailable(workspaceId?: string | null): boolean { @@ -22,7 +27,9 @@ export function useLinearAvailable(workspaceId?: string | null): boolean { [workspaceId], ); return useIntegrationAvailable({ + active: workspaceId !== null, useEnabled: useLinearEnabled, fetchConfig, + queryKey: qk.integrations.linear.config(workspaceId), }); } diff --git a/apps/web/hooks/domains/linear/use-linear-issue-watches.ts b/apps/web/hooks/domains/linear/use-linear-issue-watches.ts index de5c630446..54de90dcf2 100644 --- a/apps/web/hooks/domains/linear/use-linear-issue-watches.ts +++ b/apps/web/hooks/domains/linear/use-linear-issue-watches.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useCallback, useRef } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - listLinearIssueWatches, createLinearIssueWatch, updateLinearIssueWatch, deleteLinearIssueWatch, @@ -10,8 +10,13 @@ import { previewResetLinearIssueWatch, resetLinearIssueWatch, } from "@/lib/api/domains/linear-api"; -import { useAppStore } from "@/components/state-provider"; -import type { CreateLinearIssueWatchInput, UpdateLinearIssueWatchInput } from "@/lib/types/linear"; +import { qk } from "@/lib/query/keys"; +import { linearIssueWatchesQueryOptions } from "@/lib/query/query-options/linear"; +import type { + CreateLinearIssueWatchInput, + LinearIssueWatch, + UpdateLinearIssueWatchInput, +} from "@/lib/types/linear"; // WORKSPACE_REQUIRED is thrown by per-row mutation callbacks when the // install-wide listing case forgets to forward the row's workspaceId. @@ -30,43 +35,17 @@ const WORKSPACE_REQUIRED = "workspaceId required"; * the user doesn't see the previous workspace's stale rows during the swap. */ export function useLinearIssueWatches(workspaceId?: string | null) { - const items = useAppStore((s) => s.linearIssueWatches.items); - const loaded = useAppStore((s) => s.linearIssueWatches.loaded); - const loading = useAppStore((s) => s.linearIssueWatches.loading); - const setWatches = useAppStore((s) => s.setLinearIssueWatches); - const resetWatches = useAppStore((s) => s.resetLinearIssueWatches); - const setLoading = useAppStore((s) => s.setLinearIssueWatchesLoading); - const addWatch = useAppStore((s) => s.addLinearIssueWatch); - const updateWatch = useAppStore((s) => s.updateLinearIssueWatch); - const removeWatch = useAppStore((s) => s.removeLinearIssueWatch); - - const lastScope = useRef(undefined); - const scope: string | null = workspaceId ?? null; - - useEffect(() => { - if (workspaceId === null) return; - if (lastScope.current !== undefined && lastScope.current !== scope) { - resetWatches(); - } - lastScope.current = scope; - }, [workspaceId, scope, resetWatches]); - - useEffect(() => { - if (workspaceId === null || loaded || loading) return; - setLoading(true); - listLinearIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) - .then((res) => setWatches(res ?? [])) - .catch(() => setWatches([])) - .finally(() => setLoading(false)); - }, [workspaceId, loaded, loading, setWatches, setLoading]); + const queryClient = useQueryClient(); + const query = useQuery(linearIssueWatchesQueryOptions(workspaceId)); + const items = query.data ?? []; const create = useCallback( async (req: CreateLinearIssueWatchInput) => { const watch = await createLinearIssueWatch(req); - addWatch(watch); + patchLinearIssueWatchCaches(queryClient, workspaceId, watch); return watch; }, - [addWatch], + [queryClient, workspaceId], ); // Per-row mutations require the row's own workspace_id to satisfy the @@ -77,10 +56,10 @@ export function useLinearIssueWatches(workspaceId?: string | null) { const ws = rowWorkspaceId ?? workspaceId; if (!ws) throw new Error(WORKSPACE_REQUIRED); const watch = await updateLinearIssueWatch(ws, id, req); - updateWatch(watch); + patchLinearIssueWatchCaches(queryClient, workspaceId, watch); return watch; }, - [workspaceId, updateWatch], + [queryClient, workspaceId], ); const remove = useCallback( @@ -88,9 +67,9 @@ export function useLinearIssueWatches(workspaceId?: string | null) { const ws = rowWorkspaceId ?? workspaceId; if (!ws) throw new Error(WORKSPACE_REQUIRED); await deleteLinearIssueWatch(ws, id); - removeWatch(id); + removeLinearIssueWatchFromCaches(queryClient, workspaceId, id); }, - [workspaceId, removeWatch], + [queryClient, workspaceId], ); const trigger = useCallback( @@ -116,11 +95,57 @@ export function useLinearIssueWatches(workspaceId?: string | null) { const ws = rowWorkspaceId ?? workspaceId; if (!ws) throw new Error(WORKSPACE_REQUIRED); const res = await resetLinearIssueWatch(ws, id); - resetWatches(); + queryClient.invalidateQueries({ queryKey: qk.integrations.linear.issueWatches(workspaceId) }); + queryClient.invalidateQueries({ queryKey: qk.integrations.linear.issueWatches(undefined) }); + queryClient.invalidateQueries({ queryKey: qk.integrations.linear.issueWatches(ws) }); return res; }, - [workspaceId, resetWatches], + [queryClient, workspaceId], ); - return { items, loaded, loading, create, update, remove, trigger, previewReset, reset }; + return { + items, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + create, + update, + remove, + trigger, + previewReset, + reset, + }; +} + +function patchLinearIssueWatchCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + watch: LinearIssueWatch, +) { + const patch = (prev: LinearIssueWatch[] | undefined) => upsertById(prev ?? [], watch); + const patchExisting = (prev: LinearIssueWatch[] | undefined) => + prev ? upsertById(prev, watch) : prev; + queryClient.setQueryData(qk.integrations.linear.issueWatches(workspaceId), patch); + queryClient.setQueryData(qk.integrations.linear.issueWatches(undefined), patchExisting); + queryClient.setQueryData(qk.integrations.linear.issueWatches(watch.workspaceId), patch); +} + +function removeLinearIssueWatchFromCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + id: string, +) { + const remove = (prev: LinearIssueWatch[] | undefined) => + (prev ?? []).filter((watch) => watch.id !== id); + const removeExisting = (prev: LinearIssueWatch[] | undefined) => + prev ? prev.filter((watch) => watch.id !== id) : prev; + queryClient.setQueryData(qk.integrations.linear.issueWatches(workspaceId), remove); + queryClient.setQueryData(qk.integrations.linear.issueWatches(undefined), removeExisting); +} + +function upsertById(items: T[], next: T): T[] { + const index = items.findIndex((item) => item.id === next.id); + if (index === -1) return [...items, next]; + const copy = [...items]; + copy[index] = next; + return copy; } diff --git a/apps/web/hooks/domains/office/query-error.ts b/apps/web/hooks/domains/office/query-error.ts new file mode 100644 index 0000000000..3775572af1 --- /dev/null +++ b/apps/web/hooks/domains/office/query-error.ts @@ -0,0 +1,4 @@ +export function queryErrorMessage(error: unknown): string | null { + if (!error) return null; + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/web/hooks/domains/office/use-agent-route.ts b/apps/web/hooks/domains/office/use-agent-route.ts index 4bdcb014c8..61462ce29a 100644 --- a/apps/web/hooks/domains/office/use-agent-route.ts +++ b/apps/web/hooks/domains/office/use-agent-route.ts @@ -1,9 +1,11 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { getAgentRoute, updateAgentRouting } from "@/lib/api/domains/office-extended-api"; +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { officeAgentRouteQueryOptions } from "@/lib/query/query-options/office"; +import { updateAgentRouting } from "@/lib/api/domains/office-extended-api"; import type { AgentRouteData, AgentRoutingOverrides } from "@/lib/state/slices/office/types"; +import { queryErrorMessage } from "./query-error"; export type UseAgentRouteResult = { data: AgentRouteData | undefined; @@ -14,30 +16,12 @@ export type UseAgentRouteResult = { }; export function useAgentRoute(agentId: string | null): UseAgentRouteResult { - const data = useAppStore((s) => (agentId ? s.office.agentRouting.byAgentId[agentId] : undefined)); - const setAgentRouting = useAppStore((s) => s.setAgentRouting); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const query = useQuery(officeAgentRouteQueryOptions(agentId ?? "")); const refresh = useCallback(async () => { if (!agentId) return; - setIsLoading(true); - setError(null); - try { - const res = await getAgentRoute(agentId); - setAgentRouting(agentId, res); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load agent route"); - } finally { - setIsLoading(false); - } - }, [agentId, setAgentRouting]); - - useEffect(() => { - if (!agentId) return; - if (data !== undefined) return; - void refresh(); - }, [agentId, data, refresh]); + await query.refetch(); + }, [agentId, query]); const updateOverrides = useCallback( async (ov: AgentRoutingOverrides) => { @@ -48,5 +32,8 @@ export function useAgentRoute(agentId: string | null): UseAgentRouteResult { [agentId, refresh], ); - return { data, isLoading, error, refresh, updateOverrides }; + const data = query.data; + const error = queryErrorMessage(query.error); + + return { data, isLoading: query.isPending, error, refresh, updateOverrides }; } diff --git a/apps/web/hooks/domains/office/use-office-data.test.tsx b/apps/web/hooks/domains/office/use-office-data.test.tsx new file mode 100644 index 0000000000..1fd15bbaa8 --- /dev/null +++ b/apps/web/hooks/domains/office/use-office-data.test.tsx @@ -0,0 +1,220 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import type { OfficeMeta } from "@/lib/state/slices/office/types"; +import { + useOfficeActivityData, + useOfficeAgentsData, + useOfficeInboxData, + useOfficeMetaData, + useOfficeProjectsData, + useOfficeRoutinesData, + useOfficeSkillsData, +} from "./use-office-data"; + +type OfficeQueryClient = ReturnType; + +const officeApiMocks = vi.hoisted(() => ({ + getInbox: vi.fn(), + getMeta: vi.fn(), + listActivity: vi.fn(), + listAgentProfiles: vi.fn(), + listProjects: vi.fn(), + listRoutines: vi.fn(), +})); +const listSkillsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api/domains/office-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/office-api", + ); + return { + ...actual, + getInbox: officeApiMocks.getInbox, + getMeta: officeApiMocks.getMeta, + listActivity: officeApiMocks.listActivity, + listAgentProfiles: officeApiMocks.listAgentProfiles, + listProjects: officeApiMocks.listProjects, + listRoutines: officeApiMocks.listRoutines, + }; +}); + +vi.mock("@/lib/api/domains/office-skills-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/office-skills-api", + ); + return { + ...actual, + listSkills: listSkillsMock, + }; +}); + +function meta(overrides: Partial = {}): OfficeMeta { + return { + statuses: [{ id: "todo", label: "Todo", color: "text-blue-600", order: 1 }], + priorities: [{ id: "medium", label: "Medium", color: "text-yellow-600", order: 2, value: 2 }], + roles: [{ id: "worker", label: "Worker", description: "Worker agent", color: "bg-blue-100" }], + executorTypes: [{ id: "local_pc", label: "Local", description: "Local executor" }], + skillSourceTypes: [ + { + id: "inline", + label: "Inline", + readOnly: false, + }, + ], + projectStatuses: [{ id: "active", label: "Active", color: "bg-green-100" }], + agentStatuses: [{ id: "idle", label: "Idle", color: "bg-neutral-400" }], + routineRunStatuses: [{ id: "done", label: "Done", color: "bg-green-100" }], + inboxItemTypes: [{ id: "approval", label: "Approval", icon: "shield-check" }], + permissions: [], + permissionDefaults: {}, + ...overrides, + }; +} + +function officeWrapper(queryClient: OfficeQueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function seedOfficeSnapshots(queryClient: OfficeQueryClient, workspaceId: string) { + const cachedAgents = { agents: [{ id: "agent-old" }] }; + const cachedProjects = { projects: [{ id: "project-old" }] }; + const cachedRoutines = { routines: [{ id: "routine-old" }] }; + const cachedSkills = { skills: [{ id: "skill-old" }] }; + const cachedInbox = { items: [{ id: "inbox-old" }], total_count: 1 }; + const cachedActivity = { activity: [{ id: "activity-old" }] }; + queryClient.setQueryData(qk.office.agents(workspaceId), cachedAgents); + queryClient.setQueryData(qk.office.projects(workspaceId), cachedProjects); + queryClient.setQueryData(qk.office.routines(workspaceId), cachedRoutines); + queryClient.setQueryData(qk.office.skills(workspaceId), cachedSkills); + queryClient.setQueryData(qk.office.inbox(workspaceId), cachedInbox); + queryClient.setQueryData(qk.office.activity(workspaceId, "all"), cachedActivity); + return { + cachedActivity, + cachedAgents, + cachedInbox, + cachedProjects, + cachedRoutines, + cachedSkills, + }; +} + +function mockEmptyOfficeResponses() { + officeApiMocks.listAgentProfiles.mockResolvedValue({ agents: [] }); + officeApiMocks.listProjects.mockResolvedValue({ projects: [] }); + officeApiMocks.listRoutines.mockResolvedValue({ routines: [] }); + officeApiMocks.getInbox.mockResolvedValue({ items: [], total_count: 0 }); + officeApiMocks.listActivity.mockResolvedValue({ activity: [] }); + listSkillsMock.mockResolvedValue({ skills: [] }); +} + +function renderOfficeSnapshotHooks( + queryClient: OfficeQueryClient, + workspaceId: string, + initialData: "empty" | "absent", +) { + renderHook( + () => { + if (initialData === "empty") { + useOfficeAgentsData(workspaceId, []); + useOfficeProjectsData(workspaceId, []); + useOfficeRoutinesData(workspaceId, []); + useOfficeSkillsData(workspaceId, []); + useOfficeInboxData(workspaceId, [], 0); + useOfficeActivityData(workspaceId, "all", []); + return; + } + useOfficeAgentsData(workspaceId); + useOfficeProjectsData(workspaceId); + useOfficeRoutinesData(workspaceId); + useOfficeSkillsData(workspaceId); + useOfficeInboxData(workspaceId); + useOfficeActivityData(workspaceId, "all"); + }, + { wrapper: officeWrapper(queryClient) }, + ); +} + +describe("useOfficeMetaData", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("reads already seeded office meta from the query cache", () => { + const queryClient = makeQueryClient(); + const seeded = meta(); + queryClient.setQueryData(qk.office.meta(), seeded); + + const { result } = renderHook(() => useOfficeMetaData(), { + wrapper: officeWrapper(queryClient), + }); + + expect(result.current.data).toEqual(seeded); + expect(officeApiMocks.getMeta).not.toHaveBeenCalled(); + }); + + it("seeds initial office meta into the query cache", async () => { + const queryClient = makeQueryClient(); + const initial = meta({ + executorTypes: [{ id: "sprites", label: "Sprites", description: "Remote executor" }], + }); + + const { result } = renderHook(() => useOfficeMetaData(initial), { + wrapper: officeWrapper(queryClient), + }); + + expect(result.current.data).toEqual(initial); + await waitFor(() => { + expect(queryClient.getQueryData(qk.office.meta())).toEqual(initial); + }); + expect(officeApiMocks.getMeta).not.toHaveBeenCalled(); + }); + + it("seeds explicit empty office snapshots over cached lists", async () => { + const workspaceId = "workspace-1"; + const queryClient = makeQueryClient(); + seedOfficeSnapshots(queryClient, workspaceId); + mockEmptyOfficeResponses(); + + renderOfficeSnapshotHooks(queryClient, workspaceId, "empty"); + + await waitFor(() => { + expect(queryClient.getQueryData(qk.office.agents(workspaceId))).toEqual({ agents: [] }); + expect(queryClient.getQueryData(qk.office.projects(workspaceId))).toEqual({ projects: [] }); + expect(queryClient.getQueryData(qk.office.routines(workspaceId))).toEqual({ routines: [] }); + expect(queryClient.getQueryData(qk.office.skills(workspaceId))).toEqual({ skills: [] }); + expect(queryClient.getQueryData(qk.office.inbox(workspaceId))).toEqual({ + items: [], + total_count: 0, + }); + expect(queryClient.getQueryData(qk.office.activity(workspaceId, "all"))).toEqual({ + activity: [], + }); + }); + }); + + it("does not overwrite cached office snapshots when initial route data is absent", async () => { + const workspaceId = "workspace-1"; + const queryClient = makeQueryClient(); + const cached = seedOfficeSnapshots(queryClient, workspaceId); + + renderOfficeSnapshotHooks(queryClient, workspaceId, "absent"); + + await waitFor(() => { + expect(queryClient.getQueryData(qk.office.agents(workspaceId))).toBe(cached.cachedAgents); + expect(queryClient.getQueryData(qk.office.projects(workspaceId))).toBe(cached.cachedProjects); + expect(queryClient.getQueryData(qk.office.routines(workspaceId))).toBe(cached.cachedRoutines); + expect(queryClient.getQueryData(qk.office.skills(workspaceId))).toBe(cached.cachedSkills); + expect(queryClient.getQueryData(qk.office.inbox(workspaceId))).toBe(cached.cachedInbox); + expect(queryClient.getQueryData(qk.office.activity(workspaceId, "all"))).toBe( + cached.cachedActivity, + ); + }); + }); +}); diff --git a/apps/web/hooks/domains/office/use-office-data.ts b/apps/web/hooks/domains/office/use-office-data.ts new file mode 100644 index 0000000000..eb91ed75d7 --- /dev/null +++ b/apps/web/hooks/domains/office/use-office-data.ts @@ -0,0 +1,144 @@ +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import { + officeActivityQueryOptions, + officeAgentsQueryOptions, + officeDashboardQueryOptions, + officeInboxQueryOptions, + officeMetaQueryOptions, + officeProjectsQueryOptions, + officeRoutinesQueryOptions, + officeSkillsQueryOptions, +} from "@/lib/query/query-options/office"; +import type { + ActivityEntry, + AgentProfile, + DashboardData, + InboxItem, + OfficeMeta, + Project, + Routine, + Skill, +} from "@/lib/state/slices/office/types"; + +export function useOfficeMetaData(initialMeta?: OfficeMeta | null) { + const queryClient = useQueryClient(); + const query = useQuery({ + ...officeMetaQueryOptions(), + initialData: initialMeta ?? undefined, + }); + + useEffect(() => { + if (!initialMeta) return; + queryClient.setQueryData(qk.office.meta(), initialMeta); + }, [initialMeta, queryClient]); + + return query; +} + +export function useOfficeDashboardData( + workspaceId: string | null, + initialDashboard?: DashboardData | null, +) { + const queryClient = useQueryClient(); + const query = useQuery(officeDashboardQueryOptions(workspaceId ?? "")); + + useEffect(() => { + if (!workspaceId || !initialDashboard) return; + queryClient.setQueryData(qk.office.dashboard(workspaceId), initialDashboard); + }, [initialDashboard, queryClient, workspaceId]); + + return query; +} + +export function useOfficeAgentsData(workspaceId: string | null, initialAgents?: AgentProfile[]) { + const queryClient = useQueryClient(); + const query = useQuery(officeAgentsQueryOptions(workspaceId ?? "")); + + useEffect(() => { + if (!workspaceId || initialAgents === undefined) return; + queryClient.setQueryData(qk.office.agents(workspaceId), { agents: initialAgents }); + }, [initialAgents, queryClient, workspaceId]); + + return query; +} + +export function useOfficeProjectsData(workspaceId: string | null, initialProjects?: Project[]) { + const queryClient = useQueryClient(); + const query = useQuery(officeProjectsQueryOptions(workspaceId ?? "")); + + useEffect(() => { + if (!workspaceId || initialProjects === undefined) return; + queryClient.setQueryData(qk.office.projects(workspaceId), { projects: initialProjects }); + }, [initialProjects, queryClient, workspaceId]); + + return query; +} + +export function useOfficeRoutinesData(workspaceId: string | null, initialRoutines?: Routine[]) { + const queryClient = useQueryClient(); + const query = useQuery(officeRoutinesQueryOptions(workspaceId ?? "")); + + useEffect(() => { + if (!workspaceId || initialRoutines === undefined) return; + queryClient.setQueryData(qk.office.routines(workspaceId), { routines: initialRoutines }); + }, [initialRoutines, queryClient, workspaceId]); + + return query; +} + +export function useOfficeSkillsData(workspaceId: string | null, initialSkills?: Skill[]) { + const queryClient = useQueryClient(); + const query = useQuery(officeSkillsQueryOptions(workspaceId ?? "")); + + useEffect(() => { + if (!workspaceId || initialSkills === undefined) return; + queryClient.setQueryData(qk.office.skills(workspaceId), { skills: initialSkills }); + }, [initialSkills, queryClient, workspaceId]); + + return query; +} + +export function useOfficeInboxData( + workspaceId: string | null, + initialItems?: InboxItem[], + initialCount?: number, +) { + const queryClient = useQueryClient(); + const inboxQuery = useQuery(officeInboxQueryOptions(workspaceId ?? "")); + const agentsQuery = useOfficeAgentsData(workspaceId); + + useEffect(() => { + if (!workspaceId || initialItems === undefined) return; + queryClient.setQueryData(qk.office.inbox(workspaceId), { + items: initialItems, + total_count: initialCount ?? initialItems.length, + }); + }, [initialCount, initialItems, queryClient, workspaceId]); + + return { + ...inboxQuery, + refetchAll: async () => { + await Promise.all([inboxQuery.refetch(), agentsQuery.refetch()]); + }, + }; +} + +export function useOfficeActivityData( + workspaceId: string | null, + filterType = "all", + initialActivity?: ActivityEntry[], +) { + const queryClient = useQueryClient(); + const query = useQuery(officeActivityQueryOptions(workspaceId ?? "", filterType)); + + useEffect(() => { + if (!workspaceId || filterType !== "all" || initialActivity === undefined) return; + queryClient.setQueryData(qk.office.activity(workspaceId, filterType), { + activity: initialActivity, + }); + }, [filterType, initialActivity, queryClient, workspaceId]); + + return query; +} diff --git a/apps/web/hooks/domains/office/use-provider-health.ts b/apps/web/hooks/domains/office/use-provider-health.ts index a289a9a6a4..83ea7e66f3 100644 --- a/apps/web/hooks/domains/office/use-provider-health.ts +++ b/apps/web/hooks/domains/office/use-provider-health.ts @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { getProviderHealth } from "@/lib/api/domains/office-extended-api"; +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { officeProviderHealthQueryOptions } from "@/lib/query/query-options/office"; import type { ProviderHealth } from "@/lib/state/slices/office/types"; +import { queryErrorMessage } from "./query-error"; export type UseProviderHealthResult = { health: ProviderHealth[]; @@ -15,35 +16,15 @@ export type UseProviderHealthResult = { const EMPTY_HEALTH: ProviderHealth[] = []; export function useProviderHealth(workspaceName: string | null): UseProviderHealthResult { - const health = useAppStore((s) => - workspaceName - ? (s.office.providerHealth.byWorkspace[workspaceName] ?? EMPTY_HEALTH) - : EMPTY_HEALTH, - ); - const setProviderHealth = useAppStore((s) => s.setProviderHealth); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [fetched, setFetched] = useState(false); + const query = useQuery(officeProviderHealthQueryOptions(workspaceName ?? "")); const refresh = useCallback(async () => { if (!workspaceName) return; - setIsLoading(true); - setError(null); - try { - const res = await getProviderHealth(workspaceName); - setProviderHealth(workspaceName, res.health ?? []); - setFetched(true); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load provider health"); - } finally { - setIsLoading(false); - } - }, [workspaceName, setProviderHealth]); + await query.refetch(); + }, [query, workspaceName]); - useEffect(() => { - if (!workspaceName || fetched) return; - void refresh(); - }, [workspaceName, fetched, refresh]); + const queryHealth = query.data?.health ?? EMPTY_HEALTH; + const error = queryErrorMessage(query.error); - return { health, isLoading, error, refresh }; + return { health: queryHealth, isLoading: query.isPending, error, refresh }; } diff --git a/apps/web/hooks/domains/office/use-routing-preview.ts b/apps/web/hooks/domains/office/use-routing-preview.ts index 7c2a9c3380..eadf0a6ec0 100644 --- a/apps/web/hooks/domains/office/use-routing-preview.ts +++ b/apps/web/hooks/domains/office/use-routing-preview.ts @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { getRoutingPreview } from "@/lib/api/domains/office-extended-api"; +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { officeRoutingPreviewQueryOptions } from "@/lib/query/query-options/office"; import type { AgentRoutePreview } from "@/lib/state/slices/office/types"; +import { queryErrorMessage } from "./query-error"; export type UseRoutingPreviewResult = { agents: AgentRoutePreview[]; @@ -15,35 +16,15 @@ export type UseRoutingPreviewResult = { const EMPTY_PREVIEW: AgentRoutePreview[] = []; export function useRoutingPreview(workspaceName: string | null): UseRoutingPreviewResult { - const agents = useAppStore((s) => - workspaceName - ? (s.office.routing.preview.byWorkspace[workspaceName] ?? EMPTY_PREVIEW) - : EMPTY_PREVIEW, - ); - const setRoutingPreview = useAppStore((s) => s.setRoutingPreview); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [fetched, setFetched] = useState(false); + const query = useQuery(officeRoutingPreviewQueryOptions(workspaceName ?? "")); const refresh = useCallback(async () => { if (!workspaceName) return; - setIsLoading(true); - setError(null); - try { - const res = await getRoutingPreview(workspaceName); - setRoutingPreview(workspaceName, res.agents ?? []); - setFetched(true); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load routing preview"); - } finally { - setIsLoading(false); - } - }, [workspaceName, setRoutingPreview]); + await query.refetch(); + }, [query, workspaceName]); - useEffect(() => { - if (!workspaceName || fetched) return; - void refresh(); - }, [workspaceName, fetched, refresh]); + const queryAgents = query.data?.agents ?? EMPTY_PREVIEW; + const error = queryErrorMessage(query.error); - return { agents, isLoading, error, refresh }; + return { agents: queryAgents, isLoading: query.isPending, error, refresh }; } diff --git a/apps/web/hooks/domains/office/use-routing-query-hooks.test.tsx b/apps/web/hooks/domains/office/use-routing-query-hooks.test.tsx new file mode 100644 index 0000000000..6ea2c955e9 --- /dev/null +++ b/apps/web/hooks/domains/office/use-routing-query-hooks.test.tsx @@ -0,0 +1,139 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { + AgentRouteData, + AgentRoutePreview, + ProviderHealth, + RouteAttempt, +} from "@/lib/state/slices/office/types"; +import { useAgentRoute } from "./use-agent-route"; +import { useProviderHealth } from "./use-provider-health"; +import { useRoutingPreview } from "./use-routing-preview"; +import { useRunAttempts } from "./use-run-attempts"; + +const routingMocks = vi.hoisted(() => ({ + getAgentRoute: vi.fn(), + getProviderHealth: vi.fn(), + getRoutingPreview: vi.fn(), + getWorkspaceRouting: vi.fn(), +})); + +const runMocks = vi.hoisted(() => ({ + getRunAttempts: vi.fn(), +})); + +vi.mock("@/lib/api/domains/office-routing-api", () => ({ + getAgentRoute: routingMocks.getAgentRoute, + getProviderHealth: routingMocks.getProviderHealth, + getRoutingPreview: routingMocks.getRoutingPreview, + getWorkspaceRouting: routingMocks.getWorkspaceRouting, +})); + +vi.mock("@/lib/api/domains/office-runs-api", () => ({ + getRunAttempts: runMocks.getRunAttempts, +})); + +describe("Office routing query hooks", () => { + beforeEach(() => { + routingMocks.getAgentRoute.mockReset(); + routingMocks.getProviderHealth.mockReset(); + routingMocks.getRoutingPreview.mockReset(); + routingMocks.getWorkspaceRouting.mockReset(); + runMocks.getRunAttempts.mockReset(); + }); + + it("reads provider health from the query result", async () => { + const health: ProviderHealth[] = [ + { + provider_id: "claude-acp", + scope: "provider", + scope_value: "claude-acp", + state: "healthy", + backoff_step: 0, + }, + ]; + routingMocks.getProviderHealth.mockResolvedValue({ health }); + + const { result } = renderHook(() => useProviderHealth("ws-1"), { + wrapper: createQueryWrapper(), + }); + + await waitFor(() => expect(result.current.health).toEqual(health)); + }); + + it("reads routing preview agents from the query result", async () => { + const agents: AgentRoutePreview[] = [ + { + agent_id: "agent-1", + agent_name: "Builder", + tier_source: "inherit", + effective_tier: "balanced", + fallback_chain: [], + missing: [], + degraded: false, + }, + ]; + routingMocks.getRoutingPreview.mockResolvedValue({ agents }); + + const { result } = renderHook(() => useRoutingPreview("ws-1"), { + wrapper: createQueryWrapper(), + }); + + await waitFor(() => expect(result.current.agents).toEqual(agents)); + }); + + it("reads run attempts from the query result", async () => { + const attempts: RouteAttempt[] = [ + { + seq: 1, + provider_id: "claude-acp", + tier: "balanced", + outcome: "launched", + started_at: "2026-01-01T00:00:00Z", + }, + ]; + runMocks.getRunAttempts.mockResolvedValue({ attempts }); + + const { result } = renderHook(() => useRunAttempts("run-1"), { + wrapper: createQueryWrapper(), + }); + + await waitFor(() => expect(result.current.attempts).toEqual(attempts)); + }); + + it("reads agent route data from the query result", async () => { + const route: AgentRouteData = { + preview: { + agent_id: "agent-1", + agent_name: "Builder", + tier_source: "override", + effective_tier: "frontier", + fallback_chain: [], + missing: [], + degraded: false, + }, + overrides: { tier_source: "override", tier: "frontier" }, + }; + routingMocks.getAgentRoute.mockResolvedValue(route); + + const { result } = renderHook(() => useAgentRoute("agent-1"), { + wrapper: createQueryWrapper(), + }); + + await waitFor(() => expect(result.current.data).toEqual(route)); + }); +}); + +function createQueryWrapper() { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + return function Wrapper({ children }: PropsWithChildren) { + return {children}; + }; +} diff --git a/apps/web/hooks/domains/office/use-run-attempts.ts b/apps/web/hooks/domains/office/use-run-attempts.ts index b47dc8789c..f9367c1d0e 100644 --- a/apps/web/hooks/domains/office/use-run-attempts.ts +++ b/apps/web/hooks/domains/office/use-run-attempts.ts @@ -1,9 +1,10 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { getRunAttempts } from "@/lib/api/domains/office-runs-api"; +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { officeRunAttemptsQueryOptions } from "@/lib/query/query-options/office"; import type { RouteAttempt } from "@/lib/state/slices/office/types"; +import { queryErrorMessage } from "./query-error"; export type UseRunAttemptsResult = { attempts: RouteAttempt[]; @@ -15,33 +16,15 @@ export type UseRunAttemptsResult = { const EMPTY_ATTEMPTS: RouteAttempt[] = []; export function useRunAttempts(runId: string | null): UseRunAttemptsResult { - const attempts = useAppStore((s) => - runId ? (s.office.runAttempts.byRunId[runId] ?? EMPTY_ATTEMPTS) : EMPTY_ATTEMPTS, - ); - const setRunAttempts = useAppStore((s) => s.setRunAttempts); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - const [fetched, setFetched] = useState(false); + const query = useQuery(officeRunAttemptsQueryOptions(runId ?? "")); const refresh = useCallback(async () => { if (!runId) return; - setIsLoading(true); - setError(null); - try { - const res = await getRunAttempts(runId); - setRunAttempts(runId, res.attempts ?? []); - setFetched(true); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load route attempts"); - } finally { - setIsLoading(false); - } - }, [runId, setRunAttempts]); + await query.refetch(); + }, [query, runId]); - useEffect(() => { - if (!runId || fetched) return; - void refresh(); - }, [runId, fetched, refresh]); + const queryAttempts = query.data?.attempts ?? EMPTY_ATTEMPTS; + const error = queryErrorMessage(query.error); - return { attempts, isLoading, error, refresh }; + return { attempts: queryAttempts, isLoading: query.isPending, error, refresh }; } diff --git a/apps/web/hooks/domains/office/use-workspace-routing.test.tsx b/apps/web/hooks/domains/office/use-workspace-routing.test.tsx index 8805895732..84544bbda4 100644 --- a/apps/web/hooks/domains/office/use-workspace-routing.test.tsx +++ b/apps/web/hooks/domains/office/use-workspace-routing.test.tsx @@ -1,5 +1,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { PropsWithChildren } from "react"; +import { qk } from "@/lib/query/keys"; +import type { WorkspaceRouting } from "@/lib/state/slices/office/types"; import { useWorkspaceRouting } from "./use-workspace-routing"; const mocks = vi.hoisted(() => ({ @@ -9,30 +13,21 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("@/lib/api/domains/office-extended-api", () => ({ - getWorkspaceRouting: mocks.getWorkspaceRouting, retryProvider: mocks.retryProvider, updateWorkspaceRouting: mocks.updateWorkspaceRouting, })); -const setKnownProviders = vi.fn(); -const setWorkspaceRouting = vi.fn(); - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (sel: (state: unknown) => unknown) => - sel({ - office: { - routing: { byWorkspace: {}, knownProviders: [] }, - }, - setKnownProviders, - setWorkspaceRouting, - }), +vi.mock("@/lib/api/domains/office-routing-api", () => ({ + getWorkspaceRouting: mocks.getWorkspaceRouting, })); +const WORKSPACE_ID = "ws-1"; +const PROVIDER_ID = "claude-acp"; + describe("useWorkspaceRouting", () => { beforeEach(() => { - setKnownProviders.mockReset(); - setWorkspaceRouting.mockReset(); mocks.getWorkspaceRouting.mockReset(); + mocks.updateWorkspaceRouting.mockReset(); mocks.getWorkspaceRouting.mockResolvedValue({ config: { enabled: false, @@ -40,23 +35,63 @@ describe("useWorkspaceRouting", () => { default_tier: "balanced", provider_profiles: {}, }, - known_providers: ["claude-acp"], + known_providers: [PROVIDER_ID], }); }); it("fetches once on mount when there is no cached config", async () => { - const { unmount } = renderHook(() => useWorkspaceRouting("ws-1")); + const { wrapper } = createQueryWrapper(); + const { unmount } = renderHook(() => useWorkspaceRouting(WORKSPACE_ID), { + wrapper, + }); await waitFor(() => expect(mocks.getWorkspaceRouting).toHaveBeenCalledTimes(1)); - expect(setKnownProviders).toHaveBeenCalled(); - expect(setWorkspaceRouting).toHaveBeenCalled(); unmount(); }); + it("updates the routing query cache without a store mirror", async () => { + const { client, wrapper } = createQueryWrapper(); + const nextConfig: WorkspaceRouting = { + enabled: true, + provider_order: [PROVIDER_ID], + default_tier: "balanced", + provider_profiles: {}, + }; + + const { result } = renderHook(() => useWorkspaceRouting(WORKSPACE_ID), { wrapper }); + + await waitFor(() => expect(result.current.knownProviders).toEqual([PROVIDER_ID])); + await act(async () => { + await result.current.update(nextConfig); + }); + + expect(mocks.updateWorkspaceRouting).toHaveBeenCalledWith(WORKSPACE_ID, nextConfig); + expect(client.getQueryData(qk.office.routing(WORKSPACE_ID))).toEqual({ + config: nextConfig, + known_providers: [PROVIDER_ID], + }); + }); + it("does not call setInterval (no polling)", () => { const spy = vi.spyOn(globalThis, "setInterval"); - const { unmount } = renderHook(() => useWorkspaceRouting("ws-1")); + const { wrapper } = createQueryWrapper(); + const { unmount } = renderHook(() => useWorkspaceRouting(WORKSPACE_ID), { + wrapper, + }); expect(spy).not.toHaveBeenCalled(); unmount(); spy.mockRestore(); }); }); + +function createQueryWrapper() { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const wrapper = function Wrapper({ children }: PropsWithChildren) { + return {children}; + }; + return { client, wrapper }; +} diff --git a/apps/web/hooks/domains/office/use-workspace-routing.ts b/apps/web/hooks/domains/office/use-workspace-routing.ts index 6b29eb6196..a4dbe90bfd 100644 --- a/apps/web/hooks/domains/office/use-workspace-routing.ts +++ b/apps/web/hooks/domains/office/use-workspace-routing.ts @@ -1,13 +1,12 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { - getWorkspaceRouting, - retryProvider, - updateWorkspaceRouting, -} from "@/lib/api/domains/office-extended-api"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import { officeRoutingQueryOptions } from "@/lib/query/query-options/office"; +import { retryProvider, updateWorkspaceRouting } from "@/lib/api/domains/office-extended-api"; import type { WorkspaceRouting } from "@/lib/state/slices/office/types"; +import { queryErrorMessage } from "./query-error"; export type UseWorkspaceRoutingResult = { config: WorkspaceRouting | undefined; @@ -20,43 +19,24 @@ export type UseWorkspaceRoutingResult = { }; export function useWorkspaceRouting(workspaceName: string | null): UseWorkspaceRoutingResult { - const config = useAppStore((s) => - workspaceName ? s.office.routing.byWorkspace[workspaceName] : undefined, - ); - const knownProviders = useAppStore((s) => s.office.routing.knownProviders); - const setWorkspaceRouting = useAppStore((s) => s.setWorkspaceRouting); - const setKnownProviders = useAppStore((s) => s.setKnownProviders); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); + const queryClient = useQueryClient(); + const query = useQuery(officeRoutingQueryOptions(workspaceName ?? "")); const refresh = useCallback(async () => { if (!workspaceName) return; - setIsLoading(true); - setError(null); - try { - const res = await getWorkspaceRouting(workspaceName); - if (res.config) setWorkspaceRouting(workspaceName, res.config); - if (Array.isArray(res.known_providers)) setKnownProviders(res.known_providers); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to load routing config"); - } finally { - setIsLoading(false); - } - }, [workspaceName, setWorkspaceRouting, setKnownProviders]); - - useEffect(() => { - if (!workspaceName) return; - if (config !== undefined) return; - void refresh(); - }, [workspaceName, config, refresh]); + await query.refetch(); + }, [query, workspaceName]); const update = useCallback( async (cfg: WorkspaceRouting) => { if (!workspaceName) return; await updateWorkspaceRouting(workspaceName, cfg); - setWorkspaceRouting(workspaceName, cfg); + queryClient.setQueryData(qk.office.routing(workspaceName), { + config: cfg, + known_providers: query.data?.known_providers ?? [], + }); }, - [workspaceName, setWorkspaceRouting], + [query.data?.known_providers, queryClient, workspaceName], ); const retry = useCallback( @@ -67,5 +47,9 @@ export function useWorkspaceRouting(workspaceName: string | null): UseWorkspaceR [workspaceName], ); - return { config, knownProviders, isLoading, error, refresh, update, retry }; + const config = query.data?.config ?? undefined; + const knownProviders = query.data?.known_providers ?? []; + const error = queryErrorMessage(query.error); + + return { config, knownProviders, isLoading: query.isPending, error, refresh, update, retry }; } diff --git a/apps/web/hooks/domains/sentry/use-sentry-availability.ts b/apps/web/hooks/domains/sentry/use-sentry-availability.ts index 0fbff8f147..a1e19ac7f2 100644 --- a/apps/web/hooks/domains/sentry/use-sentry-availability.ts +++ b/apps/web/hooks/domains/sentry/use-sentry-availability.ts @@ -1,9 +1,9 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { listSentryInstances } from "@/lib/api/domains/sentry-api"; +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { sentryInstancesQueryOptions } from "@/lib/query/query-options/sentry"; import type { SentryConfig } from "@/lib/types/sentry"; -import { INTEGRATION_STATUS_REFRESH_MS } from "../integrations/use-integration-availability"; import { useSentryEnabled } from "./use-sentry-enabled"; // isHealthySentryInstance is the single definition of a usable instance: @@ -29,51 +29,18 @@ export type SentryAvailability = { state: SentryAvailabilityState; }; -// useSentryInstances polls a workspace's Sentry instances (respecting the -// per-workspace enabled toggle) and derives the availability state the browse -// surfaces and settings banner consume. Fetches are request-versioned so a slow -// response for a previous workspace can't clobber a newer one. +// useSentryInstances reads a workspace's Sentry instances from Query +// (respecting the per-workspace enabled toggle) and derives the availability +// state the browse surfaces and settings banner consume. export function useSentryInstances(workspaceId?: string | null): SentryAvailability { const { enabled, loaded } = useSentryEnabled(); const active = loaded && enabled && !!workspaceId; - const [instances, setInstances] = useState([]); - const [loading, setLoading] = useState(true); - const requestId = useRef(0); - - useEffect(() => { - if (!active || !workspaceId) { - // Drop any state carried over from a previous workspace / enabled toggle - // so a disabled integration shows no stale instances. - setInstances([]); - setLoading(false); - return; - } - setLoading(true); - setInstances([]); - let cancelled = false; - const refresh = async () => { - const current = ++requestId.current; - try { - const list = await listSentryInstances(workspaceId); - if (cancelled || current !== requestId.current) return; - setInstances(list); - } catch { - if (cancelled || current !== requestId.current) return; - setInstances([]); - } finally { - if (!cancelled && current === requestId.current) setLoading(false); - } - }; - void refresh(); - const id = setInterval(() => void refresh(), INTEGRATION_STATUS_REFRESH_MS); - return () => { - cancelled = true; - clearInterval(id); - }; - }, [active, workspaceId]); + const query = useQuery(sentryInstancesQueryOptions(active ? workspaceId : null)); return useMemo(() => { + const instances = active ? (query.data ?? []) : []; const healthy = instances.filter(isHealthySentryInstance); + const loading = active && (query.isPending || (query.isFetching && !query.data)); const available = active && healthy.length >= 1; let state: SentryAvailabilityState; if (loading) state = "loading"; @@ -82,7 +49,11 @@ export function useSentryInstances(workspaceId?: string | null): SentryAvailabil else if (healthy.length === 1) state = "single"; else state = "multi"; return { loading, instances, healthy, available, state }; - }, [active, instances, loading]); + }, [active, query.data, query.isFetching, query.isPending]); +} + +export function useSentryAuthed(workspaceId?: string | null): boolean { + return useSentryInstances(workspaceId).instances.some((instance) => instance.hasSecret); } // useSentryAvailable is the boolean gate that shows/hides Sentry entry points: diff --git a/apps/web/hooks/domains/sentry/use-sentry-issue-watches.ts b/apps/web/hooks/domains/sentry/use-sentry-issue-watches.ts index 515f38a542..2bac854b7f 100644 --- a/apps/web/hooks/domains/sentry/use-sentry-issue-watches.ts +++ b/apps/web/hooks/domains/sentry/use-sentry-issue-watches.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useCallback, useRef, useState } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - listSentryIssueWatches, createSentryIssueWatch, updateSentryIssueWatch, deleteSentryIssueWatch, @@ -10,6 +10,8 @@ import { previewResetSentryIssueWatch, resetSentryIssueWatch, } from "@/lib/api/domains/sentry-api"; +import { qk } from "@/lib/query/keys"; +import { sentryIssueWatchesQueryOptions } from "@/lib/query/query-options/sentry"; import type { CreateSentryIssueWatchRequest, SentryIssueWatch, @@ -27,80 +29,35 @@ import type { * cross-workspace mutations. */ export function useSentryIssueWatches(workspaceId?: string | null) { - const [items, setItems] = useState([]); - const [loaded, setLoaded] = useState(false); - const [loading, setLoading] = useState(false); + const queryClient = useQueryClient(); + const query = useQuery(sentryIssueWatchesQueryOptions(workspaceId)); + const items = query.data ?? []; - // Track the raw workspaceId (not `?? null`) so the three scopes stay distinct: - // undefined = fetch all, null = don't fetch, string = one workspace. Collapsing - // undefined→null would hide an "all → none" transition and leave stale data. - const initialized = useRef(false); - const lastWorkspaceId = useRef(undefined); - - useEffect(() => { - if (initialized.current && lastWorkspaceId.current !== workspaceId) { - // Reset cached list on scope change (incl. → null). Also clear `loading`: - // a fetch from the old scope can no longer complete into the new one (its - // .finally is gated by `ignore`), and a → null scope starts no replacement - // fetch, so without this the hook would stay stuck at loading=true. - /* eslint-disable react-hooks/set-state-in-effect -- resetting cached state when scope changes */ - setItems([]); - setLoaded(false); - setLoading(false); - /* eslint-enable react-hooks/set-state-in-effect */ - } - initialized.current = true; - lastWorkspaceId.current = workspaceId; - }, [workspaceId]); - - useEffect(() => { - if (workspaceId === null || loaded) return; - // `loading` is intentionally NOT a dependency: setLoading(true) below would - // otherwise re-run this effect, whose cleanup sets ignore=true on the - // in-flight request, so its .finally skips setLoading(false) and the hook - // sticks at loading=true forever. The `loaded` guard already prevents a - // duplicate fetch, and `ignore` handles a workspaceId change mid-flight. - let ignore = false; - // eslint-disable-next-line react-hooks/set-state-in-effect -- starting external fetch - setLoading(true); - listSentryIssueWatches(workspaceId ?? undefined, { cache: "no-store" }) - .then((res) => { - if (ignore) return; - setItems(res ?? []); - setLoaded(true); - }) - .catch(() => { - if (ignore) return; - setItems([]); - setLoaded(true); - }) - .finally(() => { - if (!ignore) setLoading(false); - }); - return () => { - ignore = true; - }; - }, [workspaceId, loaded]); - - const create = useCallback(async (req: CreateSentryIssueWatchRequest) => { - const watch = await createSentryIssueWatch(req); - setItems((prev) => [...prev, watch]); - return watch; - }, []); + const create = useCallback( + async (req: CreateSentryIssueWatchRequest) => { + const watch = await createSentryIssueWatch(req); + patchSentryIssueWatchCaches(queryClient, workspaceId, watch); + return watch; + }, + [queryClient, workspaceId], + ); const update = useCallback( async (id: string, watchWorkspaceId: string, req: UpdateSentryIssueWatchRequest) => { const watch = await updateSentryIssueWatch(id, watchWorkspaceId, req); - setItems((prev) => prev.map((w) => (w.id === watch.id ? watch : w))); + patchSentryIssueWatchCaches(queryClient, workspaceId, watch); return watch; }, - [], + [queryClient, workspaceId], ); - const remove = useCallback(async (id: string, watchWorkspaceId: string) => { - await deleteSentryIssueWatch(id, watchWorkspaceId); - setItems((prev) => prev.filter((w) => w.id !== id)); - }, []); + const remove = useCallback( + async (id: string, watchWorkspaceId: string) => { + await deleteSentryIssueWatch(id, watchWorkspaceId); + removeSentryIssueWatchFromCaches(queryClient, workspaceId, id); + }, + [queryClient, workspaceId], + ); const trigger = useCallback(async (id: string, watchWorkspaceId: string) => { return triggerSentryIssueWatch(id, watchWorkspaceId); @@ -110,13 +67,62 @@ export function useSentryIssueWatches(workspaceId?: string | null) { return previewResetSentryIssueWatch(id, watchWorkspaceId); }, []); - const reset = useCallback(async (id: string, watchWorkspaceId: string) => { - const res = await resetSentryIssueWatch(id, watchWorkspaceId); - // Force the next render to refetch so the now-empty dedup set / cleared - // last_polled_at land in the UI immediately. - setLoaded(false); - return res; - }, []); + const reset = useCallback( + async (id: string, watchWorkspaceId: string) => { + const res = await resetSentryIssueWatch(id, watchWorkspaceId); + queryClient.invalidateQueries({ queryKey: qk.integrations.sentry.issueWatches(workspaceId) }); + queryClient.invalidateQueries({ queryKey: qk.integrations.sentry.issueWatches(undefined) }); + queryClient.invalidateQueries({ + queryKey: qk.integrations.sentry.issueWatches(watchWorkspaceId), + }); + return res; + }, + [queryClient, workspaceId], + ); + + return { + items, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + create, + update, + remove, + trigger, + previewReset, + reset, + }; +} + +function patchSentryIssueWatchCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + watch: SentryIssueWatch, +) { + const patch = (prev: SentryIssueWatch[] | undefined) => upsertById(prev ?? [], watch); + const patchExisting = (prev: SentryIssueWatch[] | undefined) => + prev ? upsertById(prev, watch) : prev; + queryClient.setQueryData(qk.integrations.sentry.issueWatches(workspaceId), patch); + queryClient.setQueryData(qk.integrations.sentry.issueWatches(undefined), patchExisting); + queryClient.setQueryData(qk.integrations.sentry.issueWatches(watch.workspaceId), patch); +} + +function removeSentryIssueWatchFromCaches( + queryClient: ReturnType, + workspaceId: string | null | undefined, + id: string, +) { + const remove = (prev: SentryIssueWatch[] | undefined) => + (prev ?? []).filter((watch) => watch.id !== id); + const removeExisting = (prev: SentryIssueWatch[] | undefined) => + prev ? prev.filter((watch) => watch.id !== id) : prev; + queryClient.setQueryData(qk.integrations.sentry.issueWatches(workspaceId), remove); + queryClient.setQueryData(qk.integrations.sentry.issueWatches(undefined), removeExisting); +} - return { items, loaded, loading, create, update, remove, trigger, previewReset, reset }; +function upsertById(items: T[], next: T): T[] { + const index = items.findIndex((item) => item.id === next.id); + if (index === -1) return [...items, next]; + const copy = [...items]; + copy[index] = next; + return copy; } diff --git a/apps/web/hooks/domains/session/session-message-fetch.test.ts b/apps/web/hooks/domains/session/session-message-fetch.test.ts new file mode 100644 index 0000000000..054273e8f6 --- /dev/null +++ b/apps/web/hooks/domains/session/session-message-fetch.test.ts @@ -0,0 +1,138 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { + sessionMessagesLatestQueryOptions, + type SessionMessagesLatestData, +} from "@/lib/query/query-options"; +import { listTaskSessionMessages } from "@/lib/api/domains/session-api"; +import type { Message } from "@/lib/types/http"; +import { sessionId as toSessionId, taskId as toTaskId } from "@/lib/types/http"; +import { + commitFetchSeq, + fetchAndStoreMessages, + INITIAL_FETCH_LIMIT, +} from "./session-message-fetch"; + +vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(), + listSessionTurns: vi.fn(), + listTaskSessionMessages: vi.fn(), + listTaskSessions: vi.fn(), + searchSessionMessages: vi.fn(), +})); + +const TASK_ID = toTaskId("task-1"); + +function makeMessage(id: string, createdAt: string): Message { + return { + id, + task_id: TASK_ID, + session_id: toSessionId("session-1"), + author_type: "agent", + content: id, + type: "message", + created_at: createdAt, + }; +} + +function makeStore(sessionId: string, messages: Message[]) { + const state: { + messages: { + bySession: Record; + metaBySession: Record< + string, + { isLoading: boolean; hasMore: boolean; oldestCursor: string | null } + >; + }; + mergeMessages: ( + nextSessionId: string, + nextMessages: Message[], + meta?: { hasMore?: boolean; oldestCursor?: string | null }, + ) => void; + } = { + messages: { + bySession: { [sessionId]: messages }, + metaBySession: {}, + }, + mergeMessages: vi.fn( + ( + nextSessionId: string, + nextMessages: Message[], + meta?: { hasMore?: boolean; oldestCursor?: string | null }, + ) => { + state.messages.bySession[nextSessionId] = nextMessages; + state.messages.metaBySession[nextSessionId] = { + isLoading: false, + hasMore: meta?.hasMore ?? false, + oldestCursor: meta?.oldestCursor ?? null, + }; + }, + ), + }; + return { getState: () => state }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("fetchAndStoreMessages", () => { + it("writes the merged store-preserved snapshot back to the latest messages query cache", async () => { + const sessionId = "session-1"; + const fetched = makeMessage("fetched", "2026-01-01T00:00:01Z"); + const wsOnly = makeMessage("ws-only", "2026-01-01T00:00:02Z"); + const queryClient = makeQueryClient(); + const store = makeStore(sessionId, [wsOnly]); + + vi.mocked(listTaskSessionMessages).mockResolvedValueOnce({ + messages: [fetched], + total: 1, + has_more: true, + cursor: "older", + }); + + const result = await fetchAndStoreMessages(sessionId, store as never, queryClient); + const cached = queryClient.getQueryData( + sessionMessagesLatestQueryOptions(sessionId, INITIAL_FETCH_LIMIT).queryKey, + ); + + expect(result.map((message) => message.id)).toEqual(["fetched", "ws-only"]); + expect(cached?.messages.map((message) => message.id)).toEqual(["fetched", "ws-only"]); + expect(store.getState().mergeMessages).toHaveBeenCalledWith( + sessionId, + expect.arrayContaining([fetched, wsOnly]), + expect.objectContaining({ hasMore: true, oldestCursor: "fetched" }), + ); + }); + + it("restores the current store snapshot into the query cache when a fetch sequence is stale", async () => { + const sessionId = "session-stale"; + const fetched = { + ...makeMessage("fetched", "2026-01-01T00:00:01Z"), + session_id: toSessionId(sessionId), + }; + const current = { + ...makeMessage("current", "2026-01-01T00:00:02Z"), + session_id: toSessionId(sessionId), + }; + const queryClient = makeQueryClient(); + const store = makeStore(sessionId, [current]); + commitFetchSeq(sessionId, 999); + + vi.mocked(listTaskSessionMessages).mockResolvedValueOnce({ + messages: [fetched], + total: 1, + has_more: false, + cursor: "", + }); + + const result = await fetchAndStoreMessages(sessionId, store as never, queryClient); + const cached = queryClient.getQueryData( + sessionMessagesLatestQueryOptions(sessionId, INITIAL_FETCH_LIMIT).queryKey, + ); + + expect(result.map((message) => message.id)).toEqual(["current"]); + expect(cached?.messages.map((message) => message.id)).toEqual(["current"]); + expect(store.getState().mergeMessages).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/hooks/domains/session/session-message-fetch.ts b/apps/web/hooks/domains/session/session-message-fetch.ts new file mode 100644 index 0000000000..9a4274a89d --- /dev/null +++ b/apps/web/hooks/domains/session/session-message-fetch.ts @@ -0,0 +1,285 @@ +import { QueryClient } from "@tanstack/react-query"; +import type { useAppStoreApi } from "@/components/state-provider"; +import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { + type SessionMessagesLatestData, + sessionMessagesLatestQueryOptions, + sessionMessagesQueryOptions, +} from "@/lib/query/query-options"; +import type { Message, TaskSessionState } from "@/lib/types/http"; + +export const INITIAL_FETCH_LIMIT = 100; +const BACKFILL_PAGE_LIMIT = 100; +export const MAX_AUTO_BACKFILL_PAGES = 10; + +const standaloneMessageQueryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, +}); + +const lastAppliedFetchSeq = new Map(); +const debug = createDebugLogger("messages:fetch"); +let fetchSeqCounter = 0; + +export function nextFetchSeq(): number { + fetchSeqCounter += 1; + return fetchSeqCounter; +} + +export function commitFetchSeq(sessionId: string, seq: number): boolean { + const last = lastAppliedFetchSeq.get(sessionId) ?? 0; + if (seq < last) return false; + lastAppliedFetchSeq.set(sessionId, seq); + return true; +} + +export function hasUserOrAgentMessage(messages: Message[]): boolean { + return messages.some( + (message) => + message.type === "message" && + (message.author_type === "user" || message.author_type === "agent"), + ); +} + +const ACTIVE_SESSION_STATES: ReadonlySet = new Set(["STARTING", "RUNNING"]); + +const SETTLED_SESSION_STATES: ReadonlySet = new Set([ + "IDLE", + "WAITING_FOR_INPUT", + "COMPLETED", + "FAILED", + "CANCELLED", +]); + +export function isTurnSettleTransition( + previousState: TaskSessionState | null, + nextState: TaskSessionState | null, +): boolean { + return ( + !!previousState && + !!nextState && + ACTIVE_SESSION_STATES.has(previousState) && + SETTLED_SESSION_STATES.has(nextState) + ); +} + +export function hasUserPromptInActiveTurn(messages: Message[], activeTurnId: string | null) { + return ( + !!activeTurnId && + messages.some( + (message) => + message.turn_id === activeTurnId && + message.type === "message" && + message.author_type === "user", + ) + ); +} + +export function shouldRunMessageBackfill(params: { + taskSessionState: TaskSessionState | null; + connectionStatus: string; + activeTurnId: string | null; + messages: Message[]; +}) { + return ( + params.connectionStatus === "connected" && + params.taskSessionState === "RUNNING" && + params.activeTurnId !== null + ); +} + +type MessageSummary = { + count: number; + byType: Record; + userMessageCount: number; + agentMessageCount: number; + oldestCreatedAt: string | null; + newestCreatedAt: string | null; +}; + +function summarizeMessages(messages: Message[]): MessageSummary { + const byType: Record = {}; + let userMessageCount = 0; + let agentMessageCount = 0; + for (const m of messages) { + const t = m.type ?? "unknown"; + byType[t] = (byType[t] ?? 0) + 1; + if (m.type === "message" && m.author_type === "user") userMessageCount++; + if (m.type === "message" && m.author_type === "agent") agentMessageCount++; + } + return { + count: messages.length, + byType, + userMessageCount, + agentMessageCount, + oldestCreatedAt: messages[0]?.created_at ?? null, + newestCreatedAt: messages[messages.length - 1]?.created_at ?? null, + }; +} + +type MessageListResponse = { messages: Message[]; has_more?: boolean; cursor?: string }; + +function logFetchSummary( + sessionId: string, + fetched: Message[], + response: MessageListResponse, + limit: number, +): void { + if (!isDebug()) return; + const summary = summarizeMessages(fetched); + debug("message.list response", { + sessionId, + hasMore: response.has_more ?? false, + cursor: response.cursor ?? null, + ...summary, + }); + if (fetched.length > 0 && summary.userMessageCount === 0 && summary.agentMessageCount === 0) { + debug("WARNING: fetched window contains no user/agent message rows", { + sessionId, + limit, + hasMore: response.has_more ?? false, + byType: summary.byType, + hint: "The fetch limit may be too small for this session's last turn — user prompt and agent replies live further back. Paginate or raise the limit to see them.", + }); + } +} + +function writeLatestMessagesCache( + queryClient: QueryClient, + sessionId: string, + limit: number, + messages: Message[], + response: Pick, +): void { + queryClient.setQueryData( + sessionMessagesLatestQueryOptions(sessionId, limit).queryKey, + { + messages, + hasMore: response.hasMore, + oldestCursor: messages[0]?.id ?? response.oldestCursor ?? null, + }, + ); +} + +export async function fetchAndStoreMessages( + sessionId: string, + store: ReturnType, + queryClient: QueryClient = standaloneMessageQueryClient, +): Promise { + const seq = nextFetchSeq(); + const requestParams = { + limit: INITIAL_FETCH_LIMIT, + sort: "desc" as const, + }; + debug("message.list request", { session_id: sessionId, ...requestParams }); + const response = await queryClient.fetchQuery({ + ...sessionMessagesLatestQueryOptions(sessionId, requestParams.limit), + staleTime: 0, + }); + const fetched = response.messages ?? []; + logFetchSummary( + sessionId, + fetched, + { + messages: fetched, + has_more: response.hasMore, + cursor: response.oldestCursor ?? undefined, + }, + requestParams.limit, + ); + const existing = store.getState().messages.bySession[sessionId] ?? []; + const fetchedIds = new Set(fetched.map((m) => m.id)); + const extras = existing.filter((m) => !fetchedIds.has(m.id)); + const merged = + extras.length > 0 + ? [...fetched, ...extras].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), + ) + : fetched; + + if (!commitFetchSeq(sessionId, seq)) { + debug("message.list stale fetch skipped", { sessionId, seq }); + const current = store.getState().messages.bySession[sessionId] ?? merged; + writeLatestMessagesCache(queryClient, sessionId, requestParams.limit, current, response); + return current; + } + + writeLatestMessagesCache(queryClient, sessionId, requestParams.limit, merged, response); + store.getState().mergeMessages(sessionId, merged, { + hasMore: response.hasMore, + oldestCursor: merged[0]?.id ?? response.oldestCursor, + }); + return merged; +} + +export type BackfillStep = "continue" | "stop"; + +async function fetchAndPrependOlder( + sessionId: string, + store: ReturnType, + oldestCursor: string, + queryClient: QueryClient = standaloneMessageQueryClient, +): Promise { + const response = await queryClient.fetchQuery({ + ...sessionMessagesQueryOptions(sessionId, { + limit: BACKFILL_PAGE_LIMIT, + before: oldestCursor, + sort: "desc", + }), + staleTime: 0, + }); + const ordered = [...(response.messages ?? [])].reverse(); + const newOldestCursor = ordered[0]?.id ?? oldestCursor; + store.getState().prependMessages(sessionId, ordered, { + hasMore: response.has_more ?? false, + oldestCursor: newOldestCursor, + }); + return ordered.length; +} + +export async function runBackfillRound( + sessionId: string, + store: ReturnType, + round: number, + queryClient: QueryClient = standaloneMessageQueryClient, +): Promise { + const meta = store.getState().messages.metaBySession[sessionId]; + const messages = store.getState().messages.bySession[sessionId] ?? []; + if (hasUserOrAgentMessage(messages)) return "stop"; + if (!meta?.hasMore || !meta.oldestCursor) { + debug("autoBackfill: stopping (no more older messages)", { + sessionId, + round, + hasMore: meta?.hasMore ?? false, + }); + return "stop"; + } + debug("autoBackfill: window has no user/agent message, fetching older", { + sessionId, + round, + currentCount: messages.length, + oldestCursor: meta.oldestCursor, + }); + try { + const added = await fetchAndPrependOlder(sessionId, store, meta.oldestCursor, queryClient); + return added === 0 ? "stop" : "continue"; + } catch (err) { + debug("autoBackfill: fetch failed, stopping", { sessionId, round, err }); + return "stop"; + } +} + +export async function autoBackfillUntilUserMessage( + sessionId: string, + store: ReturnType, + queryClient: QueryClient = standaloneMessageQueryClient, +): Promise { + for (let round = 0; round < MAX_AUTO_BACKFILL_PAGES; round++) { + const step = await runBackfillRound(sessionId, store, round, queryClient); + if (step === "stop") return; + } + debug("autoBackfill: hit page budget without finding user/agent message", { + sessionId, + pageBudget: MAX_AUTO_BACKFILL_PAGES, + messageBudget: MAX_AUTO_BACKFILL_PAGES * BACKFILL_PAGE_LIMIT, + }); +} diff --git a/apps/web/hooks/domains/session/use-base-branch-by-repo.test.tsx b/apps/web/hooks/domains/session/use-base-branch-by-repo.test.tsx new file mode 100644 index 0000000000..94314dcf09 --- /dev/null +++ b/apps/web/hooks/domains/session/use-base-branch-by-repo.test.tsx @@ -0,0 +1,128 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import { repositoryId, taskId, workflowId, workspaceId } from "@/lib/types/ids"; +import type { Repository, Task } from "@/lib/types/http"; +import { useBaseBranchByRepo } from "./use-base-branch-by-repo"; + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(), +})); + +const WORKSPACE_ID = workspaceId("workspace-1"); +const WORKFLOW_ID = workflowId("workflow-1"); +const TASK_ID = taskId("task-1"); +const CREATED_AT = "2026-06-24T00:00:00Z"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + const initialState = { + kanban: { + workflowId: null, + steps: [], + tasks: [], + isLoading: false, + }, + kanbanMulti: { snapshots: {} }, + } as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function repo(id: string, name: string): Repository { + return { + id: repositoryId(id), + workspace_id: WORKSPACE_ID, + name, + source_type: "local", + local_path: `/work/${name}`, + provider: "github", + provider_repo_id: id, + provider_owner: "kdlbs", + provider_name: name, + default_branch: "main", + worktree_branch_prefix: "", + pull_before_worktree: false, + setup_script: "", + cleanup_script: "", + dev_script: "", + copy_files: "", + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function task(): Task { + return { + id: TASK_ID, + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: "step-1", + position: 0, + title: "Multi repo task", + description: "", + state: "TODO", + priority: 0, + repositories: [ + { + id: "task-repo-front", + task_id: TASK_ID, + repository_id: repositoryId("repo-front"), + base_branch: "main", + position: 0, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + { + id: "task-repo-back", + task_id: TASK_ID, + repository_id: repositoryId("repo-back"), + base_branch: "release/24.x", + position: 1, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + ], + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +describe("useBaseBranchByRepo", () => { + afterEach(() => { + cleanup(); + }); + + it("uses task detail Query data when legacy kanban mirrors are empty", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.tasks.detail(TASK_ID), task()); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), [ + repo("repo-front", "frontend"), + repo("repo-back", "backend"), + ]); + + const { result } = renderHook(() => useBaseBranchByRepo(TASK_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current).toEqual({ + frontend: "main", + backend: "release/24.x", + }); + }); +}); diff --git a/apps/web/hooks/domains/session/use-base-branch-by-repo.ts b/apps/web/hooks/domains/session/use-base-branch-by-repo.ts index fca99367a4..664b15e6dd 100644 --- a/apps/web/hooks/domains/session/use-base-branch-by-repo.ts +++ b/apps/web/hooks/domains/session/use-base-branch-by-repo.ts @@ -1,8 +1,9 @@ "use client"; import { useMemo } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { repositoryId, type Repository } from "@/lib/types/http"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; +import { repositoryId } from "@/lib/types/http"; /** * Returns a map from `repository_name` to its task base_branch for the active @@ -14,19 +15,17 @@ import { repositoryId, type Repository } from "@/lib/types/http"; * baseBranchDisplay) and for tasks not yet hydrated. */ export function useBaseBranchByRepo(activeTaskId: string | null): Record { - const tasks = useAppStore((s) => s.kanban.tasks); - const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId); + const task = useTaskById(activeTaskId); + const repositories = useAllCachedRepositories(); return useMemo(() => { if (!activeTaskId) return {}; - const task = tasks.find((t) => t.id === activeTaskId); if (!task?.repositories?.length) return {}; - const allRepos = Object.values(reposByWorkspace).flat() as Repository[]; - const repoNameById = new Map(allRepos.map((r) => [r.id, r.name])); + const repoNameById = new Map(repositories.map((r) => [r.id, r.name])); const out: Record = {}; for (const link of task.repositories) { const name = repoNameById.get(repositoryId(link.repository_id)); if (name && link.base_branch) out[name] = link.base_branch; } return out; - }, [activeTaskId, tasks, reposByWorkspace]); + }, [activeTaskId, task, repositories]); } diff --git a/apps/web/hooks/domains/session/use-commit-diff.ts b/apps/web/hooks/domains/session/use-commit-diff.ts index ed24450566..31d1f13ae9 100644 --- a/apps/web/hooks/domains/session/use-commit-diff.ts +++ b/apps/web/hooks/domains/session/use-commit-diff.ts @@ -1,9 +1,11 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { requestCommitDiff } from "@/components/task/commit-diff-request"; +import { sessionAgentctlQueryOptions } from "@/lib/query/query-options"; import type { FileInfo } from "@/lib/state/store"; type UseCommitDiffResult = { @@ -23,11 +25,13 @@ export function useCommitDiff(commitSha: string, repo?: string): UseCommitDiffRe const sessionTaskId = useAppStore((state) => activeSessionId ? state.taskSessions.items[activeSessionId]?.task_id : undefined, ); - const agentctlReady = useAppStore((state) => + const agentctlQuery = useQuery(sessionAgentctlQueryOptions(activeSessionId ?? "")); + const storeAgentctlReady = useAppStore((state) => activeSessionId ? state.sessionAgentctl.itemsBySessionId[activeSessionId]?.status === "ready" : false, ); + const agentctlReady = agentctlQuery.data?.status === "ready" || storeAgentctlReady; const { toast } = useToast(); const [files, setFiles] = useState | null>(null); diff --git a/apps/web/hooks/domains/session/use-prepare-summary.ts b/apps/web/hooks/domains/session/use-prepare-summary.ts index 1f054ba3ed..d8c07836d4 100644 --- a/apps/web/hooks/domains/session/use-prepare-summary.ts +++ b/apps/web/hooks/domains/session/use-prepare-summary.ts @@ -1,14 +1,18 @@ import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { prepareProgressQueryOptions } from "@/lib/query/query-options"; import { summarizePrepare, type PrepareSummary } from "@/lib/prepare/summarize"; export function usePrepareSummary(sessionId: string | null): PrepareSummary { - const prepareState = useAppStore((state) => + const prepareQuery = useQuery(prepareProgressQueryOptions(sessionId ?? "")); + const storePrepareState = useAppStore((state) => sessionId ? (state.prepareProgress?.bySessionId?.[sessionId] ?? null) : null, ); const sessionState = useAppStore((state) => sessionId ? (state.taskSessions?.items?.[sessionId]?.state ?? null) : null, ); + const prepareState = prepareQuery.data ?? storePrepareState; return useMemo(() => summarizePrepare(prepareState, sessionState), [prepareState, sessionState]); } diff --git a/apps/web/hooks/domains/session/use-queue.test.ts b/apps/web/hooks/domains/session/use-queue.test.ts index ad089f0009..195e768ee1 100644 --- a/apps/web/hooks/domains/session/use-queue.test.ts +++ b/apps/web/hooks/domains/session/use-queue.test.ts @@ -1,9 +1,20 @@ +import { createElement, type ReactNode } from "react"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { QueuedMessage } from "@/lib/state/slices/session/types"; +import { queueStatusQueryOptions } from "@/lib/query/query-options"; +import type { QueueStatus, QueuedMessage } from "@/lib/state/slices/session/types"; const queueApiMock = vi.hoisted(() => { - class QueueEntryNotFoundError extends Error {} + class QueueEntryNotFoundError extends Error { + readonly code = "entry_not_found"; + + constructor() { + super("Queue entry was already drained or no longer exists."); + this.name = "QueueEntryNotFoundError"; + } + } + return { QueueEntryNotFoundError, queueMessage: vi.fn(), @@ -15,44 +26,54 @@ const queueApiMock = vi.hoisted(() => { }; }); -type MockQueueState = { - queue: { - bySessionId: Record; - metaBySessionId: Record; - isLoading: Record; - }; +type MockState = { connection: { status: string }; - setQueueEntries: ReturnType; - removeQueueEntry: ReturnType; - setQueueLoading: ReturnType; }; -let mockState: MockQueueState; +let mockState: MockState; vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (state: MockQueueState) => unknown) => selector(mockState), + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), })); vi.mock("@/lib/api/domains/queue-api", () => queueApiMock); import { useQueue } from "./use-queue"; -const SESSION_ID = "sess-1"; +const SESSION_ID = "session-1"; const TASK_ID = "task-1"; +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); +} + +function createWrapper(client: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + function entry(overrides: Partial = {}): QueuedMessage { return { - id: "q-1", + id: "entry-1", session_id: SESSION_ID, task_id: TASK_ID, - content: "queued prompt", + content: "Queued prompt", plan_mode: false, - queued_at: "2026-06-27T00:00:00Z", + queued_at: "2026-06-23T00:00:00Z", queued_by: "user", ...overrides, }; } +function seedQueue(client: QueryClient, status: QueueStatus) { + client.setQueryData(queueStatusQueryOptions(SESSION_ID).queryKey, status); +} + function setDocumentVisibility(value: DocumentVisibilityState) { Object.defineProperty(document, "visibilityState", { configurable: true, @@ -60,35 +81,65 @@ function setDocumentVisibility(value: DocumentVisibilityState) { }); } -function resetMockState() { - mockState = { - queue: { - bySessionId: {}, - metaBySessionId: {}, - isLoading: {}, - }, - connection: { status: "connected" }, - setQueueEntries: vi.fn(), - removeQueueEntry: vi.fn(), - setQueueLoading: vi.fn(), - }; -} - describe("useQueue", () => { beforeEach(() => { - resetMockState(); + vi.clearAllMocks(); + mockState = { connection: { status: "connected" } }; setDocumentVisibility("visible"); queueApiMock.getQueueStatus.mockResolvedValue({ entries: [], count: 0, max: 10 }); }); afterEach(() => { cleanup(); - vi.clearAllMocks(); + }); + + it("reads cached queue status from Query without queue Zustand state", () => { + const client = createQueryClient(); + const queued = entry(); + seedQueue(client, { entries: [queued], count: 1, max: 3 }); + + const { result } = renderHook(() => useQueue(SESSION_ID), { + wrapper: createWrapper(client), + }); + + expect(result.current.entries).toEqual([queued]); + expect(result.current.count).toBe(1); + expect(result.current.max).toBe(3); + expect(result.current.isFull).toBe(false); + }); + + it("optimistically removes an entry from the queue query cache", async () => { + queueApiMock.removeQueuedEntry.mockResolvedValueOnce({ entry_id: "entry-1" }); + const client = createQueryClient(); + seedQueue(client, { + entries: [entry(), entry({ id: "entry-2", content: "Second prompt" })], + count: 2, + max: 3, + }); + const { result } = renderHook(() => useQueue(SESSION_ID), { + wrapper: createWrapper(client), + }); + + await act(async () => { + await result.current.removeEntry("entry-1"); + }); + + expect(queueApiMock.removeQueuedEntry).toHaveBeenCalledWith({ + session_id: SESSION_ID, + entry_id: "entry-1", + }); + expect(client.getQueryData(queueStatusQueryOptions(SESSION_ID).queryKey)).toEqual({ + entries: [entry({ id: "entry-2", content: "Second prompt" })], + count: 1, + max: 3, + }); }); it("refetches the queue snapshot when the WebSocket reconnects", async () => { mockState.connection.status = "disconnected"; - const { rerender } = renderHook(() => useQueue(SESSION_ID)); + const { rerender } = renderHook(() => useQueue(SESSION_ID), { + wrapper: createWrapper(createQueryClient()), + }); await act(async () => {}); expect(queueApiMock.getQueueStatus).not.toHaveBeenCalled(); @@ -97,40 +148,27 @@ describe("useQueue", () => { rerender(); await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID)); - expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [], { - count: 0, - max: 10, - }); }); it("refetches a stale queue snapshot when a suspended tab becomes visible again", async () => { - mockState.queue.bySessionId[SESSION_ID] = [entry()]; - mockState.queue.metaBySessionId[SESSION_ID] = { count: 1, max: 10 }; - queueApiMock.getQueueStatus.mockResolvedValueOnce({ - entries: [entry()], - count: 1, - max: 10, + renderHook(() => useQueue(SESSION_ID), { + wrapper: createWrapper(createQueryClient()), }); - - renderHook(() => useQueue(SESSION_ID)); await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledTimes(1)); queueApiMock.getQueueStatus.mockClear(); - mockState.setQueueEntries.mockClear(); queueApiMock.getQueueStatus.mockResolvedValueOnce({ entries: [], count: 0, max: 10 }); document.dispatchEvent(new Event("visibilitychange")); await waitFor(() => expect(queueApiMock.getQueueStatus).toHaveBeenCalledWith(SESSION_ID)); - expect(mockState.setQueueEntries).toHaveBeenCalledWith(SESSION_ID, [], { - count: 0, - max: 10, - }); }); it("does not refetch on foreground visibility while disconnected", async () => { mockState.connection.status = "disconnected"; - renderHook(() => useQueue(SESSION_ID)); + renderHook(() => useQueue(SESSION_ID), { + wrapper: createWrapper(createQueryClient()), + }); await act(async () => {}); document.dispatchEvent(new Event("visibilitychange")); diff --git a/apps/web/hooks/domains/session/use-queue.ts b/apps/web/hooks/domains/session/use-queue.ts index 97dbc10f5e..5e11257569 100644 --- a/apps/web/hooks/domains/session/use-queue.ts +++ b/apps/web/hooks/domains/session/use-queue.ts @@ -1,17 +1,19 @@ -import { useEffect, useCallback } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { queueMessage, clearQueue, drainQueuedMessage, - getQueueStatus, updateQueuedMessage, removeQueuedEntry, QueueEntryNotFoundError, } from "@/lib/api/domains/queue-api"; -import type { QueuedMessage } from "@/lib/state/slices/session/types"; +import { queueStatusQueryOptions } from "@/lib/query/query-options"; +import type { QueueStatus, QueuedMessage } from "@/lib/state/slices/session/types"; const EMPTY_ENTRIES: QueuedMessage[] = []; +const EMPTY_STATUS: QueueStatus = { entries: EMPTY_ENTRIES, count: 0, max: 0 }; export type MessageAttachment = { type: string; @@ -21,34 +23,16 @@ export type MessageAttachment = { delivery_mode?: "prompt" | "path"; }; -/** Selectors over the queue slice for one session. */ -function useQueueState(sessionId: string | null) { - const entries = useAppStore((state) => - sessionId ? (state.queue.bySessionId[sessionId] ?? EMPTY_ENTRIES) : EMPTY_ENTRIES, - ); - const meta = useAppStore((state) => - sessionId ? state.queue.metaBySessionId[sessionId] : undefined, - ); - const isLoading = useAppStore((state) => - sessionId ? (state.queue.isLoading[sessionId] ?? false) : false, - ); - const setQueueEntries = useAppStore((state) => state.setQueueEntries); - const removeQueueEntry = useAppStore((state) => state.removeQueueEntry); - const setQueueLoading = useAppStore((state) => state.setQueueLoading); - return { entries, meta, isLoading, setQueueEntries, removeQueueEntry, setQueueLoading }; -} - type QueueActionsArgs = { sessionId: string | null; - setQueueEntries: ReturnType["setQueueEntries"]; - removeQueueEntry: ReturnType["removeQueueEntry"]; - setQueueLoading: ReturnType["setQueueLoading"]; + setQueueLoading: (sessionId: string, loading: boolean) => void; metaMax: number | undefined; + queryClient: QueryClient; }; function useDrainNextAction( sessionId: string | null, - setQueueLoading: ReturnType["setQueueLoading"], + setQueueLoading: (sessionId: string, loading: boolean) => void, refetch: (sid: string) => Promise, ) { return useCallback(async () => { @@ -63,25 +47,37 @@ function useDrainNextAction( }, [sessionId, refetch, setQueueLoading]); } -/** Build an action set bound to the supplied session + slice setters. */ -function useQueueActions({ - sessionId, - setQueueEntries, - removeQueueEntry, - setQueueLoading, - metaMax, -}: QueueActionsArgs) { +function writeQueueStatus(queryClient: QueryClient, sessionId: string, status: QueueStatus) { + queryClient.setQueryData(queueStatusQueryOptions(sessionId).queryKey, status); +} + +function patchQueueStatus( + queryClient: QueryClient, + sessionId: string, + updater: (current: QueueStatus) => QueueStatus, +) { + queryClient.setQueryData(queueStatusQueryOptions(sessionId).queryKey, (current) => + updater(current ?? EMPTY_STATUS), + ); +} + +function removeEntryFromStatus(status: QueueStatus, entryId: string): QueueStatus { + const entries = (status.entries ?? []).filter((entry) => entry.id !== entryId); + return { entries, count: entries.length, max: status.max }; +} + +/** Build an action set bound to the supplied session and Query cache. */ +function useQueueActions({ sessionId, setQueueLoading, metaMax, queryClient }: QueueActionsArgs) { const refetch = useCallback( async (sid: string) => { try { setQueueLoading(sid, true); - const status = await getQueueStatus(sid); - setQueueEntries(sid, status.entries ?? [], { count: status.count, max: status.max }); + await queryClient.fetchQuery({ ...queueStatusQueryOptions(sid), staleTime: 0 }); } finally { setQueueLoading(sid, false); } }, - [setQueueEntries, setQueueLoading], + [queryClient, setQueueLoading], ); const queue = useCallback( @@ -120,11 +116,15 @@ function useQueueActions({ // will replace it with the authoritative server value. Using the // pre-clear entry count as a fallback for `max` was wrong (it would // pretend the cap equals "however many were queued"). - setQueueEntries(sessionId, [], { count: 0, max: metaMax ?? 0 }); + writeQueueStatus(queryClient, sessionId, { + entries: [], + count: 0, + max: metaMax ?? 0, + }); } finally { setQueueLoading(sessionId, false); } - }, [sessionId, setQueueEntries, setQueueLoading, metaMax]); + }, [sessionId, setQueueLoading, metaMax, queryClient]); const drainNext = useDrainNextAction(sessionId, setQueueLoading, refetch); @@ -152,7 +152,9 @@ function useQueueActions({ const removeEntry = useCallback( async (entryId: string) => { if (!sessionId) return; - removeQueueEntry(sessionId, entryId); + patchQueueStatus(queryClient, sessionId, (current) => + removeEntryFromStatus(current, entryId), + ); try { await removeQueuedEntry({ session_id: sessionId, entry_id: entryId }); } catch (err) { @@ -161,7 +163,7 @@ function useQueueActions({ throw err; } }, - [sessionId, refetch, removeQueueEntry], + [sessionId, queryClient, refetch], ); return { refetch, queue, clearAll, drainNext, editEntry, removeEntry }; @@ -176,20 +178,41 @@ function useQueueActions({ * server returns `entry_not_found` and we refetch to resync the local list. */ export function useQueue(sessionId: string | null) { - const state = useQueueState(sessionId); - const { entries, meta, isLoading } = state; + const queryClient = useQueryClient(); const connectionStatus = useAppStore((appState) => appState.connection.status); + const statusQuery = useQuery({ + ...queueStatusQueryOptions(sessionId ?? ""), + enabled: Boolean(sessionId && connectionStatus === "connected"), + }); + const [loadingBySessionId, setLoadingBySessionId] = useState>({}); + const setQueueLoading = useCallback((sid: string, loading: boolean) => { + setLoadingBySessionId((current) => { + if ((current[sid] ?? false) === loading) return current; + const next = { ...current }; + if (loading) { + next[sid] = true; + } else { + delete next[sid]; + } + return next; + }); + }, []); + const status = statusQuery.data ?? EMPTY_STATUS; + const entries = status.entries ?? EMPTY_ENTRIES; + const isLoading = sessionId ? (loadingBySessionId[sessionId] ?? false) : false; const { refetch, queue, clearAll, drainNext, editEntry, removeEntry } = useQueueActions({ sessionId, - setQueueEntries: state.setQueueEntries, - removeQueueEntry: state.removeQueueEntry, - setQueueLoading: state.setQueueLoading, - metaMax: meta?.max, + setQueueLoading, + metaMax: status.max, + queryClient, }); + const previousConnectionStatusRef = useRef(connectionStatus); useEffect(() => { + const previous = previousConnectionStatusRef.current; + previousConnectionStatusRef.current = connectionStatus; if (!sessionId) return; - if (connectionStatus !== "connected") return; + if (connectionStatus !== "connected" || previous === "connected") return; void refetch(sessionId).catch((err) => { console.error("Failed to fetch queue status:", err); }); @@ -215,10 +238,10 @@ export function useQueue(sessionId: string | null) { return { entries, - count: meta?.count ?? entries.length, - max: meta?.max ?? 0, - isFull: meta ? meta.count >= meta.max && meta.max > 0 : false, - isLoading, + count: status.count ?? entries.length, + max: status.max ?? 0, + isFull: status.count >= status.max && status.max > 0, + isLoading: isLoading || statusQuery.isFetching, queue, clearAll, drainNext, diff --git a/apps/web/hooks/domains/session/use-repo-display-name.test.tsx b/apps/web/hooks/domains/session/use-repo-display-name.test.tsx new file mode 100644 index 0000000000..1f87dbd0cd --- /dev/null +++ b/apps/web/hooks/domains/session/use-repo-display-name.test.tsx @@ -0,0 +1,131 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import { repositoryId, sessionId, taskId, workflowId, workspaceId } from "@/lib/types/ids"; +import type { Repository, Task, TaskSession } from "@/lib/types/http"; +import { useRepoDisplayName } from "./use-repo-display-name"; + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(), +})); + +const WORKSPACE_ID = workspaceId("workspace-1"); +const WORKFLOW_ID = workflowId("workflow-1"); +const TASK_ID = taskId("task-1"); +const SESSION_ID = sessionId("session-1"); +const REPOSITORY_ID = repositoryId("repo-1"); +const CREATED_AT = "2026-06-24T00:00:00Z"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + const initialState = { + kanban: { + workflowId: null, + steps: [], + tasks: [], + isLoading: false, + }, + kanbanMulti: { snapshots: {} }, + taskSessions: { + items: { + [SESSION_ID]: taskSession(), + }, + }, + } as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function taskSession(): TaskSession { + return { + id: SESSION_ID, + task_id: TASK_ID, + repository_id: REPOSITORY_ID, + state: "RUNNING", + started_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function repository(): Repository { + return { + id: REPOSITORY_ID, + workspace_id: WORKSPACE_ID, + name: "kandev", + source_type: "local", + local_path: "/work/kandev", + provider: "github", + provider_repo_id: "repo-1", + provider_owner: "kdlbs", + provider_name: "kandev", + default_branch: "main", + worktree_branch_prefix: "", + pull_before_worktree: false, + setup_script: "", + cleanup_script: "", + dev_script: "", + copy_files: "", + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +function task(): Task { + return { + id: TASK_ID, + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: "step-1", + position: 0, + title: "Single repo task", + description: "", + state: "TODO", + priority: 0, + repositories: [ + { + id: "task-repo-1", + task_id: TASK_ID, + repository_id: REPOSITORY_ID, + base_branch: "main", + position: 0, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + ], + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} + +describe("useRepoDisplayName", () => { + afterEach(() => { + cleanup(); + }); + + it("uses task detail Query data for the primary repo fallback", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.tasks.detail(TASK_ID), task()); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), [repository()]); + + const { result } = renderHook(() => useRepoDisplayName(SESSION_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current("")).toBe("kandev"); + }); +}); diff --git a/apps/web/hooks/domains/session/use-repo-display-name.ts b/apps/web/hooks/domains/session/use-repo-display-name.ts index c1fbf037aa..365e65c14d 100644 --- a/apps/web/hooks/domains/session/use-repo-display-name.ts +++ b/apps/web/hooks/domains/session/use-repo-display-name.ts @@ -2,10 +2,12 @@ import { useMemo } from "react"; import { useAppStore } from "@/components/state-provider"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { useRepositoriesByWorkspace } from "@/hooks/domains/workspace/use-repository-cache"; /** * Resolves the workspace's primary single-repo name from the active task and - * repositories slice. Returns undefined when: + * repository Query cache. Returns undefined when: * - the task hasn't loaded yet (Bug 5 loading-order concern: `tasks` and * `reposByWorkspace` hydrate independently via SSR + WS, so the task can be * missing on first render) @@ -24,12 +26,10 @@ type TaskLike = { type RepoEntry = { id: string; name: string }; function resolvePrimaryRepoName( - taskId: string | null, - tasks: TaskLike[], + task: TaskLike | null, reposByWorkspace: Record, ): string | undefined { - const task = taskId ? tasks.find((t) => t.id === taskId) : undefined; - if (task === undefined) return undefined; + if (!task) return undefined; const primaryRepoId = task.repositoryId ?? null; const taskHasMultipleRepos = (task.repositories?.length ?? 0) > 1; if (taskHasMultipleRepos || !primaryRepoId) return undefined; @@ -56,16 +56,15 @@ function resolvePrimaryRepoName( export function useRepoDisplayName(sessionId: string | null | undefined) { const session = useAppStore((state) => (sessionId ? state.taskSessions.items[sessionId] : null)); const taskId = session?.task_id ?? null; - const tasks = useAppStore((state) => state.kanban.tasks); - const reposByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const task = useTaskById(taskId); + const reposByWorkspace = useRepositoriesByWorkspace(); const primaryName = useMemo( () => resolvePrimaryRepoName( - taskId, - tasks as unknown as TaskLike[], + task as TaskLike | null, reposByWorkspace as unknown as Record, ), - [taskId, tasks, reposByWorkspace], + [task, reposByWorkspace], ); // Flattened, sorted list of known repo names — long names first so // `kandev-foo` matches `kandev-foo` before it matches `kandev`. diff --git a/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts b/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts index 02f519bf55..f41ac31493 100644 --- a/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts +++ b/apps/web/hooks/domains/session/use-request-changes-walkthrough.test.ts @@ -1,5 +1,8 @@ import { act, renderHook } from "@testing-library/react"; +import type { QueryClient } from "@tanstack/react-query"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; const mockRequest = vi.fn(); const mockQueueMessage = vi.fn(); @@ -9,6 +12,12 @@ const mockListPrompts = vi.fn(); const mockGetWebSocketClient = vi.fn(() => ({ request: mockRequest })); type MockStoreState = ReturnType; let mockStoreState: MockStoreState; +let queryClient: QueryClient; + +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useQueryClient: () => queryClient }; +}); vi.mock("@/components/state-provider", () => ({ useAppStoreApi: () => ({ getState: () => mockStoreState }), @@ -53,6 +62,7 @@ function renderRequestHook(ready = true) { describe("useRequestChangesWalkthrough", () => { beforeEach(() => { vi.clearAllMocks(); + queryClient = makeQueryClient(); mockStoreState = storeState("WAITING_FOR_INPUT"); mockListPrompts.mockResolvedValue({ prompts: [ @@ -67,6 +77,11 @@ describe("useRequestChangesWalkthrough", () => { }); it("sends a walkthrough request directly when the agent is waiting", async () => { + queryClient.setQueryData(qk.session.messages("session-1"), { + messages: [], + hasMore: false, + oldestCursor: null, + }); mockRequest.mockResolvedValueOnce({ id: "msg-1", session_id: "session-1", @@ -99,6 +114,11 @@ describe("useRequestChangesWalkthrough", () => { expect(sentContent).not.toContain("Base commit:"); expect(mockListPrompts).toHaveBeenCalledWith({ cache: "no-store" }); expect(mockAddMessage).toHaveBeenCalledWith(expect.objectContaining({ id: "msg-1" })); + expect( + queryClient.getQueryData<{ messages: Array<{ id: string }> }>( + qk.session.messages("session-1"), + )?.messages, + ).toEqual([expect.objectContaining({ id: "msg-1" })]); expect(mockQueueMessage).not.toHaveBeenCalled(); expect(mockToast).toHaveBeenCalledWith( expect.objectContaining({ title: "Walkthrough request sent" }), diff --git a/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts b/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts index 95daa4de81..4c61c612b0 100644 --- a/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts +++ b/apps/web/hooks/domains/session/use-request-changes-walkthrough.ts @@ -1,4 +1,5 @@ import { useCallback } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useAppStoreApi } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; import { listPrompts } from "@/lib/api"; @@ -10,6 +11,8 @@ import { } from "@/lib/walkthrough-request"; import type { Message } from "@/lib/types/http"; import type { AppState } from "@/lib/state/store"; +import { qk } from "@/lib/query/keys"; +import { upsertSessionMessageCaches } from "@/lib/query/bridge/session"; type UseRequestChangesWalkthroughParams = { taskId: string | null | undefined; @@ -55,6 +58,7 @@ async function sendWalkthroughRequest(params: { content: string; planModeEnabled: boolean; state: AppState; + queryClient: QueryClient; }) { const client = getWebSocketClient(); if (!client) throw new Error("WebSocket client unavailable"); @@ -68,7 +72,14 @@ async function sendWalkthroughRequest(params: { }, 10000, ); - if (created?.id && created.session_id) params.state.addMessage(created); + if (created?.id && created.session_id) { + await params.queryClient.cancelQueries({ + exact: true, + queryKey: qk.session.messages(created.session_id), + }); + upsertSessionMessageCaches(params.queryClient, created); + params.state.addMessage(created); + } } export function useRequestChangesWalkthrough({ @@ -77,6 +88,7 @@ export function useRequestChangesWalkthrough({ ready = true, }: UseRequestChangesWalkthroughParams) { const storeApi = useAppStoreApi(); + const queryClient = useQueryClient(); const { toast } = useToast(); return useCallback(async () => { @@ -104,11 +116,18 @@ export function useRequestChangesWalkthrough({ return; } - await sendWalkthroughRequest({ taskId, sessionId, content, planModeEnabled, state }); + await sendWalkthroughRequest({ + taskId, + sessionId, + content, + planModeEnabled, + state, + queryClient, + }); toast({ title: "Walkthrough request sent", variant: "success" }); } catch (error) { console.error("Failed to request walkthrough:", error); toast({ title: "Failed to request walkthrough", variant: "error" }); } - }, [ready, sessionId, storeApi, taskId, toast]); + }, [queryClient, ready, sessionId, storeApi, taskId, toast]); } diff --git a/apps/web/hooks/domains/session/use-session-actions.test.ts b/apps/web/hooks/domains/session/use-session-actions.test.ts index 48a037a62e..e525e25fb2 100644 --- a/apps/web/hooks/domains/session/use-session-actions.test.ts +++ b/apps/web/hooks/domains/session/use-session-actions.test.ts @@ -13,6 +13,9 @@ const mockRequest = vi.fn(); const mockRemoveTaskSession = vi.fn(); const mockSetActiveSessionAuto = vi.fn(); const mockClearActiveSession = vi.fn(); +const mockSetQueryData = vi.fn(); +const mockRemoveQueries = vi.fn(); +const mockInvalidateQueries = vi.fn(); let mockState: Record = {}; @@ -24,6 +27,14 @@ vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => ({ request: mockRequest }), })); +vi.mock("@tanstack/react-query", () => ({ + useQueryClient: () => ({ + setQueryData: mockSetQueryData, + removeQueries: mockRemoveQueries, + invalidateQueries: mockInvalidateQueries, + }), +})); + vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: Record) => unknown) => selector({ @@ -34,6 +45,18 @@ vi.mock("@/components/state-provider", () => ({ }), })); +beforeEach(() => { + vi.clearAllMocks(); + mockToast.mockReturnValue("toast-1"); + mockRequest.mockResolvedValue(undefined); + mockState = { + tasks: { activeSessionId: null }, + taskSessionsByTask: { itemsByTaskId: {} }, + setActiveSessionAuto: mockSetActiveSessionAuto, + clearActiveSession: mockClearActiveSession, + }; +}); + describe("session state predicates", () => { it("isSessionStoppable returns true for active states", () => { expect(isSessionStoppable("RUNNING")).toBe(true); @@ -61,18 +84,6 @@ describe("session state predicates", () => { }); describe("useSessionActions", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockToast.mockReturnValue("toast-1"); - mockRequest.mockResolvedValue(undefined); - mockState = { - tasks: { activeSessionId: null }, - taskSessionsByTask: { itemsByTaskId: {} }, - setActiveSessionAuto: mockSetActiveSessionAuto, - clearActiveSession: mockClearActiveSession, - }; - }); - it("setPrimary dispatches session.set_primary with session id", async () => { const { result } = renderHook(() => useSessionActions({ sessionId: "s1", taskId: "t1" })); await result.current.setPrimary(); @@ -94,7 +105,9 @@ describe("useSessionActions", () => { 30000, ); }); +}); +describe("useSessionActions remove", () => { it("remove deletes via WS, removes from store, and runs onDeleted callback", async () => { const onDeleted = vi.fn(); const { result } = renderHook(() => @@ -104,6 +117,25 @@ describe("useSessionActions", () => { await waitFor(() => expect(onDeleted).toHaveBeenCalled()); expect(mockRequest).toHaveBeenCalledWith("session.delete", { session_id: "s1" }, 15000); expect(mockRemoveTaskSession).toHaveBeenCalledWith("t1", "s1"); + expect(mockSetQueryData).toHaveBeenCalledWith( + ["session", "byTask", "t1"], + expect.any(Function), + ); + expect(mockRemoveQueries).toHaveBeenCalledWith({ + exact: true, + queryKey: ["session", "byId", "s1"], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + exact: true, + queryKey: ["session", "byTask", "t1"], + }); + + const updater = mockSetQueryData.mock.calls[0]?.[1] as (current: { + sessions: Array<{ id: string }>; + }) => { sessions: Array<{ id: string }> }; + expect(updater({ sessions: [{ id: "s1" }, { id: "s2" }] })).toEqual({ + sessions: [{ id: "s2" }], + }); }); it("remove no-ops when WS request fails (store untouched)", async () => { @@ -114,6 +146,7 @@ describe("useSessionActions", () => { ); await result.current.remove(); expect(mockRemoveTaskSession).not.toHaveBeenCalled(); + expect(mockSetQueryData).not.toHaveBeenCalled(); expect(onDeleted).not.toHaveBeenCalled(); }); diff --git a/apps/web/hooks/domains/session/use-session-actions.ts b/apps/web/hooks/domains/session/use-session-actions.ts index 1124af35e8..ea69dc37eb 100644 --- a/apps/web/hooks/domains/session/use-session-actions.ts +++ b/apps/web/hooks/domains/session/use-session-actions.ts @@ -1,10 +1,12 @@ "use client"; import { useCallback } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useToast } from "@/components/toast-provider"; +import { qk } from "@/lib/query/keys"; import { getWebSocketClient } from "@/lib/ws/connection"; -import type { TaskSessionState } from "@/lib/types/http"; +import type { TaskSession, TaskSessionState } from "@/lib/types/http"; export function isSessionStoppable(s: TaskSessionState): boolean { return s === "RUNNING" || s === "STARTING" || s === "WAITING_FOR_INPUT"; @@ -30,6 +32,25 @@ type WsActionFn = ( timeout?: number, ) => Promise; +type TaskSessionsCache = { + sessions?: TaskSession[]; +}; + +function removeTaskSessionFromQueryCache( + queryClient: QueryClient, + taskId: string, + sessionId: string, +) { + queryClient.setQueryData(qk.taskSession.byTask(taskId), (current) => { + if (!current || !Array.isArray(current.sessions)) return current; + const sessions = current.sessions.filter((session) => session.id !== sessionId); + if (sessions.length === current.sessions.length) return current; + return { ...current, sessions }; + }); + queryClient.removeQueries({ exact: true, queryKey: qk.taskSession.byId(sessionId) }); + void queryClient.invalidateQueries({ exact: true, queryKey: qk.taskSession.byTask(taskId) }); +} + function useWsAction(): WsActionFn { const { toast, updateToast } = useToast(); return useCallback( @@ -58,6 +79,7 @@ function useWsAction(): WsActionFn { */ export function useSessionActions({ sessionId, taskId, onDeleted }: SessionActionsArgs) { const wsAction = useWsAction(); + const queryClient = useQueryClient(); const removeTaskSession = useAppStore((state) => state.removeTaskSession); const appStoreApi = useAppStoreApi(); @@ -105,8 +127,9 @@ export function useSessionActions({ sessionId, taskId, onDeleted }: SessionActio } removeTaskSession(taskId, sessionId); + removeTaskSessionFromQueryCache(queryClient, taskId, sessionId); onDeleted?.(); - }, [sessionId, taskId, wsAction, removeTaskSession, appStoreApi, onDeleted]); + }, [sessionId, taskId, wsAction, removeTaskSession, queryClient, appStoreApi, onDeleted]); return { setPrimary, stop, resume, remove }; } diff --git a/apps/web/hooks/domains/session/use-session-agentctl.test.tsx b/apps/web/hooks/domains/session/use-session-agentctl.test.tsx new file mode 100644 index 0000000000..86d70ee0a4 --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-agentctl.test.tsx @@ -0,0 +1,87 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import type { SessionAgentctlStatus } from "@/lib/state/slices/session/types"; +import { sessionId as toSessionId, taskId as toTaskId, type TaskSession } from "@/lib/types/http"; +import { useSessionAgentctl } from "./use-session-agentctl"; + +const SESSION_ID = toSessionId("session-1"); +const TASK_ID = toTaskId("task-1"); +const TIMESTAMP = "2026-06-24T00:00:00Z"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); +} + +function taskSession(overrides: Partial = {}): TaskSession { + return { + id: SESSION_ID, + task_id: TASK_ID, + state: "COMPLETED", + started_at: TIMESTAMP, + updated_at: TIMESTAMP, + task_environment_id: "env-1", + worktree_path: "/tmp/repo", + ...overrides, + }; +} + +function wrapperFor(session: TaskSession, agentctlStatus?: SessionAgentctlStatus) { + const queryClient = createQueryClient(); + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); + }; +} + +describe("useSessionAgentctl", () => { + it("treats a prepared terminal session as ready when the transient ready event was missed", () => { + const { result } = renderHook(() => useSessionAgentctl(SESSION_ID), { + wrapper: wrapperFor(taskSession()), + }); + + expect(result.current).toMatchObject({ + status: "ready", + isReady: true, + isStarting: false, + isError: false, + }); + }); + + it("promotes a stale starting status after the session has completed with a workspace", () => { + const { result } = renderHook(() => useSessionAgentctl(SESSION_ID), { + wrapper: wrapperFor(taskSession(), { + status: "starting", + updatedAt: TIMESTAMP, + }), + }); + + expect(result.current).toMatchObject({ + status: "ready", + isReady: true, + isStarting: false, + isError: false, + }); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-agentctl.ts b/apps/web/hooks/domains/session/use-session-agentctl.ts index 4cb266a766..0c66ae362c 100644 --- a/apps/web/hooks/domains/session/use-session-agentctl.ts +++ b/apps/web/hooks/domains/session/use-session-agentctl.ts @@ -1,30 +1,42 @@ import { useEffect, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { sessionAgentctlQueryOptions } from "@/lib/query/query-options"; import { getWebSocketClient } from "@/lib/ws/connection"; import { createDebugLogger } from "@/lib/debug/log"; +import type { SessionAgentctlStatus } from "@/lib/state/slices/session/types"; +import type { TaskSession, TaskSessionState } from "@/lib/types/http"; const debug = createDebugLogger("agentctl:status"); -export function useSessionAgentctl(sessionId: string | null) { - const session = useAppStore((state) => - sessionId ? state.taskSessions.items[sessionId] : undefined, - ); - const status = useAppStore((state) => - sessionId ? state.sessionAgentctl.itemsBySessionId[sessionId] : undefined, - ); - const connectionStatus = useAppStore((state) => state.connection.status); +type AgentctlStatusLog = { + errorMessage?: string | null; + agentExecutionId?: string | null; +}; - useEffect(() => { - if (!session?.id) return; - if (connectionStatus !== "connected") return; - const client = getWebSocketClient(); - if (!client) return; - return client.subscribeSession(session.id); - }, [session?.id, connectionStatus]); +const WORKSPACE_READY_STATES: ReadonlySet = new Set([ + "RUNNING", + "WAITING_FOR_INPUT", + "COMPLETED", + "CANCELLED", +]); - // Log status transitions only — re-rendering should not spam. +function resolveAgentctlStatusValue( + status: SessionAgentctlStatus | null | undefined, + session: TaskSession | null | undefined, +) { + if (status?.status === "error" || status?.status === "ready") return status.status; + if (session?.task_environment_id && WORKSPACE_READY_STATES.has(session.state)) return "ready"; + return status?.status ?? "missing"; +} + +function useAgentctlTransitionLog( + sessionId: string | null, + statusValue: string, + status: AgentctlStatusLog | undefined, + connectionStatus: string, +) { const lastLoggedRef = useRef(null); - const statusValue = status?.status ?? "missing"; const snapshot = `${sessionId ?? "none"}|${statusValue}|${status?.errorMessage ?? ""}|${status?.agentExecutionId ?? ""}`; useEffect(() => { if (!sessionId) return; @@ -39,13 +51,37 @@ export function useSessionAgentctl(sessionId: string | null) { }); lastLoggedRef.current = snapshot; }, [sessionId, snapshot, statusValue, status, connectionStatus]); +} + +export function useSessionAgentctl(sessionId: string | null) { + const agentctlQuery = useQuery(sessionAgentctlQueryOptions(sessionId ?? "")); + const session = useAppStore((state) => + sessionId ? state.taskSessions.items[sessionId] : undefined, + ); + const storeStatus = useAppStore((state) => + sessionId ? state.sessionAgentctl.itemsBySessionId[sessionId] : undefined, + ); + const status = agentctlQuery.data ?? storeStatus; + const connectionStatus = useAppStore((state) => state.connection.status); + + useEffect(() => { + if (!session?.id) return; + if (connectionStatus !== "connected") return; + const client = getWebSocketClient(); + if (!client) return; + return client.subscribeSession(session.id); + }, [session?.id, connectionStatus]); + + // Log status transitions only — re-rendering should not spam. + const statusValue = resolveAgentctlStatusValue(status, session); + useAgentctlTransitionLog(sessionId, statusValue, status, connectionStatus); return { - status: status?.status ?? "starting", + status: statusValue === "missing" ? "starting" : statusValue, errorMessage: status?.errorMessage, agentExecutionId: status?.agentExecutionId, isReady: statusValue === "ready", - isStarting: statusValue === "starting" || !status, + isStarting: statusValue === "starting" || statusValue === "missing", isError: statusValue === "error", }; } diff --git a/apps/web/hooks/domains/session/use-session-changes-count.test.ts b/apps/web/hooks/domains/session/use-session-changes-count.test.ts index 5ff47caa51..501c2c05c5 100644 --- a/apps/web/hooks/domains/session/use-session-changes-count.test.ts +++ b/apps/web/hooks/domains/session/use-session-changes-count.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { cleanup, renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => null, @@ -13,6 +15,13 @@ vi.mock("@/components/state-provider", () => ({ import { useSessionChangesCount } from "./use-session-changes-count"; +function createWrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + type FileEntry = { path: string; status: "modified"; staged: boolean }; type StatusEntry = { branch: string; @@ -68,12 +77,16 @@ describe("useSessionChangesCount", () => { }); it("returns 0 when the session has no gitStatus and no commits yet", () => { - const { result } = renderHook(() => useSessionChangesCount("sess-new")); + const { result } = renderHook(() => useSessionChangesCount("sess-new"), { + wrapper: createWrapper(), + }); expect(result.current).toBe(0); }); it("returns 0 for null session id", () => { - const { result } = renderHook(() => useSessionChangesCount(null)); + const { result } = renderHook(() => useSessionChangesCount(null), { + wrapper: createWrapper(), + }); expect(result.current).toBe(0); }); @@ -82,7 +95,9 @@ describe("useSessionChangesCount", () => { envBySession: { "sess-1": "env-1" }, byEnvironmentRepo: { "env-1": { "": status(["a.ts", "b.ts"]) } }, }); - const { result } = renderHook(() => useSessionChangesCount("sess-1")); + const { result } = renderHook(() => useSessionChangesCount("sess-1"), { + wrapper: createWrapper(), + }); expect(result.current).toBe(2); }); @@ -99,7 +114,9 @@ describe("useSessionChangesCount", () => { }, }, }); - const { result } = renderHook(() => useSessionChangesCount("sess-1")); + const { result } = renderHook(() => useSessionChangesCount("sess-1"), { + wrapper: createWrapper(), + }); expect(result.current).toBe(3); }); @@ -109,7 +126,9 @@ describe("useSessionChangesCount", () => { byEnvironmentRepo: { "env-1": { "": status(["a.ts"]) } }, commitsByEnvironmentId: { "env-1": [{ commit_sha: "x" }, { commit_sha: "y" }] }, }); - const { result } = renderHook(() => useSessionChangesCount("sess-1")); + const { result } = renderHook(() => useSessionChangesCount("sess-1"), { + wrapper: createWrapper(), + }); expect(result.current).toBe(3); }); @@ -117,7 +136,9 @@ describe("useSessionChangesCount", () => { setStore({ byEnvironmentRepo: { "sess-pending": { "": status(["only.ts"]) } }, }); - const { result } = renderHook(() => useSessionChangesCount("sess-pending")); + const { result } = renderHook(() => useSessionChangesCount("sess-pending"), { + wrapper: createWrapper(), + }); expect(result.current).toBe(1); }); @@ -130,7 +151,9 @@ describe("useSessionChangesCount", () => { byEnvironmentRepo: { "env-old": { "": status(["leak.ts", "leak2.ts"]) } }, commitsByEnvironmentId: { "env-old": [{ commit_sha: "old" }] }, }); - const { result } = renderHook(() => useSessionChangesCount("sess-new")); + const { result } = renderHook(() => useSessionChangesCount("sess-new"), { + wrapper: createWrapper(), + }); expect(result.current).toBe(0); }); }); diff --git a/apps/web/hooks/domains/session/use-session-changes-count.ts b/apps/web/hooks/domains/session/use-session-changes-count.ts index 6af6610df3..8d00c76a70 100644 --- a/apps/web/hooks/domains/session/use-session-changes-count.ts +++ b/apps/web/hooks/domains/session/use-session-changes-count.ts @@ -14,14 +14,25 @@ import { useSessionCommits } from "./use-session-commits"; * a different repo's status. Read this hook instead whenever the UI needs a * single number representing the session's total changes. */ -export function useSessionChangesCount(sessionId: string | null): number { +export function useSessionChangesSummary(sessionId: string | null): { + totalCount: number; + loaded: boolean; +} { const statusByRepo = useSessionGitStatusByRepo(sessionId); - const { commits } = useSessionCommits(sessionId); - return useMemo(() => { + const { commits, loaded: commitsLoaded } = useSessionCommits(sessionId); + const totalCount = useMemo(() => { let fileCount = 0; for (const { status } of statusByRepo) { if (status?.files) fileCount += Object.keys(status.files).length; } return fileCount + commits.length; }, [statusByRepo, commits.length]); + return { + totalCount, + loaded: statusByRepo.length > 0 && commitsLoaded, + }; +} + +export function useSessionChangesCount(sessionId: string | null): number { + return useSessionChangesSummary(sessionId).totalCount; } diff --git a/apps/web/hooks/domains/session/use-session-commits.test.ts b/apps/web/hooks/domains/session/use-session-commits.test.ts index cb0094def1..9af9c5e4ea 100644 --- a/apps/web/hooks/domains/session/use-session-commits.test.ts +++ b/apps/web/hooks/domains/session/use-session-commits.test.ts @@ -1,5 +1,8 @@ +/* eslint-disable max-lines-per-function */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const mockRequest = vi.fn(); // setSessionCommits is the only mock whose default behaviour matters: the @@ -33,6 +36,13 @@ vi.mock("@/components/state-provider", () => ({ import { useSessionCommits } from "./use-session-commits"; +function createWrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + function setStore(connectionStatus: "connected" | "disconnected" = "connected") { storeState = { environmentIdBySessionId: {} as Record, @@ -62,7 +72,7 @@ describe("useSessionCommits", () => { commits: [{ commit_sha: "abc", insertions: 10, deletions: 2 }], }); - renderHook(() => useSessionCommits("sess-1")); + renderHook(() => useSessionCommits("sess-1"), { wrapper: createWrapper() }); await waitFor(() => { expect(mockSetSessionCommits).toHaveBeenCalledWith( @@ -78,7 +88,7 @@ describe("useSessionCommits", () => { commits: [{ commit_sha: "abc", insertions: 5, deletions: 1 }], }); - renderHook(() => useSessionCommits("sess-1")); + renderHook(() => useSessionCommits("sess-1"), { wrapper: createWrapper() }); // First request fires immediately. await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); @@ -108,7 +118,7 @@ describe("useSessionCommits", () => { commits: [{ commit_sha: "abc" }], }); - renderHook(() => useSessionCommits("sess-1")); + renderHook(() => useSessionCommits("sess-1"), { wrapper: createWrapper() }); // First request resolves with ready:false — the hook should set loading // to true at the start, then leave it as-is (no setLoading(false) call) @@ -133,7 +143,7 @@ describe("useSessionCommits", () => { ready: true, }); - renderHook(() => useSessionCommits("sess-1")); + renderHook(() => useSessionCommits("sess-1"), { wrapper: createWrapper() }); await waitFor(() => expect(mockSetSessionCommits).toHaveBeenCalledTimes(1)); @@ -144,12 +154,12 @@ describe("useSessionCommits", () => { it("does not fetch when disconnected", () => { setStore("disconnected"); - renderHook(() => useSessionCommits("sess-1")); + renderHook(() => useSessionCommits("sess-1"), { wrapper: createWrapper() }); expect(mockRequest).not.toHaveBeenCalled(); }); it("does not fetch when sessionId is null", () => { - renderHook(() => useSessionCommits(null)); + renderHook(() => useSessionCommits(null), { wrapper: createWrapper() }); expect(mockRequest).not.toHaveBeenCalled(); }); }); @@ -203,7 +213,7 @@ describe("useSessionCommits — authoritative snapshot on mount", () => { ready: true, }); - renderHook(() => useSessionCommits("sess-1")); + renderHook(() => useSessionCommits("sess-1"), { wrapper: createWrapper() }); await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); await waitFor(() => { @@ -231,6 +241,7 @@ describe("useSessionCommits — authoritative snapshot on mount", () => { const { rerender } = renderHook(({ id }) => useSessionCommits(id), { initialProps: { id: "sess-1" as string | null }, + wrapper: createWrapper(), }); await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); @@ -282,7 +293,9 @@ describe("useSessionCommits — stale-while-revalidate on trigger bump", () => { it("refetches when refetchTrigger bumps without nulling the visible list", async () => { const resolveRequest = await seedAndDeferRefetch("sess-1"); - const { rerender } = renderHook(() => useSessionCommits("sess-1")); + const { rerender } = renderHook(() => useSessionCommits("sess-1"), { + wrapper: createWrapper(), + }); // The mount-time snapshot fetch fires once with the seeded data. await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); @@ -321,7 +334,9 @@ describe("useSessionCommits — stale-while-revalidate on trigger bump", () => { // Trigger-bump fetch returns the authoritative empty list. mockRequest.mockResolvedValueOnce({ commits: [], ready: true }); - const { rerender } = renderHook(() => useSessionCommits("sess-1")); + const { rerender } = renderHook(() => useSessionCommits("sess-1"), { + wrapper: createWrapper(), + }); await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); bumpTrigger("sess-1", 1); rerender(); @@ -361,7 +376,9 @@ describe("useSessionCommits — stale-while-revalidate on trigger bump", () => { }), ); - const { rerender } = renderHook(() => useSessionCommits("sess-1")); + const { rerender } = renderHook(() => useSessionCommits("sess-1"), { + wrapper: createWrapper(), + }); await waitFor(() => expect(mockRequest).toHaveBeenCalledTimes(1)); bumpTrigger("sess-1", 1); diff --git a/apps/web/hooks/domains/session/use-session-commits.ts b/apps/web/hooks/domains/session/use-session-commits.ts index bda148f9a3..f21a3e20b1 100644 --- a/apps/web/hooks/domains/session/use-session-commits.ts +++ b/apps/web/hooks/domains/session/use-session-commits.ts @@ -1,6 +1,8 @@ -import { useEffect, useCallback, useRef } from "react"; +import { useEffect, useCallback, useRef, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { getWebSocketClient } from "@/lib/ws/connection"; +import { qk } from "@/lib/query/keys"; +import { fetchSessionCommitsSnapshot, sessionCommitsQueryOptions } from "@/lib/query/query-options"; import type { SessionCommit } from "@/lib/state/slices/session-runtime/types"; // Sentinel ref value: forces the trigger-bumped path to fire on first mount if @@ -12,74 +14,57 @@ const REFETCH_TRIGGER_INIT = 0; const NOT_READY_RETRY_MS = 2000; -/** - * Hook to fetch and manage commits for a session. - * Commits are keyed by environmentId so sessions sharing the same environment - * share the same commit list and don't duplicate fetches. - */ -export function useSessionCommits(sessionId: string | null) { - const commits = useAppStore((state) => { +function useSessionCommitStoreState(sessionId: string | null) { + const envKey = useAppStore((state) => + sessionId ? (state.environmentIdBySessionId[sessionId] ?? sessionId) : "", + ); + const storeCommits = useAppStore((state) => { if (!sessionId) return undefined; - const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId; - return state.sessionCommits.byEnvironmentId[envKey]; + const key = state.environmentIdBySessionId[sessionId] ?? sessionId; + return state.sessionCommits.byEnvironmentId[key]; }); - const loading = useAppStore((state) => { + const storeLoading = useAppStore((state) => { if (!sessionId) return false; - const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId; - return state.sessionCommits.loading[envKey] ?? false; + const key = state.environmentIdBySessionId[sessionId] ?? sessionId; + return state.sessionCommits.loading[key] ?? false; }); - // Stale-while-revalidate trigger: bumped by commits_reset / branch_switched - // WS events. We refetch on change without nulling the visible list, so the - // Changes panel keeps showing the previous commits until the new ones land. const refetchTrigger = useAppStore((state) => { if (!sessionId) return 0; const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId; return state.sessionCommits.refetchTrigger[envKey] ?? 0; }); + return { envKey, storeCommits, storeLoading, refetchTrigger }; +} + +/** + * Hook to fetch and manage commits for a session. + * Commits are keyed by environmentId so sessions sharing the same environment + * share the same commit list and don't duplicate fetches. + */ +export function useSessionCommits(sessionId: string | null) { + const queryClient = useQueryClient(); + const { envKey, storeCommits, storeLoading, refetchTrigger } = + useSessionCommitStoreState(sessionId); + const snapshotKey = sessionId && envKey ? `${sessionId}:${envKey}:${refetchTrigger}` : null; + const commitsQuery = useQuery({ + ...sessionCommitsQueryOptions(envKey, sessionId ?? ""), + enabled: false, + }); + const commits = commitsQuery.data ?? storeCommits; + const loading = commitsQuery.isFetching || storeLoading; const setSessionCommits = useAppStore((state) => state.setSessionCommits); const setSessionCommitsLoading = useAppStore((state) => state.setSessionCommitsLoading); const connectionStatus = useAppStore((state) => state.connection.status); - // Track the last refetch trigger we acted on, so a bump triggers exactly one - // refetch rather than re-firing on every render. Initialise to a sentinel - // (not `refetchTrigger`) so a bump that arrived before this hook mounted - // still drives an initial refetch — otherwise prevRef would equal the live - // value and `triggerBumped` would silently be false on first render. const prevRefetchTriggerRef = useRef(REFETCH_TRIGGER_INIT); - // Tracks which sessionId we've already run an authoritative snapshot fetch - // for. Without this, the initial-fetch gate fires only when `commits` is - // undefined — but commits can be populated by a live `commit_created` - // event that arrived before mount, and a replayed/raced event can carry - // zero stats. The bad stats then stick because the snapshot (which would - // overwrite with correct stats from `git log --shortstat`) never runs. - // Anchoring to sessionId guarantees a snapshot per session regardless of - // how `commits` got populated. const fetchedSessionRef = useRef(null); - // Retry timer for the not-ready case — agentctl recovers asynchronously - // after a backend restart, so the first fetch may land before the workspace - // execution has been ensured. Without a retry the store would be stuck on - // an empty list and the COMMITS section would silently miss commits whose - // commit_created notifications were already fired (or pushed and so - // filtered out by the live watcher). const retryTimerRef = useRef | null>(null); - // Monotonic request version. Captured at fetch start; the response is only - // applied if the version still matches. Without this, two trigger bumps in - // quick succession (e.g. branch_switched → user reverts) could see the - // older in-flight response land after the newer one and clobber the panel - // with stale data. Mirrors the pattern in useCumulativeDiff. const requestVersionRef = useRef(0); + const [loadedSnapshotKey, setLoadedSnapshotKey] = useState(null); - // `allowEmpty` is threaded into setSessionCommits's guard. Trigger-bump - // refetches (commits_reset / branch_switched) can legitimately return [] — - // e.g. a `git reset` stripped every commit back to base — and the store - // must accept that authoritatively. Initial fetches keep the default - // guard so a stale empty response can't race the addSessionCommit path. const fetchCommits = useCallback( async (opts?: { allowEmpty?: boolean }) => { - if (!sessionId) return; - - const client = getWebSocketClient(); - if (!client) return; + if (!sessionId || !envKey || !snapshotKey) return; if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); @@ -89,10 +74,7 @@ export function useSessionCommits(sessionId: string | null) { const version = ++requestVersionRef.current; setSessionCommitsLoading(sessionId, true); try { - const response = await client.request<{ commits?: SessionCommit[]; ready?: boolean }>( - "session.git.commits", - { session_id: sessionId }, - ); + const response = await fetchSessionCommitsSnapshot(sessionId); // Drop stale callbacks: another fetch (e.g. a later trigger bump) // already started, so this response is for an older state and must @@ -109,7 +91,7 @@ export function useSessionCommits(sessionId: string | null) { // Schedule a retry so we eventually pick up the real list. Preserve // `opts` across retries so a trigger-bump fetch that gets ready:false // still applies its authoritative empty response when it succeeds. - if (response?.ready === false) { + if (response.ready === false) { retryTimerRef.current = setTimeout(() => { retryTimerRef.current = null; fetchCommits(opts); @@ -117,9 +99,18 @@ export function useSessionCommits(sessionId: string | null) { return; } - if (response?.commits) { - setSessionCommits(sessionId, response.commits, opts); - } + const nextCommits = response.commits; + queryClient.setQueryData(qk.sessionRuntime.commits(envKey), (current: unknown) => { + const existing = Array.isArray(current) + ? (current as SessionCommit[]) + : (storeCommits ?? []); + if (!opts?.allowEmpty && nextCommits.length === 0 && existing.length > 0) { + return existing; + } + return nextCommits; + }); + setSessionCommits(sessionId, nextCommits, opts); + setLoadedSnapshotKey(snapshotKey); } catch (error) { console.error("Failed to fetch session commits:", error); } finally { @@ -130,7 +121,15 @@ export function useSessionCommits(sessionId: string | null) { } } }, - [sessionId, setSessionCommits, setSessionCommitsLoading], + [ + envKey, + queryClient, + sessionId, + setSessionCommits, + setSessionCommitsLoading, + snapshotKey, + storeCommits, + ], ); // Fetch commits when: @@ -174,7 +173,7 @@ export function useSessionCommits(sessionId: string | null) { // Cancel any in-flight retry on unmount, when the session changes, or when // the WS disconnects — a retry firing against a disconnected client would - // either throw inside fetchCommits or hit getWebSocketClient()===null. + // just fail the snapshot request and leave loading stuck until the next run. useEffect(() => { return () => { if (retryTimerRef.current) { @@ -186,6 +185,7 @@ export function useSessionCommits(sessionId: string | null) { return { commits: commits ?? [], + loaded: loadedSnapshotKey === snapshotKey && commits !== undefined && !loading, loading, refetch: fetchCommits, }; diff --git a/apps/web/hooks/domains/session/use-session-context-window.ts b/apps/web/hooks/domains/session/use-session-context-window.ts index c0ca41a960..b5a5c4f035 100644 --- a/apps/web/hooks/domains/session/use-session-context-window.ts +++ b/apps/web/hooks/domains/session/use-session-context-window.ts @@ -1,37 +1,43 @@ import { useEffect, useMemo } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { contextWindowQueryOptions } from "@/lib/query/query-options"; import { getWebSocketClient } from "@/lib/ws/connection"; import type { ContextWindowEntry } from "@/lib/state/store"; export function useSessionContextWindow(sessionId: string | null): ContextWindowEntry | undefined { + const queryClient = useQueryClient(); + const contextWindowQuery = useQuery(contextWindowQueryOptions(sessionId ?? "")); // Subscribe to individual primitive values to ensure reactivity - const size = useAppStore((state) => + const storeSize = useAppStore((state) => sessionId ? state.contextWindow.bySessionId[sessionId]?.size : undefined, ); - const used = useAppStore((state) => + const storeUsed = useAppStore((state) => sessionId ? state.contextWindow.bySessionId[sessionId]?.used : undefined, ); - const remaining = useAppStore((state) => + const storeRemaining = useAppStore((state) => sessionId ? state.contextWindow.bySessionId[sessionId]?.remaining : undefined, ); - const efficiency = useAppStore((state) => + const storeEfficiency = useAppStore((state) => sessionId ? state.contextWindow.bySessionId[sessionId]?.efficiency : undefined, ); - const timestamp = useAppStore((state) => + const storeTimestamp = useAppStore((state) => sessionId ? state.contextWindow.bySessionId[sessionId]?.timestamp : undefined, ); // Memoize the combined object - const contextWindow = useMemo(() => { - if (size === undefined) return undefined; + const storeContextWindow = useMemo(() => { + if (storeSize === undefined) return undefined; return { - size, - used: used ?? 0, - remaining: remaining ?? 0, - efficiency: efficiency ?? 0, - timestamp, + size: storeSize, + used: storeUsed ?? 0, + remaining: storeRemaining ?? 0, + efficiency: storeEfficiency ?? 0, + timestamp: storeTimestamp, }; - }, [size, used, remaining, efficiency, timestamp]); + }, [storeSize, storeUsed, storeRemaining, storeEfficiency, storeTimestamp]); + const contextWindow = contextWindowQuery.data ?? storeContextWindow; const session = useAppStore((state) => sessionId ? state.taskSessions.items[sessionId] : undefined, @@ -61,7 +67,8 @@ export function useSessionContextWindow(sessionId: string | null): ContextWindow }; setContextWindow(sessionId, entry); - }, [sessionId, contextWindow, session?.metadata, setContextWindow]); + queryClient.setQueryData(qk.sessionRuntime.contextWindow(sessionId), entry); + }, [sessionId, contextWindow, session?.metadata, setContextWindow, queryClient]); // Subscribe to session updates via WebSocket useEffect(() => { diff --git a/apps/web/hooks/domains/session/use-session-git-status.ts b/apps/web/hooks/domains/session/use-session-git-status.ts index bcc2ee1dbe..99958fc00f 100644 --- a/apps/web/hooks/domains/session/use-session-git-status.ts +++ b/apps/web/hooks/domains/session/use-session-git-status.ts @@ -1,6 +1,8 @@ import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useShallow } from "zustand/react/shallow"; import { useAppStore } from "@/components/state-provider"; +import { gitStatusQueryOptions } from "@/lib/query/query-options"; import { getWebSocketClient } from "@/lib/ws/connection"; import { createDebugLogger } from "@/lib/debug/log"; import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; @@ -15,13 +17,17 @@ const debugSub = createDebugLogger("git-status:subscribe"); * use useSessionGitStatusByRepo when the caller needs all repos at once. */ export function useSessionGitStatus(sessionId: string | null) { - const gitStatus = useAppStore( + const envKey = useAppStore((state) => + sessionId ? (state.environmentIdBySessionId[sessionId] ?? sessionId) : "", + ); + const storeGitStatus = useAppStore( useShallow((state) => { if (!sessionId) return undefined; - const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId; - return state.gitStatus.byEnvironmentId[envKey]; + const key = state.environmentIdBySessionId[sessionId] ?? sessionId; + return state.gitStatus.byEnvironmentId[key]; }), ); + const gitStatusQuery = useQuery(gitStatusQueryOptions(envKey)); const connectionStatus = useAppStore((state) => state.connection.status); // Subscribe to session updates to receive git status via WebSocket @@ -52,7 +58,7 @@ export function useSessionGitStatus(sessionId: string | null) { }; }, [sessionId, connectionStatus]); - return gitStatus; + return gitStatusQuery.data?.latest ?? storeGitStatus; } /** @@ -67,13 +73,18 @@ export function useSessionGitStatus(sessionId: string | null) { export function useSessionGitStatusByRepo( sessionId: string | null, ): Array<{ repository_name: string; status: GitStatusEntry }> { - const map = useAppStore( + const envKey = useAppStore((state) => + sessionId ? (state.environmentIdBySessionId[sessionId] ?? sessionId) : "", + ); + const storeMap = useAppStore( useShallow((state) => { if (!sessionId) return undefined; - const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId; - return state.gitStatus.byEnvironmentRepo[envKey]; + const key = state.environmentIdBySessionId[sessionId] ?? sessionId; + return state.gitStatus.byEnvironmentRepo[key]; }), ); + const gitStatusQuery = useQuery(gitStatusQueryOptions(envKey)); + const map = gitStatusQuery.data?.byRepo ?? storeMap; return useMemo(() => { if (!map) return []; return Object.entries(map) diff --git a/apps/web/hooks/domains/session/use-session-mcp.ts b/apps/web/hooks/domains/session/use-session-mcp.ts index 463e0df494..a369c9101b 100644 --- a/apps/web/hooks/domains/session/use-session-mcp.ts +++ b/apps/web/hooks/domains/session/use-session-mcp.ts @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { getAgentProfileMcpConfigAction } from "@/app/actions/agents"; const EMPTY_SERVERS: string[] = []; @@ -12,7 +12,7 @@ const DEFAULT_KANDEV: string[] = ["kandev"]; * Returns whether the agent supports MCP and the list of active MCP server names. */ export function useSessionMcp(agentProfileId: string | null | undefined) { - const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const { settingsAgents } = useSettingsData(Boolean(agentProfileId)); // Track which profileId the fetched servers belong to, so stale results are ignored const [fetchResult, setFetchResult] = useState<{ profileId: string; diff --git a/apps/web/hooks/domains/session/use-session-messages.test.ts b/apps/web/hooks/domains/session/use-session-messages.test.ts index c3b20c6a91..bf90da82d8 100644 --- a/apps/web/hooks/domains/session/use-session-messages.test.ts +++ b/apps/web/hooks/domains/session/use-session-messages.test.ts @@ -7,6 +7,14 @@ vi.mock("@/lib/api", () => ({ listTaskSessionMessages: (...args: unknown[]) => mockListTaskSessionMessages(...args), })); +vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(), + listSessionTurns: vi.fn(), + listTaskSessionMessages: (...args: unknown[]) => mockListTaskSessionMessages(...args), + listTaskSessions: vi.fn(), + searchSessionMessages: vi.fn(), +})); + vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => null, })); diff --git a/apps/web/hooks/domains/session/use-session-messages.ts b/apps/web/hooks/domains/session/use-session-messages.ts index 79080a30b0..12cb025f84 100644 --- a/apps/web/hooks/domains/session/use-session-messages.ts +++ b/apps/web/hooks/domains/session/use-session-messages.ts @@ -1,134 +1,45 @@ import { useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import type { TaskSessionState, Message } from "@/lib/types/http"; -import { listTaskSessionMessages } from "@/lib/api"; -import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { + sessionMessagesLatestQueryOptions, + sessionTurnsQueryOptions, + taskSessionQueryOptions, +} from "@/lib/query/query-options"; +import { createDebugLogger } from "@/lib/debug/log"; import { useUnknownSessionSubscriptionRetry, useUnknownSessionSubscriptionRetryEffect, } from "./use-session-subscription-retry"; +import { + autoBackfillUntilUserMessage, + fetchAndStoreMessages, + hasUserOrAgentMessage, + INITIAL_FETCH_LIMIT, + isTurnSettleTransition, + shouldRunMessageBackfill, +} from "./session-message-fetch"; export { shouldRetryUnknownSessionSubscription } from "./use-session-subscription-retry"; +export { + autoBackfillUntilUserMessage, + commitFetchSeq, + hasUserOrAgentMessage, + hasUserPromptInActiveTurn, + isTurnSettleTransition, + MAX_AUTO_BACKFILL_PAGES, + nextFetchSeq, + runBackfillRound, + shouldRunMessageBackfill, +} from "./session-message-fetch"; -const INITIAL_FETCH_LIMIT = 100; -const BACKFILL_PAGE_LIMIT = 100; const RUNNING_BACKFILL_INITIAL_DELAY_MS = 1200; const RUNNING_BACKFILL_INTERVAL_MS = 5000; -export const MAX_AUTO_BACKFILL_PAGES = 10; - -// Monotonic guard against stale concurrent fetches. Each fetch claims an -// increasing sequence number before awaiting; a completion only merges if no -// newer fetch for the same session has already merged, so an older in-flight -// fetch cannot overwrite newer state (WS-delivered messages or a newer fetch's -// snapshot) when two fetches for the same session race. -let fetchSeqCounter = 0; -const lastAppliedFetchSeq = new Map(); - -/** Claims the next monotonic fetch sequence number. */ -export function nextFetchSeq(): number { - fetchSeqCounter += 1; - return fetchSeqCounter; -} - -/** - * Records that the fetch identified by `seq` is about to merge for `sessionId`. - * Returns false when a newer fetch has already merged for the session, so the - * caller can skip the stale merge. - */ -export function commitFetchSeq(sessionId: string, seq: number): boolean { - const applied = lastAppliedFetchSeq.get(sessionId) ?? 0; - if (seq < applied) return false; - lastAppliedFetchSeq.set(sessionId, seq); - return true; -} - -export function hasUserOrAgentMessage(messages: Message[]): boolean { - return messages.some( - (m) => m.type === "message" && (m.author_type === "user" || m.author_type === "agent"), - ); -} - -// States where a turn (or the agent boot) is actively progressing. -const ACTIVE_SESSION_STATES: ReadonlySet = new Set(["STARTING", "RUNNING"]); -// States the session settles into once a turn finishes. -const SETTLED_SESSION_STATES: ReadonlySet = new Set([ - "IDLE", - "WAITING_FOR_INPUT", - "COMPLETED", - "FAILED", - "CANCELLED", -]); - -/** - * True when the session just left an active state for a settled one — i.e. a - * turn (or a resume's agent boot) finished. Session-scoped message updates - * emitted as the turn winds down (e.g. the `agent_boot` `script_execution` - * completion during a resume) can be missed if the live subscription lapsed - * during the resume churn, so this is the signal to refetch and reconcile. - * - * `state_changed` / `turn.completed` are broadcast globally (not session-scoped), - * so the client always observes this transition even when its session - * subscription was dropped. - */ -export function isTurnSettleTransition( - prev: TaskSessionState | null, - next: TaskSessionState | null, -): boolean { - if (prev === null || next === null) return false; - return ACTIVE_SESSION_STATES.has(prev) && SETTLED_SESSION_STATES.has(next); -} - -export function hasUserPromptInActiveTurn(messages: Message[], activeTurnId: string | null) { - if (!activeTurnId) return false; - return messages.some( - (m) => m.turn_id === activeTurnId && m.type === "message" && m.author_type === "user", - ); -} - -export function shouldRunMessageBackfill(params: { - taskSessionState: TaskSessionState | null; - connectionStatus: string; - activeTurnId: string | null; - messages: Message[]; -}) { - // The active prompt may be the missed WS frame; start backfill once the turn exists. - return ( - params.connectionStatus === "connected" && - params.taskSessionState === "RUNNING" && - params.activeTurnId !== null - ); -} const debug = createDebugLogger("messages:fetch"); -function summarizeMessages(messages: Message[]): { - count: number; - byType: Record; - userMessageCount: number; - agentMessageCount: number; - oldestCreatedAt: string | null; - newestCreatedAt: string | null; -} { - const byType: Record = {}; - let userMessageCount = 0; - let agentMessageCount = 0; - for (const m of messages) { - const t = m.type ?? "unknown"; - byType[t] = (byType[t] ?? 0) + 1; - if (m.type === "message" && m.author_type === "user") userMessageCount++; - if (m.type === "message" && m.author_type === "agent") agentMessageCount++; - } - return { - count: messages.length, - byType, - userMessageCount, - agentMessageCount, - oldestCreatedAt: messages[0]?.created_at ?? null, - newestCreatedAt: messages[messages.length - 1]?.created_at ?? null, - }; -} - interface UseSessionMessagesReturn { isLoading: boolean; messages: Message[]; @@ -136,170 +47,13 @@ interface UseSessionMessagesReturn { oldestCursor: string | null; } -type MessageListResponse = { messages: Message[]; has_more?: boolean; cursor?: string }; - const EMPTY_MESSAGES: Message[] = []; const EMPTY_META = { isLoading: false, hasMore: false, oldestCursor: null }; -/** Debug-only summary of a fetch response (no-op unless debug logging is on). */ -function logFetchSummary( - sessionId: string, - fetched: Message[], - response: MessageListResponse, - limit: number, -): void { - if (!isDebug()) return; - const summary = summarizeMessages(fetched); - debug("message.list response", { - sessionId, - hasMore: response.has_more ?? false, - cursor: response.cursor ?? null, - ...summary, - }); - if (fetched.length > 0 && summary.userMessageCount === 0 && summary.agentMessageCount === 0) { - debug("WARNING: fetched window contains no user/agent message rows", { - sessionId, - limit, - hasMore: response.has_more ?? false, - byType: summary.byType, - hint: "The fetch limit may be too small for this session's last turn — user prompt and agent replies live further back. Paginate or raise the limit to see them.", - }); - } -} - -/** Fetch latest messages via WS and merge with any that arrived via live notifications. */ -async function fetchAndStoreMessages( - sessionId: string, - store: ReturnType, -): Promise { - const client = getWebSocketClient(); - if (!client) { - return []; - } - const seq = nextFetchSeq(); - - const requestParams = { - session_id: sessionId, - limit: INITIAL_FETCH_LIMIT, - sort: "desc" as const, - }; - debug("message.list request", requestParams); - const response = await client.request("message.list", requestParams, 10000); - const fetched = [...(response.messages ?? [])].reverse(); - logFetchSummary(sessionId, fetched, response, requestParams.limit); - // Merge: keep WS-delivered messages that aren't in the fetch response. - // This prevents a slow fetch (sent before messages existed) from wiping - // messages that arrived via real-time notifications while the fetch was - // in flight. - const existing = store.getState().messages.bySession[sessionId] ?? []; - const fetchedIds = new Set(fetched.map((m) => m.id)); - const extras = existing.filter((m) => !fetchedIds.has(m.id)); - const merged = - extras.length > 0 - ? [...fetched, ...extras].sort( - (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), - ) - : fetched; - - // Stale-fetch guard: if a newer fetch for this session already merged while - // this one was in flight, skip the merge so the older snapshot can't drop - // newer messages. The newer state is already in the store. - if (!commitFetchSeq(sessionId, seq)) { - debug("message.list stale fetch skipped", { sessionId, seq }); - return store.getState().messages.bySession[sessionId] ?? merged; - } - - // Idempotent merge: the store reconciles this snapshot against current state, - // preserving object/array identity for unchanged messages so the periodic - // refetch doesn't re-render the whole chat (see reconcileMessages). - store.getState().mergeMessages(sessionId, merged, { - hasMore: response.has_more ?? false, - oldestCursor: merged[0]?.id ?? null, - }); - // The store now holds the identity-reconciled array; callers only read length - // and message content from the return, so `merged` is equivalent. - return merged; -} - -/** - * When the initial fetch window contains no user/agent message rows (common - * when the latest turn produced hundreds of tool calls), the chat would render - * as an opaque collapsed activity group with nothing meaningful to scroll - * past — the lazy-load sentinel at the top of the list never fires because - * the user has no anchor to scroll from. Paginate backward via the same HTTP - * endpoint `useLazyLoadMessages` uses until we span at least one user/agent - * message or hit the page budget. - */ -export type BackfillStep = "continue" | "stop"; - -async function fetchAndPrependOlder( - sessionId: string, - store: ReturnType, - oldestCursor: string, -): Promise { - const response = await listTaskSessionMessages(sessionId, { - limit: BACKFILL_PAGE_LIMIT, - before: oldestCursor, - sort: "desc", - }); - const ordered = [...(response.messages ?? [])].reverse(); - const newOldestCursor = ordered[0]?.id ?? oldestCursor; - store.getState().prependMessages(sessionId, ordered, { - hasMore: response.has_more ?? false, - oldestCursor: newOldestCursor, - }); - return ordered.length; -} - -export async function runBackfillRound( - sessionId: string, - store: ReturnType, - round: number, -): Promise { - const meta = store.getState().messages.metaBySession[sessionId]; - const messages = store.getState().messages.bySession[sessionId] ?? []; - if (hasUserOrAgentMessage(messages)) return "stop"; - if (!meta?.hasMore || !meta.oldestCursor) { - debug("autoBackfill: stopping (no more older messages)", { - sessionId, - round, - hasMore: meta?.hasMore ?? false, - }); - return "stop"; - } - debug("autoBackfill: window has no user/agent message, fetching older", { - sessionId, - round, - currentCount: messages.length, - oldestCursor: meta.oldestCursor, - }); - try { - const added = await fetchAndPrependOlder(sessionId, store, meta.oldestCursor); - return added === 0 ? "stop" : "continue"; - } catch (err) { - debug("autoBackfill: fetch failed, stopping", { sessionId, round, err }); - return "stop"; - } -} - -export async function autoBackfillUntilUserMessage( - sessionId: string, - store: ReturnType, -): Promise { - for (let round = 0; round < MAX_AUTO_BACKFILL_PAGES; round++) { - const step = await runBackfillRound(sessionId, store, round); - if (step === "stop") return; - } - debug("autoBackfill: hit page budget without finding user/agent message", { - sessionId, - pageBudget: MAX_AUTO_BACKFILL_PAGES, - messageBudget: MAX_AUTO_BACKFILL_PAGES * BACKFILL_PAGE_LIMIT, - }); -} - type FetchMessagesParams = { taskSessionId: string; store: ReturnType; + queryClient: QueryClient; setIsLoading: (v: boolean) => void; setIsWaitingForInitialMessages: (v: boolean) => void; initialFetchStartRef: MutableRefObject; @@ -310,6 +64,7 @@ type FetchMessagesParams = { async function doFetchMessages({ taskSessionId, store, + queryClient, setIsLoading, setIsWaitingForInitialMessages, initialFetchStartRef, @@ -323,11 +78,11 @@ async function doFetchMessages({ setIsWaitingForInitialMessages(true); } try { - const fetched = await fetchAndStoreMessages(taskSessionId, store); + const fetched = await fetchAndStoreMessages(taskSessionId, store, queryClient); lastFetchedSessionIdRef.current = taskSessionId; if (fetched.length > 0) setIsWaitingForInitialMessages(false); if (fetched.length > 0 && !hasUserOrAgentMessage(fetched)) { - await autoBackfillUntilUserMessage(taskSessionId, store); + await autoBackfillUntilUserMessage(taskSessionId, store, queryClient); } } catch (error) { if (onError) onError(error); @@ -346,6 +101,7 @@ function useTerminalStateFetch( hasAgentMessage: boolean, refs: { store: ReturnType; + queryClient: QueryClient; setIsLoading: (v: boolean) => void; setIsWaitingForInitialMessages: (v: boolean) => void; initialFetchStartRef: MutableRefObject; @@ -381,6 +137,7 @@ function useTerminalStateFetch( export function useVisibilityBackfill( taskSessionId: string | null, store: ReturnType, + queryClient: QueryClient, ) { useEffect(() => { if (!taskSessionId) { @@ -402,7 +159,7 @@ export function useVisibilityBackfill( newestBefore, }); if (visibilityState !== "visible") return; - fetchAndStoreMessages(taskSessionId, store) + fetchAndStoreMessages(taskSessionId, store, queryClient) .then(() => { const afterCount = store.getState().messages.bySession[taskSessionId]?.length ?? 0; const newestAfter = @@ -423,7 +180,7 @@ export function useVisibilityBackfill( document.removeEventListener("visibilitychange", onVisible); debug("visibilityBackfill: detached", { sessionId: taskSessionId }); }; - }, [taskSessionId, store]); + }, [taskSessionId, store, queryClient]); } function useSessionSubscription( @@ -431,6 +188,7 @@ function useSessionSubscription( connectionStatus: string, isSessionStartingOrUnknown: boolean, store: ReturnType, + queryClient: QueryClient, ) { useEffect(() => { debug("subscription: effect ran", { @@ -455,13 +213,13 @@ function useSessionSubscription( // Re-fetch messages after subscribing to close the gap between SSR // (which may have run before the agent responded) and this subscription. - fetchAndStoreMessages(taskSessionId, store).catch(() => {}); + fetchAndStoreMessages(taskSessionId, store, queryClient).catch(() => {}); return () => { debug("subscription: unsubscribing", { sessionId: taskSessionId }); unsubscribe(); }; - }, [taskSessionId, connectionStatus, store, isSessionStartingOrUnknown]); + }, [taskSessionId, connectionStatus, store, isSessionStartingOrUnknown, queryClient]); } /** @@ -478,6 +236,7 @@ function useResyncOnTurnSettle( taskSessionState: TaskSessionState | null, connectionStatus: string, store: ReturnType, + queryClient: QueryClient, ) { const prevRef = useRef<{ sessionId: string | null; state: TaskSessionState | null }>({ sessionId: null, @@ -494,14 +253,15 @@ function useResyncOnTurnSettle( prev: prevState, next: taskSessionState, }); - fetchAndStoreMessages(taskSessionId, store).catch(() => {}); - }, [taskSessionId, taskSessionState, connectionStatus, store]); + fetchAndStoreMessages(taskSessionId, store, queryClient).catch(() => {}); + }, [taskSessionId, taskSessionState, connectionStatus, store, queryClient]); } function useRunningMessageBackfill( taskSessionId: string | null, shouldBackfill: boolean, store: ReturnType, + queryClient: QueryClient, ) { useEffect(() => { if (!taskSessionId || !shouldBackfill) return; @@ -511,7 +271,7 @@ function useRunningMessageBackfill( if (inFlight) return; inFlight = true; debug("running backfill", { sessionId: taskSessionId }); - fetchAndStoreMessages(taskSessionId, store) + fetchAndStoreMessages(taskSessionId, store, queryClient) .catch((err) => { debug("running backfill failed", { sessionId: taskSessionId, err }); }) @@ -525,7 +285,7 @@ function useRunningMessageBackfill( window.clearTimeout(initial); window.clearInterval(interval); }; - }, [taskSessionId, shouldBackfill, store]); + }, [taskSessionId, shouldBackfill, store, queryClient]); } function useMessageFetchState(store: ReturnType) { @@ -554,19 +314,23 @@ function useMessageFetchState(store: ReturnType) { } function useSessionMessageInputs(taskSessionId: string | null) { + const sessionQuery = useQuery(taskSessionQueryOptions(taskSessionId ?? "")); + const turnsQuery = useQuery(sessionTurnsQueryOptions(taskSessionId ?? "")); const messages = useAppStore((state) => taskSessionId ? (state.messages.bySession[taskSessionId] ?? EMPTY_MESSAGES) : EMPTY_MESSAGES, ); const messagesMeta = useAppStore((state) => taskSessionId ? (state.messages.metaBySession[taskSessionId] ?? EMPTY_META) : EMPTY_META, ); - const taskSessionState = useAppStore((state) => + const storeTaskSessionState = useAppStore((state) => taskSessionId ? (state.taskSessions.items[taskSessionId]?.state ?? null) : null, ); - const activeTurnId = useAppStore((state) => + const storeActiveTurnId = useAppStore((state) => taskSessionId ? (state.turns.activeBySession[taskSessionId] ?? null) : null, ); const connectionStatus = useAppStore((state) => state.connection.status); + const taskSessionState = sessionQuery.data?.state ?? storeTaskSessionState; + const activeTurnId = turnsQuery.data?.activeTurnId ?? storeActiveTurnId; return { messages, messagesMeta, taskSessionState, activeTurnId, connectionStatus }; } @@ -577,9 +341,17 @@ function useSessionLifecycleSubscriptions(params: { activeTurnId: string | null; messages: Message[]; store: ReturnType; + queryClient: QueryClient; }) { - const { taskSessionId, taskSessionState, connectionStatus, activeTurnId, messages, store } = - params; + const { + taskSessionId, + taskSessionState, + connectionStatus, + activeTurnId, + messages, + store, + queryClient, + } = params; // Bool flips exactly once when a freshly-adopted session leaves STARTING, // so the subscription effect re-runs then (covering the backend race where // session.subscribe arrives before the session is fully constructed) without @@ -591,13 +363,19 @@ function useSessionLifecycleSubscriptions(params: { connectionStatus, }); - useSessionSubscription(taskSessionId, connectionStatus, isSessionStartingOrUnknown, store); + useSessionSubscription( + taskSessionId, + connectionStatus, + isSessionStartingOrUnknown, + store, + queryClient, + ); useUnknownSessionSubscriptionRetryEffect({ taskSessionId, connectionStatus, retryToken: unknownSessionRetryToken, }); - useResyncOnTurnSettle(taskSessionId, taskSessionState, connectionStatus, store); + useResyncOnTurnSettle(taskSessionId, taskSessionState, connectionStatus, store, queryClient); useRunningMessageBackfill( taskSessionId, shouldRunMessageBackfill({ @@ -607,23 +385,40 @@ function useSessionLifecycleSubscriptions(params: { messages, }), store, + queryClient, ); } -export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn { - const store = useAppStoreApi(); - const { messages, messagesMeta, taskSessionState, activeTurnId, connectionStatus } = - useSessionMessageInputs(taskSessionId); - const prevSessionIdRef = useRef(null); - const hasAgentMessage = messages.some((message: Message) => message.author_type === "agent"); +function useLatestMessagesMerge( + taskSessionId: string | null, + latestMessages: + | { messages: Message[]; hasMore: boolean; oldestCursor: string | null } + | undefined, + store: ReturnType, +) { + useEffect(() => { + if (!taskSessionId || !latestMessages) return; + store.getState().mergeMessages(taskSessionId, latestMessages.messages, { + hasMore: latestMessages.hasMore, + oldestCursor: latestMessages.oldestCursor, + }); + }, [taskSessionId, latestMessages, store]); +} + +function useInitialMessageWaitState(params: { + taskSessionId: string | null; + messageCount: number; + initialFetchStartRef: MutableRefObject; + lastFetchedSessionIdRef: MutableRefObject; + setIsWaitingForInitialMessages: (waiting: boolean) => void; +}) { const { - isLoading, - isWaitingForInitialMessages, - setIsWaitingForInitialMessages, + taskSessionId, + messageCount, initialFetchStartRef, lastFetchedSessionIdRef, - refs: fetchRefs, - } = useMessageFetchState(store); + setIsWaitingForInitialMessages, + } = params; useEffect(() => { if (!taskSessionId) { @@ -640,7 +435,7 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess useEffect(() => { if (!taskSessionId) return; - if (messages.length > 0) { + if (messageCount > 0) { setIsWaitingForInitialMessages(false); return; } @@ -648,7 +443,36 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess initialFetchStartRef.current = Date.now(); setIsWaitingForInitialMessages(true); } - }, [taskSessionId, messages.length, initialFetchStartRef, setIsWaitingForInitialMessages]); + }, [taskSessionId, messageCount, initialFetchStartRef, setIsWaitingForInitialMessages]); +} + +export function useSessionMessages(taskSessionId: string | null): UseSessionMessagesReturn { + const store = useAppStoreApi(); + const queryClient = useQueryClient(); + const { messages, messagesMeta, taskSessionState, activeTurnId, connectionStatus } = + useSessionMessageInputs(taskSessionId); + const latestMessagesQuery = useQuery( + sessionMessagesLatestQueryOptions(taskSessionId ?? "", INITIAL_FETCH_LIMIT), + ); + const prevSessionIdRef = useRef(null); + const hasAgentMessage = messages.some((message: Message) => message.author_type === "agent"); + const { + isLoading, + isWaitingForInitialMessages, + setIsWaitingForInitialMessages, + initialFetchStartRef, + lastFetchedSessionIdRef, + refs: fetchRefs, + } = useMessageFetchState(store); + + useLatestMessagesMerge(taskSessionId, latestMessagesQuery.data, store); + useInitialMessageWaitState({ + taskSessionId, + messageCount: messages.length, + initialFetchStartRef, + lastFetchedSessionIdRef, + setIsWaitingForInitialMessages, + }); useEffect(() => { if (!taskSessionId || connectionStatus !== "connected") return; @@ -673,7 +497,7 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess if (isFreshMount && messages.length > 0) { lastFetchedSessionIdRef.current = taskSessionId; setIsWaitingForInitialMessages(false); - fetchAndStoreMessages(taskSessionId, store).catch(() => {}); + fetchAndStoreMessages(taskSessionId, store, queryClient).catch(() => {}); return; } @@ -682,12 +506,14 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess void doFetchMessages({ taskSessionId, ...fetchRefs, + queryClient, }); }, [ taskSessionId, connectionStatus, messages.length, store, + queryClient, lastFetchedSessionIdRef, setIsWaitingForInitialMessages, fetchRefs, @@ -700,14 +526,22 @@ export function useSessionMessages(taskSessionId: string | null): UseSessionMess activeTurnId, messages, store, + queryClient, }); - useVisibilityBackfill(taskSessionId, store); + useVisibilityBackfill(taskSessionId, store, queryClient); - useTerminalStateFetch(taskSessionId, taskSessionState, hasAgentMessage, fetchRefs); + useTerminalStateFetch(taskSessionId, taskSessionState, hasAgentMessage, { + ...fetchRefs, + queryClient, + }); return { - isLoading: isLoading || isWaitingForInitialMessages || messagesMeta.isLoading, + isLoading: + isLoading || + isWaitingForInitialMessages || + messagesMeta.isLoading || + (latestMessagesQuery.isPending && messages.length === 0), messages, hasMore: messagesMeta.hasMore, oldestCursor: messagesMeta.oldestCursor, diff --git a/apps/web/hooks/domains/session/use-session-model.ts b/apps/web/hooks/domains/session/use-session-model.ts index d9a529e33c..0ba732ad0c 100644 --- a/apps/web/hooks/domains/session/use-session-model.ts +++ b/apps/web/hooks/domains/session/use-session-model.ts @@ -1,13 +1,18 @@ import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { agentsQueryOptions } from "@/lib/query/query-options/settings"; +import { sessionModelsQueryOptions } from "@/lib/query/query-options"; import type { AgentProfile } from "@/lib/types/http"; export function useSessionModel( resolvedSessionId: string | null, agentProfileId: string | null | undefined, ) { + const sessionModelsQuery = useQuery(sessionModelsQueryOptions(resolvedSessionId ?? "")); + const settingsAgentsQuery = useQuery(agentsQueryOptions()); // Get model from agent profile using agent_profile_id - const settingsAgents = useAppStore((state) => state.settingsAgents.items); + const settingsAgents = settingsAgentsQuery.data?.agents ?? []; const sessionProfile = useMemo(() => { if (!agentProfileId) return null; for (const agent of settingsAgents) { @@ -22,11 +27,12 @@ export function useSessionModel( // ACP agents report their actual current model via session_models events. // Use that as the authoritative "session model" so comparisons in useMessageHandler // use the same ID space (ACP IDs) as the model selector dropdown. - const acpCurrentModel = useAppStore((state) => + const storeAcpCurrentModel = useAppStore((state) => resolvedSessionId ? state.sessionModels.bySessionId[resolvedSessionId]?.currentModelId || null : null, ); + const acpCurrentModel = sessionModelsQuery.data?.currentModelId || storeAcpCurrentModel; // Get active model state for this session (user's selected model) const activeModels = useAppStore((state) => state.activeModel.bySessionId); diff --git a/apps/web/hooks/domains/session/use-session-search.test.ts b/apps/web/hooks/domains/session/use-session-search.test.ts index ac45f64707..6c9f0e8bff 100644 --- a/apps/web/hooks/domains/session/use-session-search.test.ts +++ b/apps/web/hooks/domains/session/use-session-search.test.ts @@ -1,10 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { act, cleanup, renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { makeQueryClient } from "@/lib/query/client"; import type { MessageSearchHit } from "@/lib/api/domains/session-api"; const mockSearch = vi.fn(); vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(), + listSessionTurns: vi.fn(), + listTaskSessionMessages: vi.fn(), + listTaskSessions: vi.fn(), searchSessionMessages: (sessionId: string, query: string, limit: number) => mockSearch(sessionId, query, limit), })); @@ -27,6 +34,13 @@ async function flush() { await Promise.resolve(); } +function renderSessionSearch(sessionId: string | null, loadOlder?: () => Promise) { + const client = makeQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); + return renderHook(() => useSessionSearch(sessionId, loadOlder), { wrapper }); +} + describe("useSessionSearch", () => { beforeEach(() => { vi.useFakeTimers(); @@ -40,7 +54,7 @@ describe("useSessionSearch", () => { it("debounces rapid setQuery calls into a single search", async () => { mockSearch.mockResolvedValue({ hits: [makeHit("m1")], total: 1 }); - const { result } = renderHook(() => useSessionSearch("sess-1")); + const { result } = renderSessionSearch("sess-1"); act(() => { result.current.open(); @@ -67,7 +81,7 @@ describe("useSessionSearch", () => { .mockReturnValueOnce(first) .mockResolvedValueOnce({ hits: [makeHit("second")], total: 1 }); - const { result } = renderHook(() => useSessionSearch("sess-1")); + const { result } = renderSessionSearch("sess-1"); act(() => { result.current.open(); result.current.setQuery("first"); @@ -96,7 +110,7 @@ describe("useSessionSearch", () => { }); it("skips the request when sessionId is null", async () => { - const { result } = renderHook(() => useSessionSearch(null)); + const { result } = renderSessionSearch(null); act(() => { result.current.open(); result.current.setQuery("anything"); @@ -110,7 +124,7 @@ describe("useSessionSearch", () => { it("clears hits and query when closed", async () => { mockSearch.mockResolvedValue({ hits: [makeHit("m1")], total: 1 }); - const { result } = renderHook(() => useSessionSearch("sess-1")); + const { result } = renderSessionSearch("sess-1"); act(() => { result.current.open(); result.current.setQuery("foo"); @@ -127,3 +141,43 @@ describe("useSessionSearch", () => { expect(result.current.query).toBe(""); }); }); + +describe("useSessionSearch repeated queries", () => { + beforeEach(() => { + vi.useFakeTimers(); + mockSearch.mockReset(); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + it("refetches repeated searches within the Query stale window", async () => { + mockSearch + .mockResolvedValueOnce({ hits: [makeHit("old")], total: 1 }) + .mockResolvedValueOnce({ hits: [makeHit("new")], total: 1 }); + const { result } = renderSessionSearch("sess-1"); + + act(() => { + result.current.open(); + result.current.setQuery("repeat"); + }); + await act(async () => { + vi.advanceTimersByTime(200); + await flush(); + }); + expect(result.current.hits[0]?.id).toBe("old"); + + act(() => { + result.current.setQuery("repeat"); + }); + await act(async () => { + vi.advanceTimersByTime(200); + await flush(); + }); + + expect(mockSearch).toHaveBeenCalledTimes(2); + expect(result.current.hits[0]?.id).toBe("new"); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-search.ts b/apps/web/hooks/domains/session/use-session-search.ts index 9906b5aa37..9a8058b2ac 100644 --- a/apps/web/hooks/domains/session/use-session-search.ts +++ b/apps/web/hooks/domains/session/use-session-search.ts @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import { searchSessionMessages, type MessageSearchHit } from "@/lib/api/domains/session-api"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { sessionSearchQueryOptions } from "@/lib/query/query-options"; +import type { MessageSearchHit } from "@/lib/api/domains/session-api"; type SessionSearchState = { isOpen: boolean; @@ -24,6 +26,7 @@ export type SessionSearchHook = SessionSearchState & { /** Debounced fetch + request-ID cancellation. */ function useDebouncedSearch( sessionId: string | null | undefined, + queryClient: QueryClient, setHits: (hits: MessageSearchHit[]) => void, setIsSearching: (v: boolean) => void, ) { @@ -40,7 +43,10 @@ function useDebouncedSearch( const myId = ++requestIdRef.current; setIsSearching(true); try { - const resp = await searchSessionMessages(sessionId, trimmed, 50); + const resp = await queryClient.fetchQuery({ + ...sessionSearchQueryOptions(sessionId, trimmed, 50), + staleTime: 0, + }); if (requestIdRef.current !== myId) return; setHits(resp.hits ?? []); } catch (err) { @@ -51,7 +57,7 @@ function useDebouncedSearch( if (requestIdRef.current === myId) setIsSearching(false); } }, - [sessionId, setHits, setIsSearching], + [sessionId, queryClient, setHits, setIsSearching], ); } @@ -97,6 +103,7 @@ export function useSessionSearch( sessionId: string | null | undefined, loadOlder?: () => Promise, ): SessionSearchHook { + const queryClient = useQueryClient(); const [isOpen, setIsOpen] = useState(false); const [query, setQueryState] = useState(""); const [hits, setHits] = useState([]); @@ -107,7 +114,7 @@ export function useSessionSearch( // backfill loop (new click, search bar close, or component unmount). const activeHitGenRef = useRef(0); - const runSearch = useDebouncedSearch(sessionId, setHits, setIsSearching); + const runSearch = useDebouncedSearch(sessionId, queryClient, setHits, setIsSearching); const setQuery = useCallback( (q: string) => { diff --git a/apps/web/hooks/domains/session/use-session-state.test.ts b/apps/web/hooks/domains/session/use-session-state.test.ts index 2e2ee01585..6fa16e51c8 100644 --- a/apps/web/hooks/domains/session/use-session-state.test.ts +++ b/apps/web/hooks/domains/session/use-session-state.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { TaskSession } from "@/lib/types/http"; // Mock state @@ -36,6 +38,13 @@ vi.mock("@/hooks/use-task", () => ({ import { useSessionState } from "./use-session-state"; +function createWrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + const createMockSession = ( id: string, taskId: string, @@ -48,140 +57,145 @@ const createMockSession = ( error_message: "", }) as TaskSession; -describe("useSessionState", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockActiveTaskId = null; - mockActiveSessionId = null; - mockSessionItems = {}; - mockSession = null; - mockTask = null; - mockPrepareProgress = {}; - }); +function renderSessionState( + sessionId: string | null, + options?: Parameters[1], +) { + return renderHook(() => useSessionState(sessionId, options), { wrapper: createWrapper() }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockActiveTaskId = null; + mockActiveSessionId = null; + mockSessionItems = {}; + mockSession = null; + mockTask = null; + mockPrepareProgress = {}; +}); - describe("resolvedSessionId", () => { - it("uses sessionId directly when provided", () => { - mockActiveTaskId = "task-1"; - mockActiveSessionId = "session-old"; - mockSessionItems = { "session-old": createMockSession("session-old", "task-old") }; +describe("useSessionState resolvedSessionId", () => { + it("uses sessionId directly when provided", () => { + mockActiveTaskId = "task-1"; + mockActiveSessionId = "session-old"; + mockSessionItems = { "session-old": createMockSession("session-old", "task-old") }; - const { result } = renderHook(() => useSessionState("session-explicit")); + const { result } = renderSessionState("session-explicit"); - expect(result.current.resolvedSessionId).toBe("session-explicit"); - }); + expect(result.current.resolvedSessionId).toBe("session-explicit"); + }); - it("uses activeSessionId when sessionId is null and session belongs to active task", () => { - mockActiveTaskId = "task-1"; - mockActiveSessionId = "session-1"; - mockSessionItems = { "session-1": createMockSession("session-1", "task-1") }; + it("uses activeSessionId when sessionId is null and session belongs to active task", () => { + mockActiveTaskId = "task-1"; + mockActiveSessionId = "session-1"; + mockSessionItems = { "session-1": createMockSession("session-1", "task-1") }; - const { result } = renderHook(() => useSessionState(null)); + const { result } = renderSessionState(null); - expect(result.current.resolvedSessionId).toBe("session-1"); - }); + expect(result.current.resolvedSessionId).toBe("session-1"); + }); - it("returns null when sessionId is null and activeSessionId belongs to different task", () => { - mockActiveTaskId = "task-2"; - mockActiveSessionId = "session-1"; - mockSessionItems = { "session-1": createMockSession("session-1", "task-1") }; + it("returns null when sessionId is null and activeSessionId belongs to different task", () => { + mockActiveTaskId = "task-2"; + mockActiveSessionId = "session-1"; + mockSessionItems = { "session-1": createMockSession("session-1", "task-1") }; - const { result } = renderHook(() => useSessionState(null)); + const { result } = renderSessionState(null); - expect(result.current.resolvedSessionId).toBeNull(); - }); + expect(result.current.resolvedSessionId).toBeNull(); + }); - it("returns null when sessionId is null and session not yet in store", () => { - mockActiveTaskId = "task-1"; - mockActiveSessionId = "session-1"; - mockSessionItems = {}; // Session not loaded yet + it("returns null when sessionId is null and session not yet in store", () => { + mockActiveTaskId = "task-1"; + mockActiveSessionId = "session-1"; + mockSessionItems = {}; - const { result } = renderHook(() => useSessionState(null)); + const { result } = renderSessionState(null); - expect(result.current.resolvedSessionId).toBeNull(); - }); + expect(result.current.resolvedSessionId).toBeNull(); + }); - it("returns null when sessionId is null and activeSessionId is null", () => { - mockActiveTaskId = "task-1"; - mockActiveSessionId = null; - mockSessionItems = {}; + it("returns null when sessionId is null and activeSessionId is null", () => { + mockActiveTaskId = "task-1"; + mockActiveSessionId = null; + mockSessionItems = {}; - const { result } = renderHook(() => useSessionState(null)); + const { result } = renderSessionState(null); - expect(result.current.resolvedSessionId).toBeNull(); - }); + expect(result.current.resolvedSessionId).toBeNull(); + }); - it("uses taskIdHint while an explicit session row is hydrating", () => { - mockSession = null; + it("uses taskIdHint while an explicit session row is hydrating", () => { + mockSession = null; - const { result } = renderHook(() => useSessionState("session-new", { taskIdHint: "task-1" })); + const { result } = renderSessionState("session-new", { taskIdHint: "task-1" }); - expect(result.current.resolvedSessionId).toBe("session-new"); - expect(result.current.taskId).toBe("task-1"); - }); + expect(result.current.resolvedSessionId).toBe("session-new"); + expect(result.current.taskId).toBe("task-1"); + }); - it("prefers the hydrated session task over taskIdHint", () => { - mockSession = createMockSession("session-1", "task-2"); + it("prefers the hydrated session task over taskIdHint", () => { + mockSession = createMockSession("session-1", "task-2"); - const { result } = renderHook(() => useSessionState("session-1", { taskIdHint: "task-1" })); + const { result } = renderSessionState("session-1", { taskIdHint: "task-1" }); - expect(result.current.taskId).toBe("task-2"); - }); + expect(result.current.taskId).toBe("task-2"); }); +}); - describe("session flags", () => { - it("sets isStarting when session state is STARTING", () => { - mockSession = createMockSession("session-1", "task-1", "STARTING"); +describe("useSessionState session flags", () => { + it("sets isStarting when session state is STARTING", () => { + mockSession = createMockSession("session-1", "task-1", "STARTING"); - const { result } = renderHook(() => useSessionState("session-1")); + const { result } = renderSessionState("session-1"); - expect(result.current.isStarting).toBe(true); - expect(result.current.isWorking).toBe(true); - }); + expect(result.current.isStarting).toBe(true); + expect(result.current.isWorking).toBe(true); + }); - it("does not set isStarting when session state is CREATED", () => { - mockSession = createMockSession("session-1", "task-1", "CREATED"); + it("does not set isStarting when session state is CREATED", () => { + mockSession = createMockSession("session-1", "task-1", "CREATED"); - const { result } = renderHook(() => useSessionState("session-1")); + const { result } = renderSessionState("session-1"); - // CREATED is not isStarting — the input should be enabled so tests - // that create sessions and immediately fill() the input work correctly. - expect(result.current.isStarting).toBe(false); - }); + // CREATED is not isStarting: the input should be enabled so tests that + // create sessions and immediately fill() the input work correctly. + expect(result.current.isStarting).toBe(false); + }); - it("does not set isStarting when WAITING_FOR_INPUT", () => { - mockSession = createMockSession("session-1", "task-1", "WAITING_FOR_INPUT"); + it("does not set isStarting when WAITING_FOR_INPUT", () => { + mockSession = createMockSession("session-1", "task-1", "WAITING_FOR_INPUT"); - const { result } = renderHook(() => useSessionState("session-1")); + const { result } = renderSessionState("session-1"); - expect(result.current.isStarting).toBe(false); - expect(result.current.isWorking).toBe(false); - }); + expect(result.current.isStarting).toBe(false); + expect(result.current.isWorking).toBe(false); + }); - it("sets isStarting while environment preparation is still live", () => { - mockSession = createMockSession("session-1", "task-1", "WAITING_FOR_INPUT"); - mockPrepareProgress = { "session-1": { status: "preparing" } }; + it("sets isStarting while environment preparation is still live", () => { + mockSession = createMockSession("session-1", "task-1", "WAITING_FOR_INPUT"); + mockPrepareProgress = { "session-1": { status: "preparing" } }; - const { result } = renderHook(() => useSessionState("session-1")); + const { result } = renderSessionState("session-1"); - expect(result.current.isStarting).toBe(true); - expect(result.current.isWorking).toBe(true); - }); + expect(result.current.isStarting).toBe(true); + expect(result.current.isWorking).toBe(true); + }); - it("sets isAgentBusy when session state is RUNNING", () => { - mockSession = createMockSession("session-1", "task-1", "RUNNING"); + it("sets isAgentBusy when session state is RUNNING", () => { + mockSession = createMockSession("session-1", "task-1", "RUNNING"); - const { result } = renderHook(() => useSessionState("session-1")); + const { result } = renderSessionState("session-1"); - expect(result.current.isAgentBusy).toBe(true); - expect(result.current.isWorking).toBe(true); - }); + expect(result.current.isAgentBusy).toBe(true); + expect(result.current.isWorking).toBe(true); + }); - it("sets isFailed when session state is FAILED", () => { - mockSession = createMockSession("session-1", "task-1", "FAILED"); + it("sets isFailed when session state is FAILED", () => { + mockSession = createMockSession("session-1", "task-1", "FAILED"); - const { result } = renderHook(() => useSessionState("session-1")); + const { result } = renderSessionState("session-1"); - expect(result.current.isFailed).toBe(true); - }); + expect(result.current.isFailed).toBe(true); }); }); diff --git a/apps/web/hooks/domains/session/use-session-state.ts b/apps/web/hooks/domains/session/use-session-state.ts index 6b5fb804bd..b0e55cbe71 100644 --- a/apps/web/hooks/domains/session/use-session-state.ts +++ b/apps/web/hooks/domains/session/use-session-state.ts @@ -1,6 +1,8 @@ +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { useSession } from "@/hooks/domains/session/use-session"; import { useTask } from "@/hooks/use-task"; +import { prepareProgressQueryOptions } from "@/lib/query/query-options"; import type { TaskSession } from "@/lib/types/http"; function deriveSessionFlags(state: TaskSession["state"] | undefined, errorMessage?: string) { @@ -16,29 +18,33 @@ type UseSessionStateOptions = { taskIdHint?: string | null; }; -export function useSessionState(sessionId: string | null, options: UseSessionStateOptions = {}) { - const { taskIdHint = null } = options; +function useValidatedActiveSessionId() { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); - - // Validate that active session belongs to the active task before using it. - // This prevents showing messages from an unrelated session when navigating - // to a task that has no sessions yet (activeSessionId may still hold the - // old session from the previous task). const activeSessionData = useAppStore((state) => activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null, ); - const validatedActiveSessionId = - activeSessionData && activeSessionData.task_id === activeTaskId ? activeSessionId : null; + return activeSessionData && activeSessionData.task_id === activeTaskId ? activeSessionId : null; +} + +function usePrepareStatus(resolvedSessionId: string | null) { + const prepareQuery = useQuery(prepareProgressQueryOptions(resolvedSessionId ?? "")); + const storePrepareStatus = useAppStore((state) => + resolvedSessionId ? state.prepareProgress.bySessionId[resolvedSessionId]?.status : undefined, + ); + return prepareQuery.data?.status ?? storePrepareStatus; +} + +export function useSessionState(sessionId: string | null, options: UseSessionStateOptions = {}) { + const { taskIdHint = null } = options; + const validatedActiveSessionId = useValidatedActiveSessionId(); const resolvedSessionId = sessionId ?? validatedActiveSessionId; const { session } = useSession(resolvedSessionId); const taskId = session?.task_id ?? taskIdHint ?? null; const task = useTask(taskId); - const prepareStatus = useAppStore((state) => - resolvedSessionId ? state.prepareProgress.bySessionId[resolvedSessionId]?.status : undefined, - ); + const prepareStatus = usePrepareStatus(resolvedSessionId); const taskDescription = task?.description ?? null; const flags = deriveSessionFlags(session?.state, session?.error_message); diff --git a/apps/web/hooks/domains/session/use-session-turn.ts b/apps/web/hooks/domains/session/use-session-turn.ts index 727bff7b7e..67b3267936 100644 --- a/apps/web/hooks/domains/session/use-session-turn.ts +++ b/apps/web/hooks/domains/session/use-session-turn.ts @@ -1,5 +1,7 @@ import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { sessionTurnsQueryOptions } from "@/lib/query/query-options"; import type { Turn } from "@/lib/types/http"; /** @@ -30,11 +32,16 @@ function formatDuration(seconds: number): string { * @returns The last completed turn with its duration and model */ export function useSessionTurn(sessionId: string | null) { - const turns = useAppStore((state) => (sessionId ? state.turns.bySession[sessionId] : undefined)); - const activeTurnId = useAppStore((state) => + const turnsQuery = useQuery(sessionTurnsQueryOptions(sessionId ?? "")); + const storeTurns = useAppStore((state) => + sessionId ? state.turns.bySession[sessionId] : undefined, + ); + const storeActiveTurnId = useAppStore((state) => sessionId ? state.turns.activeBySession[sessionId] : null, ); const session = useAppStore((state) => (sessionId ? state.taskSessions.items[sessionId] : null)); + const turns = turnsQuery.data?.turns ?? storeTurns; + const activeTurnId = turnsQuery.data?.activeTurnId ?? storeActiveTurnId; // Get model from session's agent_profile_snapshot const sessionModel = useMemo(() => { diff --git a/apps/web/hooks/domains/session/use-session-worktrees.test.tsx b/apps/web/hooks/domains/session/use-session-worktrees.test.tsx new file mode 100644 index 0000000000..424d0aa8c2 --- /dev/null +++ b/apps/web/hooks/domains/session/use-session-worktrees.test.tsx @@ -0,0 +1,79 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { describe, expect, it } from "vitest"; +import { qk } from "@/lib/query/keys"; +import type { Worktree } from "@/lib/state/slices/session/types"; +import { + repositoryId, + sessionId as toSessionId, + taskId as toTaskId, + type TaskSession, +} from "@/lib/types/http"; +import { useSessionWorktrees } from "./use-session-worktrees"; + +const SESSION_ID = toSessionId("session-1"); +const TASK_ID = toTaskId("task-1"); +const REPOSITORY_ID = repositoryId("repo-1"); +const PRIMARY_WORKTREE_PATH = "/tmp/kandev/worktrees/primary-worktree"; +const SIBLING_WORKTREE_PATH = "/tmp/kandev/worktrees/sibling-worktree"; +const TIMESTAMP = "2026-06-24T00:00:00Z"; + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); +} + +function wrapper(client: QueryClient) { + return function TestWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + +function makeSession(overrides: Partial = {}): TaskSession { + return { + id: SESSION_ID, + task_id: TASK_ID, + state: "RUNNING", + started_at: TIMESTAMP, + updated_at: TIMESTAMP, + ...overrides, + }; +} + +describe("useSessionWorktrees", () => { + it("prepends the primary session worktree when the passive cache only has a sibling", () => { + const queryClient = makeQueryClient(); + const primary: Worktree = { + id: "primary-worktree", + sessionId: SESSION_ID, + repositoryId: REPOSITORY_ID, + path: PRIMARY_WORKTREE_PATH, + branch: "main", + }; + const sibling: Worktree = { + id: "sibling-worktree", + sessionId: SESSION_ID, + repositoryId: REPOSITORY_ID, + path: SIBLING_WORKTREE_PATH, + branch: "feature/sibling", + }; + queryClient.setQueryData( + qk.taskSession.byId(SESSION_ID), + makeSession({ + repository_id: REPOSITORY_ID, + worktree_id: primary.id, + worktree_path: PRIMARY_WORKTREE_PATH, + worktree_branch: primary.branch, + }), + ); + queryClient.setQueryData(qk.sessionRuntime.worktrees(SESSION_ID), [sibling]); + + const { result } = renderHook(() => useSessionWorktrees(SESSION_ID), { + wrapper: wrapper(queryClient), + }); + + expect(result.current).toEqual([primary, sibling]); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-worktrees.ts b/apps/web/hooks/domains/session/use-session-worktrees.ts index be8389df24..50e6b1fb66 100644 --- a/apps/web/hooks/domains/session/use-session-worktrees.ts +++ b/apps/web/hooks/domains/session/use-session-worktrees.ts @@ -1,24 +1,46 @@ import { useMemo } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { useSession } from "@/hooks/domains/session/use-session"; +import { useQuery } from "@tanstack/react-query"; +import { sessionWorktreesQueryOptions, taskSessionQueryOptions } from "@/lib/query/query-options"; +import type { Worktree } from "@/lib/state/slices/session/types"; +import type { TaskSession } from "@/lib/types/http"; + +function primaryWorktreeFromSession(session: TaskSession | null | undefined): Worktree | null { + if (!session?.worktree_id) return null; + return { + id: session.worktree_id, + sessionId: session.id, + repositoryId: session.repository_id ?? undefined, + path: session.worktree_path ?? undefined, + branch: session.worktree_branch ?? undefined, + }; +} + +function mergePrimaryWorktree(worktrees: Worktree[], session: TaskSession | null | undefined) { + const primary = primaryWorktreeFromSession(session); + if (!primary || worktrees.some((worktree) => worktree.id === primary.id)) return worktrees; + return [primary, ...worktrees]; +} export function useSessionWorktrees(sessionId: string | null) { - const { session } = useSession(sessionId); - const worktrees = useAppStore((state) => state.worktrees.items); - const sessionWorktreesBySessionId = useAppStore( - (state) => state.sessionWorktreesBySessionId.itemsBySessionId, - ); + const sessionQuery = useQuery(taskSessionQueryOptions(sessionId ?? "")); + const session = sessionQuery.data; + const worktreesQuery = useQuery(sessionWorktreesQueryOptions(sessionId ?? "")); return useMemo(() => { if (!sessionId) return []; - const worktreeIds = sessionWorktreesBySessionId[sessionId]; - if (worktreeIds?.length) { - return worktreeIds.map((id: string) => worktrees[id]).filter(Boolean); - } - if (session?.worktree_id) { - const worktree = worktrees[session.worktree_id]; - return worktree ? [worktree] : []; + if (worktreesQuery.data?.length) { + return mergePrimaryWorktree(worktreesQuery.data, session); } + const primary = primaryWorktreeFromSession(session); + if (primary) return [primary]; return []; - }, [session?.worktree_id, sessionId, sessionWorktreesBySessionId, worktrees]); + }, [ + session?.id, + session?.repository_id, + session?.worktree_branch, + session?.worktree_id, + session?.worktree_path, + sessionId, + worktreesQuery.data, + ]); } diff --git a/apps/web/hooks/domains/session/use-session.ts b/apps/web/hooks/domains/session/use-session.ts index f998abd4e3..a666940003 100644 --- a/apps/web/hooks/domains/session/use-session.ts +++ b/apps/web/hooks/domains/session/use-session.ts @@ -1,5 +1,7 @@ import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { sessionAgentctlQueryOptions, taskSessionQueryOptions } from "@/lib/query/query-options"; import { getWebSocketClient } from "@/lib/ws/connection"; import type { TaskSession } from "@/lib/types/http"; @@ -11,13 +13,23 @@ type UseSessionResult = { }; export function useSession(sessionId: string | null): UseSessionResult { - const session = useAppStore((state) => + const sessionQuery = useQuery(taskSessionQueryOptions(sessionId ?? "")); + const agentctlQuery = useQuery(sessionAgentctlQueryOptions(sessionId ?? "")); + const storeSession = useAppStore((state) => sessionId ? (state.taskSessions.items[sessionId] ?? null) : null, ); + const setTaskSession = useAppStore((state) => state.setTaskSession); const connectionStatus = useAppStore((state) => state.connection.status); - const agentctlReady = useAppStore((state) => + const storeAgentctlReady = useAppStore((state) => sessionId ? state.sessionAgentctl.itemsBySessionId[sessionId]?.status === "ready" : false, ); + const agentctlReady = agentctlQuery.data?.status === "ready" || storeAgentctlReady; + const session = sessionQuery.data ?? storeSession; + + useEffect(() => { + if (!sessionQuery.data) return; + setTaskSession(sessionQuery.data); + }, [sessionQuery.data, setTaskSession]); const isActive = useMemo(() => { if (!session?.state) return false; diff --git a/apps/web/hooks/domains/session/use-task-executor-profile.test.ts b/apps/web/hooks/domains/session/use-task-executor-profile.test.ts index ed09e08aca..be80aa0364 100644 --- a/apps/web/hooks/domains/session/use-task-executor-profile.test.ts +++ b/apps/web/hooks/domains/session/use-task-executor-profile.test.ts @@ -61,11 +61,8 @@ const BASE_ENV = { updated_at: TIMESTAMP, }; -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (state: { executors: { items: Executor[] } }) => unknown) => - selector({ - executors: { items: mockExecutors }, - }), +vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ + useSettingsData: () => ({ executors: mockExecutors }), })); vi.mock("@/lib/api/domains/task-environment-api", () => ({ diff --git a/apps/web/hooks/domains/session/use-task-executor-profile.ts b/apps/web/hooks/domains/session/use-task-executor-profile.ts index ffd9f5ddeb..6a47c6fe79 100644 --- a/apps/web/hooks/domains/session/use-task-executor-profile.ts +++ b/apps/web/hooks/domains/session/use-task-executor-profile.ts @@ -1,11 +1,11 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { fetchTaskEnvironment } from "@/lib/api/domains/task-environment-api"; import type { ExecutorProfile } from "@/lib/types/http"; /** Resolves the executor profile bound to a task's environment. */ export function useTaskExecutorProfile(taskId: string, enabled = true): ExecutorProfile | null { - const executors = useAppStore((state) => state.executors.items); + const { executors } = useSettingsData(enabled); const executorsFingerprint = useMemo( () => executors diff --git a/apps/web/hooks/domains/session/use-task-plan.test.tsx b/apps/web/hooks/domains/session/use-task-plan.test.tsx new file mode 100644 index 0000000000..95f7d2c757 --- /dev/null +++ b/apps/web/hooks/domains/session/use-task-plan.test.tsx @@ -0,0 +1,135 @@ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import type { TaskPlan, TaskPlanRevision } from "@/lib/types/http"; +import { useTaskPlan } from "./use-task-plan"; + +const TEST_TASK_ID = "task-1"; +const TEST_TIMESTAMP = "2026-06-24T00:00:00Z"; + +const apiMocks = vi.hoisted(() => ({ + createTaskPlan: vi.fn(), + deleteTaskPlan: vi.fn(), + getPlanRevision: vi.fn(), + getTaskPlan: vi.fn(), + listPlanRevisions: vi.fn(), + revertPlanRevision: vi.fn(), + updateTaskPlan: vi.fn(), +})); + +const storeState = vi.hoisted(() => ({ + connection: { status: "connected" }, + taskPlans: { + previewRevisionIdByTaskId: {}, + comparePairByTaskId: {}, + }, + hydrateTaskPlanLastSeen: vi.fn(), + markTaskPlanSeen: vi.fn(), + setPreviewRevision: vi.fn(), + toggleComparePair: vi.fn(), + clearComparePair: vi.fn(), +})); + +vi.mock("@/lib/api/domains/plan-api", () => apiMocks); +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), +})); + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function makePlan(overrides: Partial = {}): TaskPlan { + return { + id: "plan-1", + task_id: TEST_TASK_ID, + title: "Plan", + content: "# Plan", + created_by: "user", + created_at: TEST_TIMESTAMP, + updated_at: TEST_TIMESTAMP, + ...overrides, + }; +} + +function makeRevision(overrides: Partial = {}): TaskPlanRevision { + return { + id: "revision-1", + task_id: TEST_TASK_ID, + revision_number: 1, + title: "Plan", + author_kind: "agent", + author_name: "Agent", + revert_of_revision_id: null, + created_at: TEST_TIMESTAMP, + updated_at: TEST_TIMESTAMP, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + apiMocks.listPlanRevisions.mockResolvedValue([]); +}); + +describe("useTaskPlan", () => { + it("forces explicit plan refetches past the stale window", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.taskPlan.detail(TEST_TASK_ID), makePlan({ content: "# Cached" })); + queryClient.setQueryData(qk.taskPlan.revisions(TEST_TASK_ID), []); + apiMocks.getTaskPlan.mockResolvedValue(makePlan({ content: "# Fresh" })); + + const { result } = renderHook(() => useTaskPlan(TEST_TASK_ID), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.plan?.content).toBe("# Cached")); + await act(async () => { + await result.current.refetch(); + }); + + expect(apiMocks.getTaskPlan).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(qk.taskPlan.detail(TEST_TASK_ID))).toMatchObject({ + content: "# Fresh", + }); + expect(storeState.markTaskPlanSeen).toHaveBeenCalledWith(TEST_TASK_ID, TEST_TIMESTAMP); + }); + + it("forces explicit revision loads past the stale window", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.taskPlan.detail(TEST_TASK_ID), makePlan()); + queryClient.setQueryData(qk.taskPlan.revisions(TEST_TASK_ID), [ + makeRevision({ id: "revision-cached", revision_number: 1, title: "Cached" }), + ]); + apiMocks.listPlanRevisions.mockResolvedValue([ + makeRevision({ id: "revision-fresh", revision_number: 2, title: "Fresh" }), + ]); + + const { result } = renderHook(() => useTaskPlan(TEST_TASK_ID), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.revisions[0]?.title).toBe("Cached")); + await act(async () => { + await result.current.loadRevisions(); + }); + + expect(apiMocks.listPlanRevisions).toHaveBeenCalledTimes(1); + expect(queryClient.getQueryData(qk.taskPlan.revisions(TEST_TASK_ID))).toMatchObject([ + { id: "revision-fresh", revision_number: 2, title: "Fresh" }, + ]); + }); +}); diff --git a/apps/web/hooks/domains/session/use-task-plan.ts b/apps/web/hooks/domains/session/use-task-plan.ts index ca0f33e78b..5a9a789236 100644 --- a/apps/web/hooks/domains/session/use-task-plan.ts +++ b/apps/web/hooks/domains/session/use-task-plan.ts @@ -1,17 +1,20 @@ import { useEffect, useCallback, useState, useRef } from "react"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { useAppStore } from "@/components/state-provider"; import { - getTaskPlan, createTaskPlan, updateTaskPlan, deleteTaskPlan, - listPlanRevisions, - getPlanRevision, revertPlanRevision, } from "@/lib/api/domains/plan-api"; +import { + planRevisionQueryOptions, + taskPlanQueryOptions, + taskPlanRevisionsQueryOptions, +} from "@/lib/query/query-options"; import type { TaskPlan, TaskPlanRevision } from "@/lib/types/http"; -const EMPTY_REVISIONS: readonly TaskPlanRevision[] = Object.freeze([]); +const EMPTY_REVISIONS = Object.freeze([]) as unknown as TaskPlanRevision[]; /** * Hook to fetch and manage the plan for a task. @@ -21,42 +24,40 @@ const EMPTY_REVISIONS: readonly TaskPlanRevision[] = Object.freeze([]); */ export function useTaskPlan(taskId: string | null, options?: { visible?: boolean }) { const { visible = true } = options ?? {}; + const queryClient = useQueryClient(); const prevVisibleRef = useRef(visible); - const plan = useAppStore((state) => (taskId ? state.taskPlans.byTaskId[taskId] : undefined)); - const isLoading = useAppStore((state) => - taskId ? (state.taskPlans.loadingByTaskId[taskId] ?? false) : false, - ); - const isLoaded = useAppStore((state) => - taskId ? (state.taskPlans.loadedByTaskId[taskId] ?? false) : false, - ); - const isSaving = useAppStore((state) => - taskId ? (state.taskPlans.savingByTaskId[taskId] ?? false) : false, - ); - const setTaskPlan = useAppStore((state) => state.setTaskPlan); - const setTaskPlanLoading = useAppStore((state) => state.setTaskPlanLoading); - const setTaskPlanSaving = useAppStore((state) => state.setTaskPlanSaving); - const markTaskPlanSeen = useAppStore((state) => state.markTaskPlanSeen); const connectionStatus = useAppStore((state) => state.connection.status); + const planQuery = useQuery(taskPlanQueryOptions(taskId ?? "", connectionStatus === "connected")); + const hydrateTaskPlanLastSeen = useAppStore((state) => state.hydrateTaskPlanLastSeen); + const markTaskPlanSeen = useAppStore((state) => state.markTaskPlanSeen); + const plan = planQuery.data; + const isLoading = planQuery.isFetching; + const isLoaded = planQuery.isSuccess; const [error, setError] = useState(null); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + if (!taskId) return; + hydrateTaskPlanLastSeen(taskId); + }, [hydrateTaskPlanLastSeen, taskId]); const fetchPlan = useCallback(async () => { if (!taskId) return; - setTaskPlanLoading(taskId, true); setError(null); try { - const fetchedPlan = await getTaskPlan(taskId); - setTaskPlan(taskId, fetchedPlan); + const fetchedPlan = await queryClient.fetchQuery({ + ...taskPlanQueryOptions(taskId), + staleTime: 0, + }); // Initial fetch is not a notification — mark as seen so no indicator flashes. - markTaskPlanSeen(taskId); + markTaskPlanSeen(taskId, fetchedPlan?.updated_at); } catch (err) { console.error("Failed to fetch task plan:", err); setError(err instanceof Error ? err.message : "Failed to fetch plan"); - } finally { - setTaskPlanLoading(taskId, false); } - }, [taskId, setTaskPlan, setTaskPlanLoading, markTaskPlanSeen]); + }, [taskId, markTaskPlanSeen, queryClient]); // Fetch plan on mount or when taskId changes useEffect(() => { @@ -78,64 +79,88 @@ export function useTaskPlan(taskId: string | null, options?: { visible?: boolean } }, [visible, connectionStatus, taskId, fetchPlan]); + const { savePlan, removePlan } = useTaskPlanMutations({ + taskId, + plan, + setIsSaving, + setError, + queryClient, + markTaskPlanSeen, + }); + + const revisionsBundle = useTaskPlanRevisions(taskId, setIsSaving, setError, queryClient); + + return { + plan: plan ?? null, + isLoading, + isSaving, + error, + savePlan, + deletePlan: removePlan, + refetch: fetchPlan, + ...revisionsBundle, + }; +} + +type TaskPlanMutationArgs = { + taskId: string | null; + plan: TaskPlan | null | undefined; + setIsSaving: (saving: boolean) => void; + setError: (error: string | null) => void; + queryClient: QueryClient; + markTaskPlanSeen: (taskId: string, updatedAt?: string | null) => void; +}; + +function useTaskPlanMutations({ + taskId, + plan, + setIsSaving, + setError, + queryClient, + markTaskPlanSeen, +}: TaskPlanMutationArgs) { const savePlan = useCallback( async (content: string, title?: string): Promise => { if (!taskId) return null; - - setTaskPlanSaving(taskId, true); + setIsSaving(true); setError(null); try { - let savedPlan: TaskPlan; - if (plan) { - // Update existing plan - savedPlan = await updateTaskPlan(taskId, content, title); - } else { - // Create new plan - savedPlan = await createTaskPlan(taskId, content, title); - } - setTaskPlan(taskId, savedPlan); + const savedPlan = plan + ? await updateTaskPlan(taskId, content, title) + : await createTaskPlan(taskId, content, title); + queryClient.setQueryData(taskPlanQueryOptions(taskId).queryKey, savedPlan); + markTaskPlanSeen(taskId, savedPlan.updated_at); return savedPlan; } catch (err) { console.error("Failed to save task plan:", err); setError(err instanceof Error ? err.message : "Failed to save plan"); return null; } finally { - setTaskPlanSaving(taskId, false); + setIsSaving(false); } }, - [taskId, plan, setTaskPlan, setTaskPlanSaving], + [taskId, plan, setIsSaving, setError, queryClient, markTaskPlanSeen], ); const removePlan = useCallback(async (): Promise => { if (!taskId) return false; - - setTaskPlanSaving(taskId, true); + setIsSaving(true); setError(null); try { await deleteTaskPlan(taskId); - setTaskPlan(taskId, null); + queryClient.setQueryData(taskPlanQueryOptions(taskId).queryKey, null); + markTaskPlanSeen(taskId, null); return true; } catch (err) { console.error("Failed to delete task plan:", err); setError(err instanceof Error ? err.message : "Failed to delete plan"); return false; } finally { - setTaskPlanSaving(taskId, false); + setIsSaving(false); } - }, [taskId, setTaskPlan, setTaskPlanSaving]); + }, [taskId, setIsSaving, setError, queryClient, markTaskPlanSeen]); - const revisionsBundle = useTaskPlanRevisions(taskId, setTaskPlanSaving, setError); - - return { - plan: plan ?? null, - isLoading, - isSaving, - error, - savePlan, - deletePlan: removePlan, - refetch: fetchPlan, - ...revisionsBundle, - }; + return { savePlan, removePlan }; } const EMPTY_PAIR: readonly [string | null, string | null] = Object.freeze([ @@ -145,37 +170,30 @@ const EMPTY_PAIR: readonly [string | null, string | null] = Object.freeze([ function useTaskPlanRevisions( taskId: string | null, - setTaskPlanSaving: (taskId: string, saving: boolean) => void, + setIsSaving: (saving: boolean) => void, setError: (err: string | null) => void, + queryClient: QueryClient, ) { - const revisions = useAppStore((state) => - taskId ? (state.taskPlans.revisionsByTaskId[taskId] ?? EMPTY_REVISIONS) : EMPTY_REVISIONS, - ) as TaskPlanRevision[]; - const isLoadingRevisions = useAppStore((state) => - taskId ? (state.taskPlans.revisionsLoadingByTaskId[taskId] ?? false) : false, - ); - const isRevisionsLoaded = useAppStore((state) => - taskId ? (state.taskPlans.revisionsLoadedByTaskId[taskId] ?? false) : false, - ); const connectionStatus = useAppStore((state) => state.connection.status); - const storeApi = useAppStoreApi(); - const setPlanRevisions = useAppStore((state) => state.setPlanRevisions); - const setPlanRevisionsLoading = useAppStore((state) => state.setPlanRevisionsLoading); - const cachePlanRevisionContent = useAppStore((state) => state.cachePlanRevisionContent); + const revisionsQuery = useQuery( + taskPlanRevisionsQueryOptions(taskId ?? "", connectionStatus === "connected"), + ); + const revisions = revisionsQuery.data ?? EMPTY_REVISIONS; + const isLoadingRevisions = revisionsQuery.isFetching; + const isRevisionsLoaded = revisionsQuery.isSuccess; const loadRevisions = useCallback(async () => { if (!taskId) return; - setPlanRevisionsLoading(taskId, true); try { - const list = await listPlanRevisions(taskId); - setPlanRevisions(taskId, list); + await queryClient.fetchQuery({ + ...taskPlanRevisionsQueryOptions(taskId), + staleTime: 0, + }); } catch (err) { console.error("Failed to load plan revisions:", err); setError(err instanceof Error ? err.message : "Failed to load revisions"); - } finally { - setPlanRevisionsLoading(taskId, false); } - }, [taskId, setPlanRevisions, setPlanRevisionsLoading, setError]); + }, [taskId, setError, queryClient]); // Load revisions once on mount — events may have fired before the WS connected. useEffect(() => { @@ -186,38 +204,42 @@ function useTaskPlanRevisions( const loadRevisionContent = useCallback( async (revisionId: string): Promise => { - // Read the cache lazily via the store API inside the callback so this - // function's identity stays stable across cache updates. Selecting the - // cache object as a hook input would re-create the callback whenever - // any task's content was cached, which retriggers the dialogs' - // content-fetch effects (cache short-circuits, but the work is wasted). - const cached = storeApi.getState().taskPlans.revisionContentCache[revisionId]; - if (cached !== undefined) return cached; + if (!taskId) return ""; // Pass taskId so the backend can enforce revision-belongs-to-task. - const rev = await getPlanRevision(revisionId, taskId ?? undefined); - const content = rev.content ?? ""; - cachePlanRevisionContent(revisionId, content); - return content; + const queryOptions = planRevisionQueryOptions(taskId, revisionId); + const cached = queryClient.getQueryData(queryOptions.queryKey); + if (cached?.content !== undefined) return cached.content ?? ""; + const rev = await queryClient.fetchQuery(queryOptions); + return rev.content ?? ""; }, - [taskId, storeApi, cachePlanRevisionContent], + [taskId, queryClient], ); const revertTo = useCallback( async (revisionId: string, authorName?: string): Promise => { if (!taskId) return null; - setTaskPlanSaving(taskId, true); + setIsSaving(true); setError(null); try { - return await revertPlanRevision(taskId, revisionId, authorName); + const revision = await revertPlanRevision(taskId, revisionId, authorName); + queryClient.invalidateQueries({ + exact: true, + queryKey: taskPlanQueryOptions(taskId).queryKey, + }); + queryClient.invalidateQueries({ + exact: true, + queryKey: taskPlanRevisionsQueryOptions(taskId).queryKey, + }); + return revision; } catch (err) { console.error("Failed to revert plan:", err); setError(err instanceof Error ? err.message : "Failed to revert plan"); return null; } finally { - setTaskPlanSaving(taskId, false); + setIsSaving(false); } }, - [taskId, setTaskPlanSaving, setError], + [taskId, setIsSaving, setError, queryClient], ); return { diff --git a/apps/web/hooks/domains/session/use-task-walkthrough.ts b/apps/web/hooks/domains/session/use-task-walkthrough.ts new file mode 100644 index 0000000000..e0c8eedc4d --- /dev/null +++ b/apps/web/hooks/domains/session/use-task-walkthrough.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useCallback, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAppStore, useOptionalAppStore } from "@/components/state-provider"; +import { taskWalkthroughQueryOptions } from "@/lib/query/query-options/session"; +import type { TaskWalkthrough } from "@/lib/types/http"; + +export function useTaskWalkthrough(taskId: string | null | undefined, enabled = true) { + return useQuery(taskWalkthroughQueryOptions(taskId, enabled)); +} + +export function useActiveTaskWalkthrough() { + const taskId = useOptionalAppStore((s) => s.tasks.activeTaskId, null); + return { + taskId, + ...useTaskWalkthrough(taskId), + }; +} + +export function useWalkthroughStepState(taskId: string | null | undefined, stepCount: number) { + const activeStep = useOptionalAppStore( + (s) => (taskId ? (s.walkthroughs.activeStepByTaskId[taskId] ?? 0) : 0), + 0, + ); + const setActiveStepState = useAppStore((s) => s.setWalkthroughActiveStep); + const setActiveStep = useCallback( + (nextStep: number) => { + if (!taskId) return; + setActiveStepState(taskId, nextStep, stepCount); + }, + [setActiveStepState, stepCount, taskId], + ); + + useEffect(() => { + if (!taskId || stepCount === 0) return; + if (activeStep < stepCount) return; + setActiveStepState(taskId, stepCount - 1, stepCount); + }, [activeStep, setActiveStepState, stepCount, taskId]); + + return { activeStep, setActiveStep }; +} + +export function useWalkthroughSeen( + taskId: string | null | undefined, + walkthrough: TaskWalkthrough | null | undefined, +) { + const lastSeenUpdatedAt = useOptionalAppStore( + (s) => (taskId ? s.walkthroughs.lastSeenUpdatedAtByTaskId[taskId] : undefined), + undefined, + ); + const hydrateLastSeen = useAppStore((s) => s.hydrateWalkthroughLastSeen); + const markSeenState = useAppStore((s) => s.markWalkthroughSeen); + + useEffect(() => { + if (taskId) hydrateLastSeen(taskId); + }, [hydrateLastSeen, taskId]); + + const markSeen = useCallback(() => { + if (!taskId) return; + markSeenState(taskId, walkthrough?.updated_at ?? ""); + }, [markSeenState, taskId, walkthrough?.updated_at]); + + return { + lastSeenUpdatedAt, + hasUnseen: Boolean(walkthrough && walkthrough.updated_at !== lastSeenUpdatedAt), + markSeen, + }; +} diff --git a/apps/web/hooks/domains/session/use-user-shells.ts b/apps/web/hooks/domains/session/use-user-shells.ts index 73790ce211..b4098b3527 100644 --- a/apps/web/hooks/domains/session/use-user-shells.ts +++ b/apps/web/hooks/domains/session/use-user-shells.ts @@ -1,12 +1,8 @@ -import { useEffect, useRef } from "react"; -import { getWebSocketClient } from "@/lib/ws/connection"; +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import type { - UserShellInfo, - UserShellKind, - UserShellState, - UserShellPTYStatus, -} from "@/lib/state/slices"; +import { userShellsQueryOptions } from "@/lib/query/query-options"; +import type { UserShellInfo } from "@/lib/state/slices"; interface UseUserShellsReturn { shells: UserShellInfo[]; @@ -18,27 +14,6 @@ interface UseUserShellsReturn { const EMPTY_SHELLS: UserShellInfo[] = []; -/** - * Wire shape of an item in the `user_shell.list` response. Backend returns - * a discriminated union — `kind: "ordinary"` carries DB metadata, `fixed` - * and `script` only carry the id + pty_status + label. - */ -type ListResponseItem = { - id?: string; - terminal_id?: string; // legacy / passthrough - kind?: UserShellKind; - seq?: number; - display_name?: string; - custom_name?: string | null; - state?: UserShellState; - pty_status?: UserShellPTYStatus; - label?: string; - closable?: boolean; - initial_command?: string; - process_id?: string; - running?: boolean; -}; - /** * Hook to fetch and manage user shell terminals for a task environment. * @@ -58,85 +33,32 @@ export function useUserShells( taskId?: string | null, ): UseUserShellsReturn { const store = useAppStoreApi(); + const connectionStatus = useAppStore((state) => state.connection.status); + const shellsQuery = useQuery({ + ...userShellsQueryOptions(environmentId ?? "", taskId), + enabled: Boolean(environmentId && connectionStatus === "connected"), + }); - const shells = useAppStore((state) => { + const storeShells = useAppStore((state) => { if (!environmentId) return EMPTY_SHELLS; return state.userShells.byEnvironmentId[environmentId] ?? EMPTY_SHELLS; }); - const isLoading = useAppStore((state) => { + const storeLoading = useAppStore((state) => { if (!environmentId) return false; return state.userShells.loading[environmentId] ?? false; }); - const isLoaded = useAppStore((state) => { + const storeLoaded = useAppStore((state) => { if (!environmentId) return false; return state.userShells.loaded[environmentId] ?? false; }); - const connectionStatus = useAppStore((state) => state.connection.status); + const shells = storeLoaded ? storeShells : (shellsQuery.data ?? storeShells); + const isLoading = shellsQuery.isFetching || storeLoading; + const isLoaded = storeLoaded || Boolean(shellsQuery.data); - // Cache the fetch scope as env+task. An earlier load without a task - // id (e.g. before the active task hydrated) must not block the - // task-scoped refetch — that would pin the store on the legacy - // shells while the DB-backed ordinary rows never reach the panel. - const fetchScope = environmentId ? `${environmentId}:${taskId ?? ""}` : null; - const lastFetchedScopeRef = useRef(null); - - // Reset ref when scope clears (no env, or env+task changed) useEffect(() => { - if (!fetchScope) { - lastFetchedScopeRef.current = null; - } - }, [fetchScope]); - - // Fetch user shells from backend - useEffect(() => { - if (!environmentId || !fetchScope) return; - if (connectionStatus !== "connected") return; - // Skip when this exact scope has already been fetched. A different - // env+task pair re-runs the effect because the ref no longer matches. - if (lastFetchedScopeRef.current === fetchScope) return; - // `isLoaded` stays in deps so external store invalidation can - // re-run this effect; fetchScope + lastFetchedScopeRef still guard - // duplicate requests for the same env+task. - - const fetchShells = async () => { - const client = getWebSocketClient(); - if (!client) return; - - store.getState().setUserShellsLoading(environmentId, true); - - try { - // include_parked=true is required for the "Parked terminals" - // submenu to populate after a page reload — without it the - // backend returns only state=open rows and parked PTYs become - // invisible until the user parks something in the same session. - // `buildTerminalsFromShells` already filters parked entries out - // of the main strip so this doesn't change visible-tab behaviour. - const payload: Record = { - task_environment_id: environmentId, - include_parked: true, - }; - if (taskId) payload.task_id = taskId; - const response = await client.request<{ shells?: ListResponseItem[] }>( - "user_shell.list", - payload, - 10000, - ); - - const mapped: UserShellInfo[] = (response.shells ?? []).map((s) => mapListItemToShell(s)); - store.getState().setUserShells(environmentId, mapped); - lastFetchedScopeRef.current = fetchScope; - } catch (error) { - console.error("Failed to fetch user shells:", error); - store.getState().setUserShells(environmentId, []); - lastFetchedScopeRef.current = fetchScope; - } - }; - - fetchShells(); - // `taskId` is captured inside `payload` via the fetchShells closure; - // including it in the deps array is the React-hooks-lint preference - // even though `fetchScope` already encodes the same information. - }, [environmentId, fetchScope, taskId, connectionStatus, isLoaded, store]); + if (!environmentId || !shellsQuery.data) return; + store.getState().setUserShells(environmentId, shellsQuery.data); + }, [environmentId, shellsQuery.data, store]); const addShell = (shell: UserShellInfo) => { if (environmentId) { @@ -158,29 +80,3 @@ export function useUserShells( removeShell, }; } - -/** - * Maps the wire shape onto the in-store `UserShellInfo`. The new handler - * returns `id` (typed `kind: "ordinary" | "fixed" | "script"`), the - * legacy passthrough returns `terminal_id`. Both populate the legacy - * `processId/running/label/closable` fields when present so older - * consumers (e.g. the `useTerminals` build path for non-ordinary tabs) - * keep rendering correctly. - */ -function mapListItemToShell(s: ListResponseItem): UserShellInfo { - const id = (s.id ?? s.terminal_id ?? "") as string; - return { - terminalId: id, - kind: s.kind, - seq: s.seq, - customName: s.custom_name ?? null, - displayName: s.display_name, - state: s.state, - ptyStatus: s.pty_status, - processId: s.process_id, - running: s.running ?? s.pty_status === "running", - label: s.label || s.display_name || "Terminal", - closable: s.closable ?? true, - initialCommand: s.initial_command, - }; -} diff --git a/apps/web/hooks/domains/session/use-visibility-backfill.test.ts b/apps/web/hooks/domains/session/use-visibility-backfill.test.ts index 193a14bf21..a67afbf2a5 100644 --- a/apps/web/hooks/domains/session/use-visibility-backfill.test.ts +++ b/apps/web/hooks/domains/session/use-visibility-backfill.test.ts @@ -1,11 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { cleanup, renderHook } from "@testing-library/react"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { makeQueryClient } from "@/lib/query/client"; -const mockRequest = vi.fn(); -const mockSetMessages = vi.fn(); +const mockListTaskSessionMessages = vi.fn(); +const mockMergeMessages = vi.fn(); -vi.mock("@/lib/ws/connection", () => ({ - getWebSocketClient: () => ({ request: mockRequest }), +vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(), + listSessionTurns: vi.fn(), + listTaskSessionMessages: (...args: unknown[]) => mockListTaskSessionMessages(...args), + listTaskSessions: vi.fn(), + searchSessionMessages: vi.fn(), })); vi.mock("@/components/state-provider", () => ({ @@ -13,7 +18,7 @@ vi.mock("@/components/state-provider", () => ({ useAppStoreApi: () => ({ getState: () => ({ messages: { bySession: {} }, - setMessages: mockSetMessages, + mergeMessages: mockMergeMessages, }), }), })); @@ -30,9 +35,9 @@ describe("useVisibilityBackfill", () => { beforeEach(() => { vi.clearAllMocks(); - mockRequest.mockResolvedValue({ messages: [], has_more: false }); + mockListTaskSessionMessages.mockResolvedValue({ messages: [], has_more: false }); store = { - getState: () => ({ messages: { bySession: {} }, setMessages: mockSetMessages }), + getState: () => ({ messages: { bySession: {} }, mergeMessages: mockMergeMessages }), }; }); @@ -40,54 +45,62 @@ describe("useVisibilityBackfill", () => { cleanup(); }); - it("fetches when the tab becomes visible", () => { - renderHook(() => useVisibilityBackfill("sess-1", store as never)); + it("fetches when the tab becomes visible", async () => { + const queryClient = makeQueryClient(); + renderHook(() => useVisibilityBackfill("sess-1", store as never, queryClient)); setVisibility("visible"); - expect(mockRequest).toHaveBeenCalledTimes(1); - expect(mockRequest).toHaveBeenCalledWith( - "message.list", - expect.objectContaining({ session_id: "sess-1" }), - expect.any(Number), + await waitFor(() => expect(mockListTaskSessionMessages).toHaveBeenCalledTimes(1)); + expect(mockListTaskSessionMessages).toHaveBeenCalledWith( + "sess-1", + { limit: 100, sort: "desc" }, + expect.any(Object), ); }); it("does not fetch when the tab becomes hidden", () => { - renderHook(() => useVisibilityBackfill("sess-1", store as never)); + renderHook(() => useVisibilityBackfill("sess-1", store as never, makeQueryClient())); setVisibility("hidden"); - expect(mockRequest).not.toHaveBeenCalled(); + expect(mockListTaskSessionMessages).not.toHaveBeenCalled(); }); it("does nothing when sessionId is null", () => { - renderHook(() => useVisibilityBackfill(null, store as never)); + renderHook(() => useVisibilityBackfill(null, store as never, makeQueryClient())); setVisibility("visible"); - expect(mockRequest).not.toHaveBeenCalled(); + expect(mockListTaskSessionMessages).not.toHaveBeenCalled(); }); it("removes the listener on unmount", () => { - const { unmount } = renderHook(() => useVisibilityBackfill("sess-1", store as never)); + const { unmount } = renderHook(() => + useVisibilityBackfill("sess-1", store as never, makeQueryClient()), + ); unmount(); setVisibility("visible"); - expect(mockRequest).not.toHaveBeenCalled(); + expect(mockListTaskSessionMessages).not.toHaveBeenCalled(); }); - it("re-registers when sessionId changes", () => { + it("re-registers when sessionId changes", async () => { + const queryClient = makeQueryClient(); const { rerender } = renderHook( - ({ id }: { id: string | null }) => useVisibilityBackfill(id, store as never), + ({ id }: { id: string | null }) => useVisibilityBackfill(id, store as never, queryClient), { initialProps: { id: "sess-1" } }, ); setVisibility("visible"); - expect(mockRequest).toHaveBeenLastCalledWith( - "message.list", - expect.objectContaining({ session_id: "sess-1" }), - expect.any(Number), + await waitFor(() => + expect(mockListTaskSessionMessages).toHaveBeenLastCalledWith( + "sess-1", + { limit: 100, sort: "desc" }, + expect.any(Object), + ), ); rerender({ id: "sess-2" }); setVisibility("visible"); - expect(mockRequest).toHaveBeenLastCalledWith( - "message.list", - expect.objectContaining({ session_id: "sess-2" }), - expect.any(Number), + await waitFor(() => + expect(mockListTaskSessionMessages).toHaveBeenLastCalledWith( + "sess-2", + { limit: 100, sort: "desc" }, + expect.any(Object), + ), ); }); }); diff --git a/apps/web/hooks/domains/settings/use-agent-discovery.ts b/apps/web/hooks/domains/settings/use-agent-discovery.ts index aec552c867..c01e530278 100644 --- a/apps/web/hooks/domains/settings/use-agent-discovery.ts +++ b/apps/web/hooks/domains/settings/use-agent-discovery.ts @@ -1,43 +1,12 @@ -import { useEffect, useRef } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { listAgentDiscovery } from "@/lib/api"; - -const DISCOVERY_TIMEOUT_MS = 20_000; +import { useQuery } from "@tanstack/react-query"; +import { agentDiscoveryQueryOptions } from "@/lib/query/query-options/settings"; export function useAgentDiscovery(enabled = true) { - const agentDiscovery = useAppStore((state) => state.agentDiscovery); - const setAgentDiscovery = useAppStore((state) => state.setAgentDiscovery); - const setAgentDiscoveryLoading = useAppStore((state) => state.setAgentDiscoveryLoading); - const fetchingRef = useRef(false); - - useEffect(() => { - if (!enabled || agentDiscovery.loaded || fetchingRef.current) return; - fetchingRef.current = true; - setAgentDiscoveryLoading(true); - - let active = true; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS); - - listAgentDiscovery({ cache: "no-store", init: { signal: controller.signal } }) - .then((response) => { - if (active) setAgentDiscovery(response.agents); - }) - .catch(() => { - if (active) setAgentDiscovery([]); - }) - .finally(() => { - fetchingRef.current = false; - clearTimeout(timeoutId); - }); - - return () => { - active = false; - fetchingRef.current = false; - clearTimeout(timeoutId); - controller.abort(); - }; - }, [enabled, agentDiscovery.loaded, setAgentDiscovery, setAgentDiscoveryLoading]); + const query = useQuery({ ...agentDiscoveryQueryOptions(), enabled }); - return agentDiscovery; + return { + items: query.data?.agents ?? [], + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + }; } diff --git a/apps/web/hooks/domains/settings/use-agents-query-sync.test.ts b/apps/web/hooks/domains/settings/use-agents-query-sync.test.ts new file mode 100644 index 0000000000..4ca6a1826a --- /dev/null +++ b/apps/web/hooks/domains/settings/use-agents-query-sync.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import type { Agent, AgentProfile } from "@/lib/types/http"; +import { upsertProfileInAgents } from "./use-agents-query-sync"; + +const profileA = { + id: "profile-a", + agentId: "agent-a", + agentDisplayName: "Codex", + name: "Default", +} as AgentProfile; +const agentA = { + id: "agent-a", + name: "codex", + profiles: [profileA], +} as Agent; + +describe("agent query sync helpers", () => { + it("upserts a profile under its owning agent", () => { + const profileB = { ...profileA, id: "profile-b", name: "Review" } as AgentProfile; + + expect(upsertProfileInAgents([agentA], profileB)).toEqual([ + expect.objectContaining({ profiles: [profileA, profileB] }), + ]); + expect( + upsertProfileInAgents([agentA], { ...profileA, name: "Renamed" } as AgentProfile), + ).toEqual([ + expect.objectContaining({ + profiles: [expect.objectContaining({ id: "profile-a", name: "Renamed" })], + }), + ]); + }); + + it("leaves the list unchanged when the owner is not in cache", () => { + const profile = { ...profileA, agentId: "missing-agent" } as AgentProfile; + const agents = [agentA]; + const result = upsertProfileInAgents(agents, profile); + expect(result).toBe(agents); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-agents-query-sync.ts b/apps/web/hooks/domains/settings/use-agents-query-sync.ts new file mode 100644 index 0000000000..bea660a050 --- /dev/null +++ b/apps/web/hooks/domains/settings/use-agents-query-sync.ts @@ -0,0 +1,55 @@ +import { useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import type { Agent, AgentProfile } from "@/lib/types/http"; +import { useSettingsData } from "./use-settings-data"; + +type AgentsQueryData = { agents: Agent[]; total?: number }; + +export function upsertProfileInAgents(agents: Agent[], profile: AgentProfile): Agent[] { + const agentId = profile.agentId; + if (!agentId) return agents; + let foundAgent = false; + const nextAgents = agents.map((agent) => { + if (agent.id !== agentId) return agent; + foundAgent = true; + const foundProfile = agent.profiles.some((item) => item.id === profile.id); + return { + ...agent, + profiles: foundProfile + ? agent.profiles.map((item) => (item.id === profile.id ? profile : item)) + : [...agent.profiles, profile], + }; + }); + return foundAgent ? nextAgents : agents; +} + +export function useAgentsQuerySync() { + const queryClient = useQueryClient(); + const { settingsAgents } = useSettingsData(true); + + const setAgents = useCallback( + (agents: Agent[]) => { + queryClient.setQueryData(qk.settings.agents(), (previous) => ({ + ...(previous ?? {}), + agents, + total: agents.length, + })); + }, + [queryClient], + ); + + const upsertProfile = useCallback( + (profile: AgentProfile) => { + const nextAgents = upsertProfileInAgents(settingsAgents, profile); + if (nextAgents === settingsAgents) { + queryClient.invalidateQueries({ queryKey: qk.settings.agents() }); + return; + } + setAgents(nextAgents); + }, + [queryClient, setAgents, settingsAgents], + ); + + return { settingsAgents, setAgents, upsertProfile }; +} diff --git a/apps/web/hooks/domains/settings/use-automation-runs.test.ts b/apps/web/hooks/domains/settings/use-automation-runs.test.ts index 9c33a6e2dd..cdb468b909 100644 --- a/apps/web/hooks/domains/settings/use-automation-runs.test.ts +++ b/apps/web/hooks/domains/settings/use-automation-runs.test.ts @@ -1,58 +1,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; +import { qk } from "@/lib/query/keys"; import type { AutomationRun } from "@/lib/types/automation"; -type MockState = { - automationRuns: { - byAutomationId: Record; - loading: Record; - }; -}; - -let mockState: MockState = { automationRuns: { byAutomationId: {}, loading: {} } }; +let queryClient: QueryClient; function setRuns(automationId: string, runs: AutomationRun[]) { - mockState = { - automationRuns: { - ...mockState.automationRuns, - byAutomationId: { ...mockState.automationRuns.byAutomationId, [automationId]: runs }, - }, - }; + queryClient.setQueryData(qk.automations.runs(automationId), runs); } -const storeActions = { - setAutomationRuns: (automationId: string, runs: AutomationRun[]) => setRuns(automationId, runs), - setAutomationRunsLoading: (automationId: string, loading: boolean) => { - mockState = { - automationRuns: { - ...mockState.automationRuns, - loading: { ...mockState.automationRuns.loading, [automationId]: loading }, - }, - }; - }, - removeAutomationRun: (automationId: string, runId: string) => { - const runs = mockState.automationRuns.byAutomationId[automationId] ?? []; - setRuns( - automationId, - runs.filter((r) => r.id !== runId), - ); - }, - clearAutomationRuns: (automationId: string) => setRuns(automationId, []), - restoreAutomationRun: (automationId: string, run: AutomationRun) => { - const runs = mockState.automationRuns.byAutomationId[automationId] ?? []; - if (runs.some((r) => r.id === run.id)) return; - setRuns(automationId, [...runs, run]); - }, -}; - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (s: MockState & typeof storeActions) => unknown) => - selector({ ...mockState, ...storeActions }), - useAppStoreApi: () => ({ - getState: () => ({ ...mockState, ...storeActions }), - }), -})); - vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("@/lib/api/domains/automation-api", () => ({ @@ -72,6 +30,14 @@ import { useAutomationRuns } from "./use-automation-runs"; const AUTOMATION_ID = "auto-1"; const WORKSPACE_ID = "ws-1"; +function wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); +} + +function renderAutomationRunsHook() { + return renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID), { wrapper }); +} + function mkRun(id: string): AutomationRun { return { id, @@ -95,7 +61,7 @@ function deferred() { } beforeEach(() => { - mockState = { automationRuns: { byAutomationId: {}, loading: {} } }; + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); vi.mocked(listAutomationRuns).mockReset(); vi.mocked(deleteAutomationRun).mockReset(); vi.mocked(deleteAllAutomationRuns).mockReset(); @@ -120,7 +86,7 @@ describe("useAutomationRuns", () => { .mockReturnValueOnce(Promise.withResolvers().promise) .mockResolvedValue([runX, runY]); - const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + const { result, rerender } = renderAutomationRunsHook(); act(() => { result.current.deleteRun("run-x"); @@ -158,7 +124,7 @@ describe("useAutomationRuns", () => { .mockReturnValueOnce(Promise.withResolvers().promise) .mockResolvedValue([runX, runY]); - const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + const { result, rerender } = renderAutomationRunsHook(); act(() => { result.current.deleteAllRuns(); @@ -186,7 +152,7 @@ describe("useAutomationRuns", () => { vi.mocked(deleteAutomationRun).mockResolvedValue({ deleted: true }); vi.mocked(deleteAllAutomationRuns).mockResolvedValue({ deleted: true }); - const { result } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + const { result } = renderAutomationRunsHook(); act(() => { result.current.deleteRun("run-x"); @@ -213,7 +179,7 @@ describe("useAutomationRuns - double-failure recovery", () => { .mockRejectedValueOnce(new Error("network down")); vi.mocked(deleteAutomationRun).mockRejectedValue(new Error("delete failed")); - const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + const { result, rerender } = renderAutomationRunsHook(); await act(async () => {}); rerender(); @@ -242,7 +208,7 @@ describe("useAutomationRuns - double-failure recovery", () => { .mockRejectedValueOnce(new Error("network down")); vi.mocked(deleteAllAutomationRuns).mockRejectedValue(new Error("delete-all failed")); - const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + const { result, rerender } = renderAutomationRunsHook(); await act(async () => {}); rerender(); @@ -272,7 +238,7 @@ describe("useAutomationRuns - single-failure revert", () => { vi.mocked(listAutomationRuns).mockResolvedValue([runX, runY]); vi.mocked(deleteAutomationRun).mockRejectedValue(new Error("delete failed")); - const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + const { result, rerender } = renderAutomationRunsHook(); await act(async () => {}); rerender(); @@ -298,7 +264,7 @@ describe("useAutomationRuns - single-failure revert", () => { vi.mocked(listAutomationRuns).mockResolvedValue([runX, runY]); vi.mocked(deleteAllAutomationRuns).mockRejectedValue(new Error("delete-all failed")); - const { result, rerender } = renderHook(() => useAutomationRuns(AUTOMATION_ID, WORKSPACE_ID)); + const { result, rerender } = renderAutomationRunsHook(); await act(async () => {}); rerender(); diff --git a/apps/web/hooks/domains/settings/use-automation-runs.ts b/apps/web/hooks/domains/settings/use-automation-runs.ts index 99222a4a15..0d3923c705 100644 --- a/apps/web/hooks/domains/settings/use-automation-runs.ts +++ b/apps/web/hooks/domains/settings/use-automation-runs.ts @@ -1,127 +1,87 @@ "use client"; -import { useEffect, useCallback } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { listAutomationRuns, deleteAutomationRun, deleteAllAutomationRuns, } from "@/lib/api/domains/automation-api"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { automationRunsQueryOptions } from "@/lib/query/query-options/automations"; import type { AutomationRun } from "@/lib/types/automation"; const EMPTY_RUNS: AutomationRun[] = []; export function useAutomationRuns(automationId: string | null, workspaceId: string) { - const runs = useAppStore((state) => - automationId ? (state.automationRuns.byAutomationId[automationId] ?? EMPTY_RUNS) : EMPTY_RUNS, - ); - const loading = useAppStore((state) => - automationId ? (state.automationRuns.loading[automationId] ?? false) : false, - ); - const setRuns = useAppStore((state) => state.setAutomationRuns); - const setRunsLoading = useAppStore((state) => state.setAutomationRunsLoading); - const removeRun = useAppStore((state) => state.removeAutomationRun); - const clearRuns = useAppStore((state) => state.clearAutomationRuns); - const restoreRun = useAppStore((state) => state.restoreAutomationRun); - const storeApi = useAppStoreApi(); - - useEffect(() => { - if (!automationId || loading) return; - setRunsLoading(automationId, true); - listAutomationRuns(automationId) - .then((result) => { - setRuns(automationId, result ?? []); - }) - .catch(() => { - setRuns(automationId, []); - }) - .finally(() => { - setRunsLoading(automationId, false); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [automationId]); + const queryClient = useQueryClient(); + const queryKey = qk.automations.runs(automationId); + const query = useQuery({ + ...automationRunsQueryOptions(automationId ?? ""), + enabled: Boolean(automationId), + }); + const runs = query.data ?? EMPTY_RUNS; + const refetch = query.refetch; const refresh = useCallback(() => { - if (!automationId) return; - setRunsLoading(automationId, true); - listAutomationRuns(automationId) - .then((result) => { - setRuns(automationId, result ?? []); - }) - .catch(() => {}) - .finally(() => { - setRunsLoading(automationId, false); - }); - }, [automationId, setRuns, setRunsLoading]); + if (!automationId) return Promise.resolve(); + return refetch(); + }, [automationId, refetch]); const deleteRun = useCallback( (runId: string) => { if (!automationId) return; - // Snapshot the run being removed so we can restore it precisely (not - // the whole list, which could clobber unrelated concurrent changes) - // if both the delete and the recovery refresh below fail. - const deletedRun = storeApi - .getState() - .automationRuns.byAutomationId[automationId]?.find((r) => r.id === runId); - removeRun(automationId, runId); // optimistic + const previousRuns = queryClient.getQueryData(queryKey) ?? runs; + const deletedRun = previousRuns.find((run) => run.id === runId); + queryClient.setQueryData(queryKey, (current = []) => + current.filter((run) => run.id !== runId), + ); deleteAutomationRun(runId, workspaceId) .then(() => { - // Re-apply the removal: an in-flight refresh() / initial load can - // resolve between the optimistic removeRun above and this success - // callback and overwrite the store with the pre-delete list, - // resurrecting the row. Removing it again here is a no-op unless - // that happened. - removeRun(automationId, runId); + queryClient.setQueryData(queryKey, (current = []) => + current.filter((run) => run.id !== runId), + ); }) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : "Failed to delete run"; toast.error(msg); - // revert on failure listAutomationRuns(automationId) - .then((result) => setRuns(automationId, result ?? [])) + .then((result) => queryClient.setQueryData(queryKey, result ?? [])) .catch(() => { - // The recovery refresh also failed — the store would - // otherwise stay permanently missing this row even though - // the delete never succeeded server-side. Fall back to - // re-inserting just the run we know we removed. if (deletedRun) { - restoreRun(automationId, deletedRun); + queryClient.setQueryData(queryKey, (current = []) => + current.some((run) => run.id === deletedRun.id) + ? current + : [...current, deletedRun], + ); } toast.error("Could not refresh runs — restored from local cache"); }); }); }, - [automationId, removeRun, restoreRun, setRuns, storeApi, workspaceId], + [automationId, queryClient, queryKey, runs, workspaceId], ); const deleteAllRuns = useCallback(() => { if (!automationId) return; - // Snapshot the full list so we can restore it if both the delete-all - // and the recovery refresh below fail. - const previousRuns = storeApi.getState().automationRuns.byAutomationId[automationId] ?? []; - clearRuns(automationId); // optimistic + const previousRuns = queryClient.getQueryData(queryKey) ?? runs; + queryClient.setQueryData(queryKey, []); deleteAllAutomationRuns(automationId, workspaceId) .then(() => { - // See deleteRun: guard against an in-flight refresh() resurrecting - // rows between the optimistic clear and this success callback. - clearRuns(automationId); + queryClient.setQueryData(queryKey, []); }) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : "Failed to delete runs"; toast.error(msg); - // revert on failure listAutomationRuns(automationId) - .then((result) => setRuns(automationId, result ?? [])) + .then((result) => queryClient.setQueryData(queryKey, result ?? [])) .catch(() => { - // The recovery refresh also failed — without this the store - // would stay permanently empty even though delete-all never - // succeeded server-side. Fall back to the pre-clear snapshot. - setRuns(automationId, previousRuns); + queryClient.setQueryData(queryKey, previousRuns); toast.error("Could not refresh runs — restored from local cache"); }); }); - }, [automationId, clearRuns, setRuns, storeApi, workspaceId]); + }, [automationId, queryClient, queryKey, runs, workspaceId]); - return { runs, loading, refresh, deleteRun, deleteAllRuns }; + return { runs, loading: query.isFetching && !query.isSuccess, refresh, deleteRun, deleteAllRuns }; } diff --git a/apps/web/hooks/domains/settings/use-automations.ts b/apps/web/hooks/domains/settings/use-automations.ts index ade7156a74..75dc34d5cc 100644 --- a/apps/web/hooks/domains/settings/use-automations.ts +++ b/apps/web/hooks/domains/settings/use-automations.ts @@ -1,8 +1,8 @@ "use client"; -import { useEffect, useCallback, useRef, useState } from "react"; +import { useCallback } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { - listAutomations, createAutomation, updateAutomation as apiUpdateAutomation, deleteAutomation, @@ -10,105 +10,79 @@ import { disableAutomation, triggerAutomation, } from "@/lib/api/domains/automation-api"; -import { useAppStore } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { automationsQueryOptions } from "@/lib/query/query-options/automations"; import type { + Automation, CreateAutomationRequest, CreateAutomationResponse, UpdateAutomationRequest, } from "@/lib/types/automation"; export function useAutomations(workspaceId: string | null) { - const items = useAppStore((state) => state.automations.items); - const loading = useAppStore((state) => state.automations.loading); - const setAutomations = useAppStore((state) => state.setAutomations); - const setLoading = useAppStore((state) => state.setAutomationsLoading); - const addToStore = useAppStore((state) => state.addAutomation); - const updateInStore = useAppStore((state) => state.updateAutomation); - const removeFromStore = useAppStore((state) => state.removeAutomation); - - // Track which workspace the current store contents belong to so a - // workspace switch refetches instead of serving stale data from the - // previous workspace. Also gate the response apply behind the in-flight - // workspace id to drop late responses that arrive after a quick switch. - // - // loadedWorkspaceRef is a ref (not state) so the effect guard on line - // below does not create a stale-closure problem. loadedWorkspaceId is - // the parallel state copy used only for the render-time `loaded` flag. - const loadedWorkspaceRef = useRef(null); - const inFlightWorkspaceRef = useRef(null); - const [loadedWorkspaceId, setLoadedWorkspaceId] = useState(null); - - useEffect(() => { - if (!workspaceId) return; - if (loadedWorkspaceRef.current === workspaceId) return; - inFlightWorkspaceRef.current = workspaceId; - setLoading(true); - listAutomations(workspaceId) - .then((result) => { - if (inFlightWorkspaceRef.current !== workspaceId) return; // stale - setAutomations(result ?? []); - loadedWorkspaceRef.current = workspaceId; - setLoadedWorkspaceId(workspaceId); - }) - .catch(() => { - if (inFlightWorkspaceRef.current !== workspaceId) return; - setAutomations([]); - loadedWorkspaceRef.current = workspaceId; - setLoadedWorkspaceId(workspaceId); - }) - .finally(() => { - if (inFlightWorkspaceRef.current === workspaceId) { - setLoading(false); - } - }); - }, [workspaceId, setAutomations, setLoading]); + const queryClient = useQueryClient(); + const query = useQuery({ + ...automationsQueryOptions(workspaceId ?? ""), + enabled: Boolean(workspaceId), + }); + const items = query.data ?? []; + const refetch = query.refetch; const create = useCallback( async (req: CreateAutomationRequest): Promise => { const automation = await createAutomation(req); - // Strip the one-time webhook_secret before persisting to the store so it - // doesn't leak into devtools or error-reporting SDKs. The full response - // (with secret) is still returned to the caller for the reveal dialog. + // Strip the one-time webhook_secret before caching so it doesn't leak + // into devtools or error-reporting SDKs. The full response (with secret) + // is still returned to the caller for the reveal dialog. const { webhook_secret: _secret, ...stored } = automation; - addToStore(stored); + queryClient.setQueryData( + qk.automations.list(req.workspace_id), + (prev: Automation[] | undefined) => [stored, ...(prev ?? [])], + ); return automation; }, - [addToStore], + [queryClient], ); const update = useCallback( async (id: string, req: UpdateAutomationRequest) => { const automation = await apiUpdateAutomation(id, req); - updateInStore(automation); + if (workspaceId) patchAutomationCache(queryClient, workspaceId, automation); return automation; }, - [updateInStore], + [queryClient, workspaceId], ); const remove = useCallback( async (id: string) => { await deleteAutomation(id); - removeFromStore(id); + if (workspaceId) { + queryClient.setQueryData( + qk.automations.list(workspaceId), + (prev: Automation[] | undefined) => + (prev ?? []).filter((automation) => automation.id !== id), + ); + } }, - [removeFromStore], + [queryClient, workspaceId], ); const enable = useCallback( async (id: string) => { const automation = await enableAutomation(id); - updateInStore(automation); + if (workspaceId) patchAutomationCache(queryClient, workspaceId, automation); return automation; }, - [updateInStore], + [queryClient, workspaceId], ); const disable = useCallback( async (id: string) => { const automation = await disableAutomation(id); - updateInStore(automation); + if (workspaceId) patchAutomationCache(queryClient, workspaceId, automation); return automation; }, - [updateInStore], + [queryClient, workspaceId], ); const trigger = useCallback(async (id: string) => { @@ -116,21 +90,34 @@ export function useAutomations(workspaceId: string | null) { }, []); const refresh = useCallback(() => { - if (!workspaceId) return; - inFlightWorkspaceRef.current = workspaceId; - setLoading(true); - listAutomations(workspaceId) - .then((result) => { - if (inFlightWorkspaceRef.current !== workspaceId) return; - setAutomations(result ?? []); - }) - .catch(() => {}) - .finally(() => { - if (inFlightWorkspaceRef.current === workspaceId) setLoading(false); - }); - }, [workspaceId, setAutomations, setLoading]); + void refetch(); + }, [refetch]); + + return { + items, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + create, + update, + remove, + enable, + disable, + trigger, + refresh, + }; +} - // loaded mirrors "are we on the workspace we've fetched at least once?" - const loaded = loadedWorkspaceId === workspaceId; - return { items, loaded, loading, create, update, remove, enable, disable, trigger, refresh }; +function patchAutomationCache( + queryClient: ReturnType, + workspaceId: string, + automation: Automation, +) { + queryClient.setQueryData(qk.automations.list(workspaceId), (prev: Automation[] | undefined) => { + const items = prev ?? []; + const index = items.findIndex((item) => item.id === automation.id); + if (index === -1) return [automation, ...items]; + const copy = [...items]; + copy[index] = automation; + return copy; + }); } diff --git a/apps/web/hooks/domains/settings/use-available-agents.ts b/apps/web/hooks/domains/settings/use-available-agents.ts index bfe911a000..c151bd8976 100644 --- a/apps/web/hooks/domains/settings/use-available-agents.ts +++ b/apps/web/hooks/domains/settings/use-available-agents.ts @@ -1,28 +1,13 @@ -import { useEffect } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { listAvailableAgents } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; +import { availableAgentsQueryOptions } from "@/lib/query/query-options/settings"; export function useAvailableAgents(enabled = true) { - const availableAgents = useAppStore((state) => state.availableAgents); - const setAvailableAgents = useAppStore((state) => state.setAvailableAgents); - const setAvailableAgentsLoading = useAppStore((state) => state.setAvailableAgentsLoading); + const query = useQuery({ ...availableAgentsQueryOptions(), enabled }); - useEffect(() => { - if (!enabled) return; - if (availableAgents.loaded || availableAgents.loading) return; - setAvailableAgentsLoading(true); - listAvailableAgents({ cache: "no-store" }) - .then((response) => { - setAvailableAgents(response.agents, response.tools ?? []); - }) - .catch(() => setAvailableAgents([])); - }, [ - availableAgents.loaded, - availableAgents.loading, - enabled, - setAvailableAgents, - setAvailableAgentsLoading, - ]); - - return availableAgents; + return { + items: query.data?.agents ?? [], + tools: query.data?.tools ?? [], + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + }; } diff --git a/apps/web/hooks/domains/settings/use-custom-prompts.ts b/apps/web/hooks/domains/settings/use-custom-prompts.ts index 8fffab1b28..bcc7408b53 100644 --- a/apps/web/hooks/domains/settings/use-custom-prompts.ts +++ b/apps/web/hooks/domains/settings/use-custom-prompts.ts @@ -1,33 +1,14 @@ "use client"; -import { useEffect } from "react"; -import { listPrompts } from "@/lib/api"; -import { useAppStore } from "@/components/state-provider"; -export function useCustomPrompts() { - const prompts = useAppStore((state) => state.prompts.items); - const loaded = useAppStore((state) => state.prompts.loaded); - const loading = useAppStore((state) => state.prompts.loading); - const setPrompts = useAppStore((state) => state.setPrompts); - const setPromptsLoading = useAppStore((state) => state.setPromptsLoading); +import { useQuery } from "@tanstack/react-query"; +import { promptsQueryOptions } from "@/lib/query/query-options/settings"; - useEffect(() => { - if (loaded || loading) return; - setPromptsLoading(true); - listPrompts({ cache: "no-store" }) - .then((response) => { - setPrompts(response.prompts ?? []); - }) - .catch(() => { - setPrompts([]); - }) - .finally(() => { - setPromptsLoading(false); - }); - }, [loaded, loading, setPrompts, setPromptsLoading]); +export function useCustomPrompts() { + const query = useQuery(promptsQueryOptions()); return { - prompts, - loaded, - loading, + prompts: query.data?.prompts ?? [], + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, }; } diff --git a/apps/web/hooks/domains/settings/use-dynamic-models.ts b/apps/web/hooks/domains/settings/use-dynamic-models.ts index 0df271d591..c2522cf31b 100644 --- a/apps/web/hooks/domains/settings/use-dynamic-models.ts +++ b/apps/web/hooks/domains/settings/use-dynamic-models.ts @@ -1,5 +1,6 @@ -import { useState, useEffect, useCallback } from "react"; -import { fetchDynamicModels } from "@/lib/api/domains/settings-api"; +import { useCallback, useMemo, useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { dynamicModelsQueryOptions } from "@/lib/query/query-options/settings"; import type { CommandEntry, ModeEntry, @@ -31,52 +32,58 @@ export function useAgentCapabilities( initial: ModelConfig, ): UseAgentCapabilitiesState { const supportsDynamicModels = initial.supports_dynamic_models; - const [models, setModels] = useState(initial.available_models); - const [modes, setModes] = useState(initial.available_modes ?? []); - const [commands, setCommands] = useState(initial.available_commands ?? []); - const [currentModelId, setCurrentModelId] = useState( - initial.current_model_id, - ); - const [currentModeId, setCurrentModeId] = useState(initial.current_mode_id); - const [isLoading, setIsLoading] = useState(supportsDynamicModels && !!agentName); - const [error, setError] = useState(null); + const [refreshError, setRefreshError] = useState(null); + const queryClient = useQueryClient(); + const query = useQuery({ + ...dynamicModelsQueryOptions(agentName ?? ""), + enabled: supportsDynamicModels && Boolean(agentName), + }); - const fetchCaps = useCallback( - async (forceRefresh: boolean) => { + const refresh = useCallback(async () => { + setRefreshError(null); + try { if (!agentName || !supportsDynamicModels) { return; } - setIsLoading(true); - setError(null); - try { - const response: DynamicModelsResponse = await fetchDynamicModels(agentName, { - refresh: forceRefresh, - }); - if (response.error) { - setError(response.error); - return; - } - setModels(response.models ?? []); - setModes(response.modes ?? []); - setCommands(response.commands ?? []); - setCurrentModelId(response.current_model_id); - setCurrentModeId(response.current_mode_id); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to fetch capabilities"); - } finally { - setIsLoading(false); + const response = await queryClient.fetchQuery({ + ...dynamicModelsQueryOptions(agentName, { refresh: true }), + staleTime: 0, + }); + if (response.error) { + setRefreshError(response.error); } - }, - [agentName, supportsDynamicModels], - ); - - useEffect(() => { - if (supportsDynamicModels && agentName) { - void fetchCaps(false); + } catch (err) { + setRefreshError(err instanceof Error ? err.message : "Failed to fetch capabilities"); } - }, [agentName, supportsDynamicModels, fetchCaps]); + }, [agentName, queryClient, supportsDynamicModels]); + + const capabilities = useMemo(() => { + if (query.data?.error) return initialResponse(initial); + return query.data ?? initialResponse(initial); + }, [initial, query.data]); - const refresh = useCallback(() => fetchCaps(true), [fetchCaps]); + const queryError = query.error instanceof Error ? query.error.message : null; + return { + models: capabilities.models ?? [], + modes: capabilities.modes ?? [], + commands: capabilities.commands ?? [], + currentModelId: capabilities.current_model_id, + currentModeId: capabilities.current_mode_id, + isLoading: query.isFetching, + error: refreshError ?? query.data?.error ?? queryError, + refresh, + }; +} - return { models, modes, commands, currentModelId, currentModeId, isLoading, error, refresh }; +function initialResponse(initial: ModelConfig): DynamicModelsResponse { + return { + agent_name: "", + status: initial.status ?? "ok", + models: initial.available_models, + modes: initial.available_modes ?? [], + commands: initial.available_commands ?? [], + current_model_id: initial.current_model_id, + current_mode_id: initial.current_mode_id, + error: initial.error ?? null, + }; } diff --git a/apps/web/hooks/domains/settings/use-editors.ts b/apps/web/hooks/domains/settings/use-editors.ts index 060041a8c3..123525566e 100644 --- a/apps/web/hooks/domains/settings/use-editors.ts +++ b/apps/web/hooks/domains/settings/use-editors.ts @@ -1,18 +1,20 @@ "use client"; import { useEffect } from "react"; -import { listEditors } from "@/lib/api"; +import { useQuery } from "@tanstack/react-query"; import { getWebSocketClient } from "@/lib/ws/connection"; import { useAppStore } from "@/components/state-provider"; -import { useEnsureUserSettings } from "@/hooks/use-ensure-user-settings"; +import { editorsQueryOptions, userSettingsQueryOptions } from "@/lib/query/query-options/settings"; +import { mapUserSettingsQueryData } from "./user-settings-query-data"; export function useEditors() { - const editors = useAppStore((state) => state.editors.items); - const loaded = useAppStore((state) => state.editors.loaded); - const loading = useAppStore((state) => state.editors.loading); - const setEditors = useAppStore((state) => state.setEditors); - const setEditorsLoading = useAppStore((state) => state.setEditorsLoading); - useEnsureUserSettings(); + const userSettingsLoaded = useAppStore((state) => state.userSettings.loaded); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const editorsQuery = useQuery(editorsQueryOptions()); + const userSettingsQuery = useQuery({ + ...userSettingsQueryOptions(), + enabled: !userSettingsLoaded, + }); useEffect(() => { const client = getWebSocketClient(); @@ -22,19 +24,15 @@ export function useEditors() { }, []); useEffect(() => { - if (loaded || loading) return; - setEditorsLoading(true); - listEditors({ cache: "no-store" }) - .then((response) => { - setEditors(response.editors ?? []); - }) - .catch(() => { - setEditors([]); - }) - .finally(() => { - setEditorsLoading(false); - }); - }, [loaded, loading, setEditors, setEditorsLoading]); + if (userSettingsLoaded) return; + const mapped = mapUserSettingsQueryData(userSettingsQuery.data); + if (!mapped) return; + setUserSettings(mapped); + }, [setUserSettings, userSettingsLoaded, userSettingsQuery.data]); - return { editors, loaded, loading }; + return { + editors: editorsQuery.data?.editors ?? [], + loaded: editorsQuery.isSuccess, + loading: editorsQuery.isFetching && !editorsQuery.isSuccess, + }; } diff --git a/apps/web/hooks/domains/settings/use-executors-query-sync.test.ts b/apps/web/hooks/domains/settings/use-executors-query-sync.test.ts new file mode 100644 index 0000000000..e8e728a009 --- /dev/null +++ b/apps/web/hooks/domains/settings/use-executors-query-sync.test.ts @@ -0,0 +1,60 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { describe, expect, it } from "vitest"; +import type { Executor, ExecutorProfile } from "@/lib/types/http"; +import { + removeExecutorFromList, + removeExecutorProfileFromList, + upsertExecutorInList, + upsertExecutorProfileInList, +} from "./use-executors-query-sync"; + +const profileA = { + id: "profile-a", + executor_id: "executor-a", + name: "Profile A", +} as ExecutorProfile; +const profileB = { + id: "profile-b", + executor_id: "executor-a", + name: "Profile B", +} as ExecutorProfile; +const executorA = { + id: "executor-a", + name: "Executor A", + type: "ssh", + status: "active", + is_system: false, + profiles: [profileA], + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", +} as Executor; + +describe("executor query sync helpers", () => { + it("upserts executors without duplicating existing rows", () => { + expect(upsertExecutorInList([executorA], { ...executorA, name: "Renamed" })).toEqual([ + expect.objectContaining({ id: "executor-a", name: "Renamed" }), + ]); + expect(upsertExecutorInList([], executorA)).toEqual([executorA]); + }); + + it("upserts and removes profiles inside the owning executor", () => { + const withProfile = upsertExecutorProfileInList([executorA], "executor-a", profileB); + expect(withProfile[0]?.profiles).toEqual([profileA, profileB]); + + const updated = upsertExecutorProfileInList(withProfile, "executor-a", { + ...profileB, + name: "Renamed", + }); + expect(updated[0]?.profiles?.find((profile) => profile.id === "profile-b")?.name).toBe( + "Renamed", + ); + + expect(removeExecutorProfileFromList(updated, "executor-a", "profile-b")[0]?.profiles).toEqual([ + profileA, + ]); + }); + + it("removes executors by id", () => { + expect(removeExecutorFromList([executorA], "executor-a")).toEqual([]); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-executors-query-sync.ts b/apps/web/hooks/domains/settings/use-executors-query-sync.ts new file mode 100644 index 0000000000..6def76ba53 --- /dev/null +++ b/apps/web/hooks/domains/settings/use-executors-query-sync.ts @@ -0,0 +1,117 @@ +import { useCallback } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import type { Executor, ExecutorProfile } from "@/lib/types/http"; +import { useSettingsData } from "./use-settings-data"; + +type ExecutorsQueryData = { executors: Executor[]; total?: number }; + +function writeExecutors(queryClient: QueryClient, nextExecutors: Executor[]) { + queryClient.setQueryData(qk.settings.executors(), (previous) => ({ + ...(previous ?? {}), + executors: nextExecutors, + total: previous?.total ?? nextExecutors.length, + })); + queryClient.invalidateQueries({ queryKey: qk.settings.allExecutorProfiles() }); +} + +export function upsertExecutorInList(executors: Executor[], next: Executor): Executor[] { + const found = executors.some((executor) => executor.id === next.id); + return found + ? executors.map((executor) => (executor.id === next.id ? { ...executor, ...next } : executor)) + : [...executors, next]; +} + +export function removeExecutorFromList(executors: Executor[], executorId: string): Executor[] { + return executors.filter((executor) => executor.id !== executorId); +} + +export function upsertExecutorProfileInList( + executors: Executor[], + executorId: string, + profile: ExecutorProfile, +): Executor[] { + return executors.map((executor) => + executor.id === executorId + ? { + ...executor, + profiles: upsertProfile(executor.profiles ?? [], profile), + } + : executor, + ); +} + +export function removeExecutorProfileFromList( + executors: Executor[], + executorId: string, + profileId: string, +): Executor[] { + return executors.map((executor) => + executor.id === executorId + ? { + ...executor, + profiles: (executor.profiles ?? []).filter((profile) => profile.id !== profileId), + } + : executor, + ); +} + +function upsertProfile(profiles: ExecutorProfile[], next: ExecutorProfile): ExecutorProfile[] { + const found = profiles.some((profile) => profile.id === next.id); + return found + ? profiles.map((profile) => (profile.id === next.id ? next : profile)) + : [...profiles, next]; +} + +export function useExecutorsQuerySync() { + const queryClient = useQueryClient(); + const { executors } = useSettingsData(true); + + const setExecutors = useCallback( + (nextExecutors: Executor[]) => { + writeExecutors(queryClient, nextExecutors); + }, + [queryClient], + ); + + const upsertExecutor = useCallback( + (executor: Executor) => { + queryClient.setQueryData(qk.settings.executor(executor.id), executor); + writeExecutors(queryClient, upsertExecutorInList(executors, executor)); + }, + [executors, queryClient], + ); + + const removeExecutor = useCallback( + (executorId: string) => { + writeExecutors(queryClient, removeExecutorFromList(executors, executorId)); + queryClient.removeQueries({ queryKey: qk.settings.executor(executorId) }); + }, + [executors, queryClient], + ); + + const upsertExecutorProfile = useCallback( + (executorId: string, profile: ExecutorProfile) => { + writeExecutors(queryClient, upsertExecutorProfileInList(executors, executorId, profile)); + queryClient.invalidateQueries({ queryKey: qk.settings.executorProfiles(executorId) }); + }, + [executors, queryClient], + ); + + const removeExecutorProfile = useCallback( + (executorId: string, profileId: string) => { + writeExecutors(queryClient, removeExecutorProfileFromList(executors, executorId, profileId)); + queryClient.invalidateQueries({ queryKey: qk.settings.executorProfiles(executorId) }); + }, + [executors, queryClient], + ); + + return { + executors, + removeExecutor, + removeExecutorProfile, + setExecutors, + upsertExecutor, + upsertExecutorProfile, + }; +} diff --git a/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts b/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts index 5bfa9ae83f..daaad7d041 100644 --- a/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts +++ b/apps/web/hooks/domains/settings/use-healthy-agent-profiles.ts @@ -1,6 +1,6 @@ "use client"; -import { useAppStore } from "@/components/state-provider"; +import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import type { AgentProfileOption } from "@/lib/state/slices"; /** @@ -9,7 +9,7 @@ import type { AgentProfileOption } from "@/lib/state/slices"; * user can see what's currently set instead of seeing a blank select. */ export function useHealthyAgentProfiles(selectedId?: string): AgentProfileOption[] { - const agentProfiles = useAppStore((s) => s.agentProfiles.items); + const { agentProfiles } = useSettingsData(true); return agentProfiles.filter( (p) => !p.capability_status || diff --git a/apps/web/hooks/domains/settings/use-notification-providers.ts b/apps/web/hooks/domains/settings/use-notification-providers.ts index 8426048504..7c2ac0c68a 100644 --- a/apps/web/hooks/domains/settings/use-notification-providers.ts +++ b/apps/web/hooks/domains/settings/use-notification-providers.ts @@ -1,52 +1,16 @@ "use client"; -import { useEffect } from "react"; -import { listNotificationProviders } from "@/lib/api"; -import { useAppStore } from "@/components/state-provider"; +import { useQuery } from "@tanstack/react-query"; +import { notificationProvidersQueryOptions } from "@/lib/query/query-options/settings"; export function useNotificationProviders() { - const providers = useAppStore((state) => state.notificationProviders.items); - const events = useAppStore((state) => state.notificationProviders.events); - const appriseAvailable = useAppStore((state) => state.notificationProviders.appriseAvailable); - const loaded = useAppStore((state) => state.notificationProviders.loaded); - const loading = useAppStore((state) => state.notificationProviders.loading); - const setNotificationProviders = useAppStore((state) => state.setNotificationProviders); - const setNotificationProvidersLoading = useAppStore( - (state) => state.setNotificationProvidersLoading, - ); - - useEffect(() => { - if (loaded || loading) return; - setNotificationProvidersLoading(true); - listNotificationProviders({ cache: "no-store" }) - .then((response) => { - setNotificationProviders({ - items: response.providers ?? [], - events: response.events ?? [], - appriseAvailable: response.apprise_available ?? false, - loaded: true, - loading: false, - }); - }) - .catch(() => { - setNotificationProviders({ - items: [], - events: [], - appriseAvailable: false, - loaded: true, - loading: false, - }); - }) - .finally(() => { - setNotificationProvidersLoading(false); - }); - }, [loaded, loading, setNotificationProviders, setNotificationProvidersLoading]); + const query = useQuery(notificationProvidersQueryOptions()); return { - providers, - events, - appriseAvailable, - loaded, - loading, + providers: query.data?.providers ?? [], + events: query.data?.events ?? [], + appriseAvailable: query.data?.apprise_available ?? false, + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, }; } diff --git a/apps/web/hooks/domains/settings/use-secrets.ts b/apps/web/hooks/domains/settings/use-secrets.ts index 8418e03a60..a7aa1c86f5 100644 --- a/apps/web/hooks/domains/settings/use-secrets.ts +++ b/apps/web/hooks/domains/settings/use-secrets.ts @@ -1,31 +1,14 @@ "use client"; -import { useEffect } from "react"; -import { listSecrets } from "@/lib/api/domains/secrets-api"; -import { useAppStore } from "@/components/state-provider"; +import { useQuery } from "@tanstack/react-query"; +import { secretsQueryOptions } from "@/lib/query/query-options/settings"; export function useSecrets() { - const items = useAppStore((state) => state.secrets.items); - const loaded = useAppStore((state) => state.secrets.loaded); - const loading = useAppStore((state) => state.secrets.loading); - const setSecrets = useAppStore((state) => state.setSecrets); - const setSecretsLoading = useAppStore((state) => state.setSecretsLoading); + const query = useQuery(secretsQueryOptions()); - useEffect(() => { - if (loading) return; - setSecretsLoading(true); - listSecrets({ cache: "no-store" }) - .then((response) => { - setSecrets(response ?? []); - }) - .catch(() => { - setSecrets([]); - }) - .finally(() => { - setSecretsLoading(false); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return { items, loaded, loading }; + return { + items: query.data ?? [], + loaded: query.isSuccess, + loading: query.isFetching && !query.isSuccess, + }; } diff --git a/apps/web/hooks/domains/settings/use-settings-data.ts b/apps/web/hooks/domains/settings/use-settings-data.ts index 0d0e4dcbd9..e7e0dd23eb 100644 --- a/apps/web/hooks/domains/settings/use-settings-data.ts +++ b/apps/web/hooks/domains/settings/use-settings-data.ts @@ -1,113 +1,49 @@ -import { useEffect, useRef } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { listAgents, listAvailableAgents, listExecutors } from "@/lib/api"; +import { useEffect, useMemo, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + agentsQueryOptions, + availableAgentsQueryOptions, + executorsQueryOptions, +} from "@/lib/query/query-options/settings"; import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; export function useSettingsData(enabled = true) { - const executors = useAppStore((state) => state.executors.items); - const settingsAgents = useAppStore((state) => state.settingsAgents.items); - const settingsData = useAppStore((state) => state.settingsData); - const availableAgents = useAppStore((state) => state.availableAgents); - const setExecutors = useAppStore((state) => state.setExecutors); - const setSettingsAgents = useAppStore((state) => state.setSettingsAgents); - const setAgentProfiles = useAppStore((state) => state.setAgentProfiles); - const setAvailableAgents = useAppStore((state) => state.setAvailableAgents); - const setAvailableAgentsLoading = useAppStore((state) => state.setAvailableAgentsLoading); - const setSettingsData = useAppStore((state) => state.setSettingsData); + const executorsQuery = useQuery({ ...executorsQueryOptions(), enabled }); + const agentsQuery = useQuery({ ...agentsQueryOptions(), enabled }); + const availableAgentsQuery = useQuery({ ...availableAgentsQueryOptions(), enabled }); + const refetchAgents = agentsQuery.refetch; + const settingsAgents = agentsQuery.data?.agents ?? []; + const agentProfiles = useMemo( + () => + settingsAgents.flatMap((agent) => + agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), + ), + [settingsAgents], + ); - useEffect(() => { - if (!enabled) return; - if (settingsData.executorsLoaded) return; - if (executors.length === 0) { - listExecutors({ cache: "no-store" }) - .then((response) => setExecutors(response.executors)) - .catch(() => setExecutors([])) - .finally(() => setSettingsData({ executorsLoaded: true })); - } else { - setSettingsData({ executorsLoaded: true }); - } - }, [enabled, executors.length, setExecutors, setSettingsData, settingsData.executorsLoaded]); - - useEffect(() => { - if (!enabled) return; - if (settingsData.agentsLoaded) return; - if (settingsAgents.length === 0) { - listAgents({ cache: "no-store" }) - .then((response) => { - setSettingsAgents(response.agents); - setAgentProfiles( - response.agents.flatMap((agent) => - agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), - ), - ); - }) - .catch(() => { - setSettingsAgents([]); - setAgentProfiles([]); - }) - .finally(() => setSettingsData({ agentsLoaded: true })); - } else { - setSettingsData({ agentsLoaded: true }); - } - }, [ - enabled, - setAgentProfiles, - setSettingsAgents, - setSettingsData, - settingsAgents.length, - settingsData.agentsLoaded, - ]); - - // Capabilities probe — host-utility probes ACP agents in the background and - // the backend reconciler renames profiles from "Claude" → "Claude Sonnet 4.6" - // once results land. The DB write happens *after* listAgents has already - // returned the seeded profile.name, so without re-fetching here, the create - // dialog renders stale labels (agent name with no model badge) for as long as - // the probe takes to finish. This effect kicks the probe off and gates - // listAgents-staleness on its completion. - useEffect(() => { - if (!enabled) return; - if (availableAgents.loaded || availableAgents.loading) return; - setAvailableAgentsLoading(true); - listAvailableAgents({ cache: "no-store" }) - .then((response) => setAvailableAgents(response.agents, response.tools ?? [])) - .catch(() => setAvailableAgents([])); - }, [ - enabled, - availableAgents.loaded, - availableAgents.loading, - setAvailableAgents, - setAvailableAgentsLoading, - ]); - - // Re-fetch agent profiles once the host-utility probe completes. Use a ref - // gate so we re-fetch exactly once per `availableAgents.loaded` transition, - // not on every render after. + // Host-utility probes ACP agents in the background and the backend reconciler + // can rename profiles once results land. Re-fetch agent profiles once the + // capability probe completes so settings pickers don't render stale labels. const reconciledRef = useRef(false); useEffect(() => { if (!enabled) return; - if (!availableAgents.loaded) return; - if (!settingsData.agentsLoaded) return; // Wait for the initial agents fetch first. + if (!availableAgentsQuery.isSuccess) return; + if (!agentsQuery.isSuccess) return; // Wait for the initial agents fetch first. if (reconciledRef.current) return; reconciledRef.current = true; - listAgents({ cache: "no-store" }) - .then((response) => { - setSettingsAgents(response.agents); - setAgentProfiles( - response.agents.flatMap((agent) => - agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), - ), - ); - }) - .catch(() => { - // Best-effort reconcile; keep prior (possibly stale) profiles rather - // than wiping the dialog state on a transient error. - }); - }, [ - enabled, - availableAgents.loaded, - settingsData.agentsLoaded, - setAgentProfiles, - setSettingsAgents, - ]); + void refetchAgents(); + }, [agentsQuery.isSuccess, availableAgentsQuery.isSuccess, enabled, refetchAgents]); + + return { + executors: executorsQuery.data?.executors ?? [], + settingsAgents, + agentProfiles, + availableAgents: availableAgentsQuery.data?.agents ?? [], + availableTools: availableAgentsQuery.data?.tools ?? [], + settingsData: { + agentsLoaded: agentsQuery.isSuccess || agentsQuery.isError, + capabilitiesLoaded: availableAgentsQuery.isSuccess || availableAgentsQuery.isError, + executorsLoaded: executorsQuery.isSuccess || executorsQuery.isError, + }, + }; } diff --git a/apps/web/hooks/domains/settings/use-settings-query-hooks.test.tsx b/apps/web/hooks/domains/settings/use-settings-query-hooks.test.tsx new file mode 100644 index 0000000000..089d33107f --- /dev/null +++ b/apps/web/hooks/domains/settings/use-settings-query-hooks.test.tsx @@ -0,0 +1,445 @@ +/* eslint-disable max-lines-per-function */ +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { STORAGE_KEYS } from "@/lib/settings/constants"; +import type { UserSettingsState } from "@/lib/state/slices/settings/types"; +import { useAgentDiscovery } from "./use-agent-discovery"; +import { useAvailableAgents } from "./use-available-agents"; +import { useCustomPrompts } from "./use-custom-prompts"; +import { useAgentCapabilities } from "./use-dynamic-models"; +import { useEditors } from "./use-editors"; +import { useNotificationProviders } from "./use-notification-providers"; +import { useSettingsData } from "./use-settings-data"; +import { useUserDisplaySettings } from "../../use-user-display-settings"; + +const WORKSPACE_ID = "workspace-1"; + +const apiMocks = vi.hoisted(() => { + const mocks = { + fetchDefaultScripts: vi.fn(), + fetchDynamicModels: vi.fn(), + fetchExecutor: vi.fn(), + fetchSystemMetricsSettings: vi.fn(), + fetchUserSettings: vi.fn(), + getAgentProfileMcpConfig: vi.fn(), + getInstallJob: vi.fn(), + listAgentDiscovery: vi.fn(), + listAgents: vi.fn(), + listAllExecutorProfiles: vi.fn(), + listAvailableAgents: vi.fn(), + listEditors: vi.fn(), + listExecutorProfiles: vi.fn(), + listExecutors: vi.fn(), + listInstallJobs: vi.fn(), + listNotificationProviders: vi.fn(), + listPrompts: vi.fn(), + listScriptPlaceholders: vi.fn(), + updateUserSettings: vi.fn(), + }; + return mocks; +}); + +type ItemsState = { items: unknown[] }; +type LoadingItemsState = ItemsState & { loaded: boolean; loading: boolean }; +type NotificationState = { + items: unknown[]; + events: string[]; + appriseAvailable: boolean; + loaded: boolean; + loading: boolean; +}; +type TestState = { + editors: LoadingItemsState; + prompts: LoadingItemsState; + notificationProviders: NotificationState; + userSettings: UserSettingsState; +} & Record; + +const createBaseState = vi.hoisted( + () => (): TestState => ({ + editors: { items: [], loaded: false, loading: false }, + prompts: { items: [], loaded: false, loading: false }, + notificationProviders: { + items: [], + events: [], + appriseAvailable: false, + loaded: false, + loading: false, + }, + settingsData: { executorsLoaded: false, agentsLoaded: false }, + userSettings: { + workspaceId: null, + workflowId: null, + kanbanViewMode: null, + repositoryIds: [], + tasksListSort: "updated_desc", + tasksListGroup: "state", + preferredShell: null, + shellOptions: [], + defaultEditorId: null, + enablePreviewOnClick: false, + chatSubmitKey: "cmd_enter", + reviewAutoMarkOnScroll: true, + showReleaseNotification: true, + releaseNotesLastSeenVersion: null, + savedLayouts: [], + sidebarViews: [], + sidebarActiveViewId: null, + sidebarDraft: null, + sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} }, + taskCreateLastUsed: { + repositoryId: null, + branch: null, + agentProfileId: null, + executorProfileId: null, + }, + jiraSavedViews: undefined, + jiraTaskPresets: undefined, + githubSavedPresets: undefined, + githubDefaultQueryPresets: undefined, + gitlabSavedPresets: undefined, + defaultUtilityAgentId: null, + keyboardShortcuts: {}, + terminalLinkBehavior: "new_tab", + terminalFontFamily: null, + terminalFontSize: null, + changesPanelLayout: "tree", + systemMetricsDisplay: { showInTopbar: false }, + lspAutoStartLanguages: [], + lspAutoInstallLanguages: [], + lspServerConfigs: {}, + voiceMode: { + enabled: true, + engine: "auto", + language: "auto", + mode: "toggle", + autoSend: false, + whisperWebModel: "base", + }, + loaded: false, + }, + }), +); + +const assignActions = vi.hoisted(() => (target: TestState): void => { + Object.assign(target, { + setEditors: (editors: unknown[]) => { + target.editors = { items: editors, loaded: true, loading: false }; + }, + setEditorsLoading: (loading: boolean) => { + target.editors = { ...target.editors, loading }; + }, + setPrompts: (prompts: unknown[]) => { + target.prompts = { items: prompts, loaded: true, loading: false }; + }, + setPromptsLoading: (loading: boolean) => { + target.prompts = { ...target.prompts, loading }; + }, + setUserSettings: (settings: UserSettingsState) => { + target.userSettings = settings; + }, + }); +}); + +const storeHarness = vi.hoisted(() => { + let state: TestState; + function reset() { + state = createBaseState(); + assignActions(state); + } + reset(); + return { + reset, + getState: () => state, + }; +}); + +vi.mock("@/lib/api/domains/settings-api", () => apiMocks); +vi.mock("@/lib/api", () => apiMocks); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: ReturnType) => unknown) => + selector(storeHarness.getState()), + useAppStoreApi: () => ({ getState: storeHarness.getState }), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => ({ request: vi.fn(() => Promise.resolve({})), subscribeUser: vi.fn() }), +})); + +vi.mock("@/hooks/domains/workspace/use-repositories", () => ({ + useRepositories: () => ({ repositories: [], isLoading: false }), +})); + +vi.mock("@/lib/routing/client-router", () => ({ + useSearchParams: () => new URLSearchParams(), +})); + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity }, + mutations: { retry: false }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +const editor = { id: "editor-1", name: "VS Code", kind: "vscode", enabled: true }; +const executor = { id: "executor-1", name: "Docker", type: "docker" }; +const agent = { + id: "agent-1", + name: "codex", + profiles: [{ id: "profile-1", agentDisplayName: "Codex", name: "Default" }], +}; +const availableAgent = { name: "codex", display_name: "Codex", installed: true }; +const tool = { name: "codex", installed: true }; +const prompt = { id: "prompt-1", name: "Review", content: "Review this" }; +const provider = { + id: "provider-1", + name: "Desktop", + type: "desktop", + enabled: true, + config: {}, + events: ["task.completed"], +}; +const modelConfig = { + default_model: "gpt-5", + supports_dynamic_models: true, + available_models: [], + available_modes: [], + available_commands: [], + current_model_id: undefined, + current_mode_id: undefined, +}; +const dynamicModelsResponse = { + agent_name: "codex", + status: "ok", + models: [{ id: "gpt-5", name: "GPT-5" }], + modes: [{ id: "code", name: "Code" }], + commands: [{ id: "review", name: "Review", description: "Review code" }], + current_model_id: "gpt-5", + current_mode_id: "code", + error: null, +}; +const userSettingsResponse = { + shell_options: [{ value: "/bin/zsh", label: "zsh" }], + settings: { + workspace_id: WORKSPACE_ID, + workflow_filter_id: "workflow-1", + repository_ids: ["repo-1"], + preferred_shell: "/bin/zsh", + enable_preview_on_click: true, + }, +}; + +beforeEach(() => { + storeHarness.reset(); + localStorage.clear(); + vi.clearAllMocks(); + apiMocks.listAvailableAgents.mockResolvedValue({ agents: [availableAgent], tools: [tool] }); + apiMocks.listAgentDiscovery.mockResolvedValue({ agents: [{ name: "codex", installed: true }] }); + apiMocks.listEditors.mockResolvedValue({ editors: [editor] }); + apiMocks.fetchUserSettings.mockResolvedValue(userSettingsResponse); + apiMocks.listExecutors.mockResolvedValue({ executors: [executor] }); + apiMocks.listAgents.mockResolvedValue({ agents: [agent] }); + apiMocks.listPrompts.mockResolvedValue({ prompts: [prompt] }); + apiMocks.listNotificationProviders.mockResolvedValue({ + providers: [provider], + events: ["task.completed"], + apprise_available: true, + }); + apiMocks.fetchDynamicModels.mockResolvedValue(dynamicModelsResponse); +}); + +describe("settings query hooks", () => { + it("loads available agents through TanStack Query", async () => { + const queryClient = createQueryClient(); + const { result } = renderHook(() => useAvailableAgents(), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.loaded).toBe(true)); + + expect(result.current.items).toEqual([availableAgent]); + expect(result.current.tools).toEqual([tool]); + expect(queryClient.getQueryData(qk.settings.availableAgents())).toEqual({ + agents: [availableAgent], + tools: [tool], + }); + }); + + it("hydrates editors and user settings from query data", async () => { + const queryClient = createQueryClient(); + const { result } = renderHook(() => useEditors(), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.loaded).toBe(true)); + await waitFor(() => expect(storeHarness.getState().userSettings.loaded).toBe(true)); + + expect(result.current.editors).toEqual([editor]); + expect(queryClient.getQueryData(qk.settings.editors())).toEqual({ editors: [editor] }); + expect(queryClient.getQueryData(qk.settings.user())).toEqual(userSettingsResponse); + expect(storeHarness.getState().userSettings.preferredShell).toBe("/bin/zsh"); + }); + + it("loads settings bootstrap data through query keys", async () => { + const queryClient = createQueryClient(); + const { result } = renderHook(() => useSettingsData(true), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.settingsData.executorsLoaded).toBe(true)); + await waitFor(() => expect(result.current.settingsData.agentsLoaded).toBe(true)); + + expect(result.current.executors).toEqual([executor]); + expect(result.current.settingsAgents).toEqual([agent]); + expect(result.current.agentProfiles).toEqual([ + { + id: "profile-1", + label: "Codex • Default", + agent_id: "agent-1", + agent_name: "codex", + cli_passthrough: false, + capability_status: undefined, + capability_error: undefined, + }, + ]); + expect(result.current.settingsData).toEqual({ + agentsLoaded: true, + capabilitiesLoaded: true, + executorsLoaded: true, + }); + expect(queryClient.getQueryData(qk.settings.executors())).toEqual({ executors: [executor] }); + expect(queryClient.getQueryData(qk.settings.agents())).toEqual({ agents: [agent] }); + expect(queryClient.getQueryData(qk.settings.availableAgents())).toEqual({ + agents: [availableAgent], + tools: [tool], + }); + }); + + it("loads prompts and notification providers through query keys", async () => { + const queryClient = createQueryClient(); + const { result: prompts } = renderHook(() => useCustomPrompts(), { + wrapper: wrapperFor(queryClient), + }); + const { result: notifications } = renderHook(() => useNotificationProviders(), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(prompts.current.loaded).toBe(true)); + await waitFor(() => expect(notifications.current.loaded).toBe(true)); + + expect(queryClient.getQueryData(qk.settings.prompts())).toEqual({ prompts: [prompt] }); + expect(queryClient.getQueryData(qk.settings.notificationProviders())).toEqual({ + providers: [provider], + events: ["task.completed"], + apprise_available: true, + }); + }); + + it("loads agent discovery and user display settings through query keys", async () => { + const queryClient = createQueryClient(); + const { result: discovery } = renderHook(() => useAgentDiscovery(), { + wrapper: wrapperFor(queryClient), + }); + const { result: display } = renderHook( + () => useUserDisplaySettings({ workspaceId: null, workflowId: null }), + { wrapper: wrapperFor(queryClient) }, + ); + + await waitFor(() => expect(discovery.current.loaded).toBe(true)); + await waitFor(() => expect(display.current.settings.loaded).toBe(true)); + + expect(queryClient.getQueryData(qk.settings.agentDiscovery())).toEqual({ + agents: [{ name: "codex", installed: true }], + }); + expect(queryClient.getQueryData(qk.settings.user())).toEqual(userSettingsResponse); + expect(display.current.settings.workspaceId).toBe(WORKSPACE_ID); + }); + + it("preserves cached task-create selections when committing the initial workspace", async () => { + localStorage.setItem(STORAGE_KEYS.LAST_REPOSITORY_ID, JSON.stringify("repo-1")); + localStorage.setItem(STORAGE_KEYS.LAST_BRANCH, JSON.stringify("main")); + localStorage.setItem(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, JSON.stringify("agent-profile-1")); + localStorage.setItem( + STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, + JSON.stringify("executor-profile-1"), + ); + storeHarness.getState().userSettings = { + ...storeHarness.getState().userSettings, + loaded: true, + workspaceId: null, + workflowId: "workflow-1", + }; + + const queryClient = createQueryClient(); + renderHook( + () => useUserDisplaySettings({ workspaceId: WORKSPACE_ID, workflowId: "workflow-1" }), + { + wrapper: wrapperFor(queryClient), + }, + ); + + await waitFor(() => + expect(storeHarness.getState().userSettings.workspaceId).toBe(WORKSPACE_ID), + ); + expect(storeHarness.getState().userSettings.taskCreateLastUsed).toEqual({ + repositoryId: "repo-1", + branch: "main", + agentProfileId: "agent-profile-1", + executorProfileId: "executor-profile-1", + }); + }); + + it("loads dynamic model capabilities through the agent model query key", async () => { + const queryClient = createQueryClient(); + const { result } = renderHook(() => useAgentCapabilities("codex", modelConfig), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.currentModelId).toBe("gpt-5")); + + expect(result.current.models).toEqual(dynamicModelsResponse.models); + expect(result.current.modes).toEqual(dynamicModelsResponse.modes); + expect(result.current.commands).toEqual(dynamicModelsResponse.commands); + expect(queryClient.getQueryData(qk.settings.dynamicModels("codex"))).toEqual( + dynamicModelsResponse, + ); + }); + + it("forces dynamic capability refreshes past the fresh cache window", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryDefaults(qk.settings.dynamicModels("codex"), { staleTime: 30_000 }); + const refreshedResponse = { + ...dynamicModelsResponse, + models: [{ id: "gpt-5.1", name: "GPT-5.1" }], + current_model_id: "gpt-5.1", + }; + const { result } = renderHook(() => useAgentCapabilities("codex", modelConfig), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.currentModelId).toBe("gpt-5")); + apiMocks.fetchDynamicModels.mockResolvedValueOnce(refreshedResponse); + await act(async () => { + await result.current.refresh(); + }); + + expect(apiMocks.fetchDynamicModels).toHaveBeenCalledTimes(2); + expect(apiMocks.fetchDynamicModels).toHaveBeenLastCalledWith( + "codex", + expect.objectContaining({ refresh: true }), + ); + await waitFor(() => expect(result.current.currentModelId).toBe("gpt-5.1")); + }); +}); diff --git a/apps/web/hooks/domains/settings/use-sprites.ts b/apps/web/hooks/domains/settings/use-sprites.ts index 92c39fbdd9..bd5e4ef75b 100644 --- a/apps/web/hooks/domains/settings/use-sprites.ts +++ b/apps/web/hooks/domains/settings/use-sprites.ts @@ -1,38 +1,21 @@ "use client"; -import { useEffect } from "react"; -import { getSpritesStatus, listSpritesInstances } from "@/lib/api/domains/sprites-api"; -import { useAppStore } from "@/components/state-provider"; +import { useQuery } from "@tanstack/react-query"; +import { + spritesInstancesQueryOptions, + spritesStatusQueryOptions, +} from "@/lib/query/query-options/settings"; export function useSprites(secretId?: string) { - const status = useAppStore((state) => state.sprites.status); - const instances = useAppStore((state) => state.sprites.instances); - const loaded = useAppStore((state) => state.sprites.loaded); - const loading = useAppStore((state) => state.sprites.loading); - const setSpritesStatus = useAppStore((state) => state.setSpritesStatus); - const setSpritesInstances = useAppStore((state) => state.setSpritesInstances); - const setSpritesLoading = useAppStore((state) => state.setSpritesLoading); + const statusQuery = useQuery(spritesStatusQueryOptions(secretId)); + const instancesQuery = useQuery(spritesInstancesQueryOptions(secretId)); - useEffect(() => { - if (!secretId || loaded || loading) return; - setSpritesLoading(true); - - Promise.all([ - getSpritesStatus(secretId, { cache: "no-store" }), - listSpritesInstances(secretId, { cache: "no-store" }), - ]) - .then(([statusRes, instancesRes]) => { - setSpritesStatus(statusRes); - setSpritesInstances(instancesRes ?? []); - }) - .catch(() => { - setSpritesStatus({ connected: false, token_configured: false, instance_count: 0 }); - setSpritesInstances([]); - }) - .finally(() => { - setSpritesLoading(false); - }); - }, [loaded, loading, secretId, setSpritesStatus, setSpritesInstances, setSpritesLoading]); - - return { status, instances, loaded, loading }; + return { + status: statusQuery.data ?? null, + instances: instancesQuery.data ?? [], + loaded: statusQuery.isSuccess && instancesQuery.isSuccess, + loading: + (statusQuery.isFetching && !statusQuery.isSuccess) || + (instancesQuery.isFetching && !instancesQuery.isSuccess), + }; } diff --git a/apps/web/hooks/domains/settings/use-system-health.ts b/apps/web/hooks/domains/settings/use-system-health.ts index 9373e9a353..d4820cc770 100644 --- a/apps/web/hooks/domains/settings/use-system-health.ts +++ b/apps/web/hooks/domains/settings/use-system-health.ts @@ -1,56 +1,17 @@ "use client"; -import { useCallback, useEffect } from "react"; -import { fetchSystemHealth } from "@/lib/api/domains/health-api"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; - -const HEALTH_POLL_INTERVAL_MS = 5 * 60 * 1000; +import { useQuery } from "@tanstack/react-query"; +import { systemHealthQueryOptions } from "@/lib/query/query-options/settings"; export function useSystemHealth() { - const issues = useAppStore((state) => state.systemHealth.issues); - const checks = useAppStore((state) => state.systemHealth.checks); - const healthy = useAppStore((state) => state.systemHealth.healthy); - const loaded = useAppStore((state) => state.systemHealth.loaded); - const loading = useAppStore((state) => state.systemHealth.loading); - const setSystemHealth = useAppStore((state) => state.setSystemHealth); - const setSystemHealthLoading = useAppStore((state) => state.setSystemHealthLoading); - const storeApi = useAppStoreApi(); - - const fetchHealth = useCallback(() => { - if (storeApi.getState().systemHealth.loading) return; - setSystemHealthLoading(true); - fetchSystemHealth({ cache: "no-store" }) - .then((response) => { - setSystemHealth(response ?? { healthy: true, issues: [], checks: [] }); - }) - .catch(() => { - setSystemHealth({ healthy: true, issues: [], checks: [] }); - }) - .finally(() => { - setSystemHealthLoading(false); - }); - }, [storeApi, setSystemHealth, setSystemHealthLoading]); - - useEffect(() => { - if (!loaded && !loading) { - fetchHealth(); - } - }, [loaded, loading, fetchHealth]); - - useEffect(() => { - if (!loaded) return; - const handleVisibility = () => { - if (document.visibilityState === "visible") { - fetchHealth(); - } - }; - document.addEventListener("visibilitychange", handleVisibility); - const id = setInterval(fetchHealth, HEALTH_POLL_INTERVAL_MS); - return () => { - document.removeEventListener("visibilitychange", handleVisibility); - clearInterval(id); - }; - }, [loaded, fetchHealth]); + const query = useQuery(systemHealthQueryOptions()); + const data = query.data ?? null; - return { issues, checks, healthy, loaded, loading }; + return { + issues: data?.issues ?? [], + checks: data?.checks ?? [], + healthy: data?.healthy ?? true, + loaded: query.isSuccess || query.isError, + loading: query.isFetching && !query.isSuccess, + }; } diff --git a/apps/web/hooks/domains/settings/use-workflow-settings.test.ts b/apps/web/hooks/domains/settings/use-workflow-settings.test.ts index 64092b42c7..0c7428de61 100644 --- a/apps/web/hooks/domains/settings/use-workflow-settings.test.ts +++ b/apps/web/hooks/domains/settings/use-workflow-settings.test.ts @@ -1,32 +1,58 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, act, cleanup } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { listWorkflows } from "@/lib/api/domains/kanban-api"; +import { qk } from "@/lib/query/keys"; import { workflowId as toWorkflowId, workspaceId as toWorkspaceId, type Workflow, } from "@/lib/types/http"; +import type { WorkflowItem } from "@/lib/state/slices"; -type StoreWorkflow = { - id: string; - workspaceId: string; - name: string; - description?: string | null; - hidden?: boolean; - style?: "kanban" | "office" | "custom"; -}; - -type MockState = { workflows: { items: StoreWorkflow[] } }; - -let mockState: MockState = { workflows: { items: [] } }; - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (s: MockState) => unknown) => selector(mockState), +vi.mock("@/lib/api/domains/kanban-api", () => ({ + listWorkflows: vi.fn(), })); import { useWorkflowSettings } from "./use-workflow-settings"; -function setStore(items: StoreWorkflow[]) { - mockState = { workflows: { items } }; +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +function setWorkflowCache(queryClient: QueryClient, items: WorkflowItem[]) { + const grouped = new Map(); + for (const item of items) { + grouped.set(item.workspaceId, [...(grouped.get(item.workspaceId) ?? []), item]); + } + const workspaceIds = new Set([ + ...queryClient + .getQueryCache() + .findAll({ queryKey: ["workflows"] }) + .map((query) => query.queryKey[1]) + .filter((id): id is string => typeof id === "string"), + ...grouped.keys(), + ]); + for (const workspaceId of workspaceIds) { + queryClient.setQueryData( + qk.workflows.all(workspaceId, { includeHidden: true }), + grouped.get(workspaceId) ?? [], + ); + } } const wf = (id: string, wsId: string, name: string): Workflow => ({ @@ -40,127 +66,134 @@ const wf = (id: string, wsId: string, name: string): Workflow => ({ const NAME_A1 = "Workflow A1"; const NAME_B1 = "Workflow B1"; -const STORE_A1: StoreWorkflow = { id: "wf-a1", workspaceId: "ws-a", name: NAME_A1 }; -const STORE_B1: StoreWorkflow = { id: "wf-b1", workspaceId: "ws-b", name: NAME_B1 }; +const CACHE_A1: WorkflowItem = { id: "wf-a1", workspaceId: "ws-a", name: NAME_A1 }; +const CACHE_B1: WorkflowItem = { id: "wf-b1", workspaceId: "ws-b", name: NAME_B1 }; beforeEach(() => { - setStore([]); -}); - -describe("useWorkflowSettings — store boundary filters", () => { - it("does not merge office-style workflows from the global store", () => { - // The sidebar's `useEnsureWorkspaceWorkflows` populates the store with every - // workflow — including office-style ones — on all routes. The settings UI - // is kanban-only (ADR-0004), so office workflows must be dropped at the - // store boundary (matching the SSR-side filter). Otherwise they land in - // `workflowItems`, which is what "Export All" serialises → workflow-import- - // export e2e regresses. - const officeInB: StoreWorkflow = { - id: "wf-b-office", - workspaceId: "ws-b", - name: "Office Only Workflow", - style: "office", - }; - setStore([officeInB]); - - const { result } = renderHook(() => useWorkflowSettings([], "ws-b")); - - expect(result.current.workflowItems).toHaveLength(0); - expect(result.current.savedWorkflowItems).toHaveLength(0); - }); + vi.mocked(listWorkflows).mockReset(); + vi.mocked(listWorkflows).mockResolvedValue({ workflows: [], total: 0 }); + cleanup(); }); describe("useWorkflowSettings", () => { - it("does not include workflows from other workspaces present in the global store", () => { - // Store has a workflow from workspace A (e.g. user previously visited it) - setStore([STORE_A1]); + it("does not include workflows from other workspaces present in the cache", () => { + const queryClient = createQueryClient(); + setWorkflowCache(queryClient, [CACHE_A1]); // We render the settings hook for workspace B with no initial workflows - const { result } = renderHook(() => useWorkflowSettings([], "ws-b")); + const { result } = renderHook(() => useWorkflowSettings([], "ws-b"), { + wrapper: wrapperFor(queryClient), + }); // The leaked workflow from workspace A must not appear in B's list expect(result.current.workflowItems).toHaveLength(0); expect(result.current.savedWorkflowItems).toHaveLength(0); }); - it("adds workflows from the store that belong to the current workspace", () => { - setStore([STORE_A1, STORE_B1]); + it("adds workflows from the cache that belong to the current workspace", () => { + const queryClient = createQueryClient(); + setWorkflowCache(queryClient, [CACHE_A1, CACHE_B1]); - const { result } = renderHook(() => useWorkflowSettings([], "ws-b")); + const { result } = renderHook(() => useWorkflowSettings([], "ws-b"), { + wrapper: wrapperFor(queryClient), + }); expect(result.current.workflowItems.map((w) => w.id)).toEqual(["wf-b1"]); }); - it("does not remove a workspace's workflows when an unrelated workspace's entries are added/removed in the store", () => { + it("does not remove a workspace's workflows when an unrelated workspace's entries are added/removed in the cache", () => { // Initial: workspace B has one saved workflow from SSR const initial = [wf("wf-b1", "ws-b", NAME_B1)]; - setStore([STORE_B1]); + const queryClient = createQueryClient(); + setWorkflowCache(queryClient, [CACHE_B1]); - const { result, rerender } = renderHook( - ({ store }: { store: StoreWorkflow[] }) => { - setStore(store); - return useWorkflowSettings(initial, "ws-b"); - }, - { initialProps: { store: [STORE_B1] } }, - ); + const { result, rerender } = renderHook(() => useWorkflowSettings(initial, "ws-b"), { + wrapper: wrapperFor(queryClient), + }); expect(result.current.workflowItems.map((w) => w.id)).toEqual(["wf-b1"]); - // Workspace A workflow is added to the store (e.g. WS event from another tab) + // Workspace A workflow is added to the cache (e.g. invalidation from another route) act(() => { - rerender({ store: [STORE_B1, STORE_A1] }); + setWorkflowCache(queryClient, [CACHE_B1, CACHE_A1]); }); + rerender(); expect(result.current.workflowItems.map((w) => w.id)).toEqual(["wf-b1"]); - // Workspace A workflow is removed from the store — must not affect B's list + // Workspace A workflow is removed from the cache — must not affect B's list act(() => { - rerender({ store: [STORE_B1] }); + setWorkflowCache(queryClient, [CACHE_B1]); }); + rerender(); expect(result.current.workflowItems.map((w) => w.id)).toEqual(["wf-b1"]); }); - it("falls back to the unscoped store when no workspaceId is provided", () => { - setStore([STORE_A1, STORE_B1]); + it("falls back to the unscoped cache when no workspaceId is provided", () => { + const queryClient = createQueryClient(); + setWorkflowCache(queryClient, [CACHE_A1, CACHE_B1]); - const { result } = renderHook(() => useWorkflowSettings([])); + const { result } = renderHook(() => useWorkflowSettings([]), { + wrapper: wrapperFor(queryClient), + }); expect(result.current.workflowItems.map((w) => w.id).sort()).toEqual(["wf-a1", "wf-b1"]); }); +}); - it("syncs name updates from the store within the current workspace", () => { +describe("useWorkflowSettings cache updates", () => { + it("syncs name updates from the cache within the current workspace", () => { const initial = [wf("wf-b1", "ws-b", NAME_B1)]; - setStore([STORE_B1]); + const queryClient = createQueryClient(); + setWorkflowCache(queryClient, [CACHE_B1]); - const { result, rerender } = renderHook( - ({ store }: { store: StoreWorkflow[] }) => { - setStore(store); - return useWorkflowSettings(initial, "ws-b"); - }, - { initialProps: { store: [STORE_B1] } }, - ); + const { result, rerender } = renderHook(() => useWorkflowSettings(initial, "ws-b"), { + wrapper: wrapperFor(queryClient), + }); expect(result.current.workflowItems[0].name).toEqual(NAME_B1); act(() => { - rerender({ store: [{ id: "wf-b1", workspaceId: "ws-b", name: "Renamed B1" }] }); + setWorkflowCache(queryClient, [{ id: "wf-b1", workspaceId: "ws-b", name: "Renamed B1" }]); }); + rerender(); expect(result.current.workflowItems[0].name).toEqual("Renamed B1"); }); it("excludes hidden system workflows from the settings list", () => { - // System workflows like "Improve Kandev" live in the global store with + // System workflows like "Improve Kandev" live in Query with // hidden=true so the kanban can resolve task references, but they must // never appear in the management UI. - const HIDDEN_SYSTEM: StoreWorkflow = { + const HIDDEN_SYSTEM: WorkflowItem = { id: "wf-improve-kandev", workspaceId: "ws-b", name: "Improve Kandev", hidden: true, }; - setStore([STORE_B1, HIDDEN_SYSTEM]); + const queryClient = createQueryClient(); + setWorkflowCache(queryClient, [CACHE_B1, HIDDEN_SYSTEM]); - const { result } = renderHook(() => useWorkflowSettings([], "ws-b")); + const { result } = renderHook(() => useWorkflowSettings([], "ws-b"), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current.workflowItems.map((w) => w.id)).toEqual(["wf-b1"]); + expect(result.current.savedWorkflowItems.map((w) => w.id)).toEqual(["wf-b1"]); + }); + + it("excludes office-style workflows from the settings list", () => { + const OFFICE_WORKFLOW: WorkflowItem = { + id: "wf-office", + workspaceId: "ws-b", + name: "Office Only Workflow", + style: "office", + }; + const queryClient = createQueryClient(); + setWorkflowCache(queryClient, [CACHE_B1, OFFICE_WORKFLOW]); + + const { result } = renderHook(() => useWorkflowSettings([], "ws-b"), { + wrapper: wrapperFor(queryClient), + }); expect(result.current.workflowItems.map((w) => w.id)).toEqual(["wf-b1"]); expect(result.current.savedWorkflowItems.map((w) => w.id)).toEqual(["wf-b1"]); @@ -168,32 +201,34 @@ describe("useWorkflowSettings", () => { it("drops a workflow from the settings list once it becomes hidden", () => { const initial = [wf("wf-b1", "ws-b", NAME_B1)]; - setStore([STORE_B1]); + const queryClient = createQueryClient(); + setWorkflowCache(queryClient, [CACHE_B1]); - const { result, rerender } = renderHook( - ({ store }: { store: StoreWorkflow[] }) => { - setStore(store); - return useWorkflowSettings(initial, "ws-b"); - }, - { initialProps: { store: [STORE_B1] } }, - ); + const { result, rerender } = renderHook(() => useWorkflowSettings(initial, "ws-b"), { + wrapper: wrapperFor(queryClient), + }); expect(result.current.workflowItems.map((w) => w.id)).toEqual(["wf-b1"]); // Backend flips hidden=true (e.g. healing the improve-kandev record). act(() => { - rerender({ store: [{ ...STORE_B1, hidden: true }] }); + setWorkflowCache(queryClient, [{ ...CACHE_B1, hidden: true }]); }); + rerender(); expect(result.current.workflowItems.map((w) => w.id)).toEqual([]); }); - it("starts scoping store entries once a workspaceId becomes defined", () => { - setStore([STORE_A1, STORE_B1]); + it("starts scoping cache entries once a workspaceId becomes defined", () => { + const queryClient = createQueryClient(); + setWorkflowCache(queryClient, [CACHE_A1, CACHE_B1]); const { result, rerender } = renderHook( ({ workspaceId }: { workspaceId?: string }) => useWorkflowSettings([], workspaceId), - { initialProps: { workspaceId: undefined as string | undefined } }, + { + initialProps: { workspaceId: undefined as string | undefined }, + wrapper: wrapperFor(queryClient), + }, ); // No workspaceId → unscoped fallback shows both diff --git a/apps/web/hooks/domains/settings/use-workflow-settings.ts b/apps/web/hooks/domains/settings/use-workflow-settings.ts index 2c0e941cf0..c5bbd96901 100644 --- a/apps/web/hooks/domains/settings/use-workflow-settings.ts +++ b/apps/web/hooks/domains/settings/use-workflow-settings.ts @@ -1,42 +1,48 @@ "use client"; import { useEffect, useMemo, useRef, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; +import { useAllCachedWorkflows, useCachedWorkflows } from "@/hooks/use-workflow-cache"; +import { useWorkflows } from "@/hooks/use-workflows"; import { workflowId, workspaceId as toWorkspaceId, type Workflow } from "@/lib/types/http"; /** - * Manages workflow list state for the settings page, synced with WS events - * from the Zustand store. Supports local edits (dirty tracking) and temp drafts. + * Manages workflow list state for the settings page, synced with Query cache + * updates. Supports local edits (dirty tracking) and temp drafts. * * `workspaceId` scopes the visible workflows to the current workspace so that - * stale entries from previously visited workspaces (still cached in the global - * Zustand store) don't leak into another workspace's settings page. + * stale entries from previously visited workspaces (still cached in Query) + * don't leak into another workspace's settings page. */ export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?: string) { - const storeWorkflows = useAppStore((state) => state.workflows.items); - // Hidden workflows (e.g. the system "Improve Kandev" template) are loaded - // into the global store with `includeHidden: true` so the kanban can resolve - // them when a task references one, but they must never surface in the - // settings management UI. Office-style workflows are managed from the Office - // surface (ADR-0004) and must be excluded the same way — the SSR-side filter - // in `workspace-workflows-client.tsx` already drops them; the store-boundary - // filter must match so live WS/fetch updates cannot merge them back in. - const scopedStoreWorkflows = useMemo(() => { - const visible = storeWorkflows.filter((w) => !w.hidden && w.style !== "office"); - return workspaceId ? visible.filter((w) => w.workspaceId === workspaceId) : visible; - }, [storeWorkflows, workspaceId]); - const [workflowItems, setWorkflowItems] = useState(initialWorkflows); - const [savedWorkflowItems, setSavedWorkflowItems] = useState(initialWorkflows); + useWorkflows(workspaceId ?? null, Boolean(workspaceId)); + const workspaceCachedWorkflows = useCachedWorkflows(workspaceId); + const allCachedWorkflows = useAllCachedWorkflows(); + const cachedWorkflows = workspaceId ? workspaceCachedWorkflows : allCachedWorkflows; + // Hidden workflows (e.g. the system "Improve Kandev" template) and + // office-style workflows are loaded into Query with `includeHidden: true` + // so other surfaces can resolve references, but the workflow settings page + // is kanban-only. Filter them out at the cache/prop boundaries so all + // downstream merging logic remains settings-visible only. + const visibleInitialWorkflows = useMemo( + () => initialWorkflows.filter(isSettingsVisibleWorkflow), + [initialWorkflows], + ); + const visibleCachedWorkflows = useMemo( + () => cachedWorkflows.filter(isSettingsVisibleWorkflow), + [cachedWorkflows], + ); + const [workflowItems, setWorkflowItems] = useState(visibleInitialWorkflows); + const [savedWorkflowItems, setSavedWorkflowItems] = useState(visibleInitialWorkflows); // Track all IDs we've ever seen from SSR props so we only add genuinely new ones // (not re-add workflows the user deleted locally). - const seenInitialIdsRef = useRef>(new Set(initialWorkflows.map((w) => w.id))); + const seenInitialIdsRef = useRef>(new Set(visibleInitialWorkflows.map((w) => w.id))); // Merge new workflows from SSR props (e.g. after router.refresh() following import). // useState ignores updated initialWorkflows on re-render, so we sync manually. useEffect(() => { const seen = seenInitialIdsRef.current; - const newWorkflows = initialWorkflows.filter((w) => !seen.has(w.id)); + const newWorkflows = visibleInitialWorkflows.filter((w) => !seen.has(w.id)); if (newWorkflows.length === 0) return; for (const w of newWorkflows) seen.add(w.id); @@ -53,45 +59,45 @@ export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?: if (toAdd.length === 0) return prev; return [...prev, ...toAdd]; }); - }, [initialWorkflows]); + }, [visibleInitialWorkflows]); - // Track which IDs the store has previously reported so we only remove - // workflows that were actually deleted via WS, not ones the store never knew about. - const prevStoreIdsRef = useRef>(new Set()); + // Track which IDs the cache has previously reported so we only remove + // workflows that were actually deleted, not ones the cache never knew about. + const prevCachedIdsRef = useRef>(new Set()); useEffect(() => { - const currentStoreIds = new Set(scopedStoreWorkflows.map((w) => w.id)); - const prevStoreIds = prevStoreIdsRef.current; + const currentCachedIds = new Set(visibleCachedWorkflows.map((w) => w.id)); + const prevCachedIds = prevCachedIdsRef.current; - // IDs that were in the store last render but are gone now → actually deleted via WS. - const deletedIds = new Set([...prevStoreIds].filter((id) => !currentStoreIds.has(id))); + // IDs that were in the cache last render but are gone now → actually deleted. + const deletedIds = new Set([...prevCachedIds].filter((id) => !currentCachedIds.has(id))); - prevStoreIdsRef.current = currentStoreIds; + prevCachedIdsRef.current = currentCachedIds; - const newFromStore = (prev: Workflow[]) => { + const newFromCache = (prev: Workflow[]) => { const localIds = new Set(prev.map((w) => w.id)); - // Don't add workflows from store for workspaces where we have temp (pending create) workflows. - // This prevents race conditions where WS event arrives before the create API callback. + // Don't add workflows from cache for workspaces where we have temp (pending create) workflows. + // This prevents races where invalidation resolves before the create API callback. const tempWorkspaceIds = new Set( prev.filter((w) => w.id.startsWith("temp-")).map((w) => w.workspace_id), ); - return scopedStoreWorkflows + return visibleCachedWorkflows .filter( (sw) => !localIds.has(workflowId(sw.id)) && !tempWorkspaceIds.has(toWorkspaceId(sw.workspaceId)), ) - .map((sw) => storeItemToWorkflow(sw)); + .map((sw) => cacheItemToWorkflow(sw)); }; setWorkflowItems((prev) => { - const toAdd = newFromStore(prev); + const toAdd = newFromCache(prev); // Only remove workflows the store explicitly deleted, keep everything else. const filtered = prev.filter((w) => !deletedIds.has(w.id)); const updated = filtered.map((w) => { if (w.id.startsWith("temp-")) return w; - const sw = scopedStoreWorkflows.find((s) => s.id === w.id); + const sw = visibleCachedWorkflows.find((s) => s.id === w.id); if (sw && sw.name !== w.name) return { ...w, name: sw.name }; return w; }); @@ -107,12 +113,12 @@ export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?: }); setSavedWorkflowItems((prev) => { - const toAdd = newFromStore(prev); + const toAdd = newFromCache(prev); const filtered = prev.filter((w) => !deletedIds.has(w.id)); if (toAdd.length === 0 && filtered.length === prev.length) return prev; return [...toAdd, ...filtered]; }); - }, [scopedStoreWorkflows]); + }, [visibleCachedWorkflows]); const savedWorkflowsById = useMemo(() => { return new Map(savedWorkflowItems.map((w) => [w.id, w])); @@ -137,17 +143,26 @@ export function useWorkflowSettings(initialWorkflows: Workflow[], workspaceId?: }; } -function storeItemToWorkflow(sw: { +function isSettingsVisibleWorkflow(workflow: { + hidden?: boolean; + style?: "kanban" | "office" | "custom"; +}) { + return !workflow.hidden && workflow.style !== "office"; +} + +function cacheItemToWorkflow(sw: { id: string; workspaceId: string; name: string; description?: string | null; + style?: Workflow["style"]; }): Workflow { return { id: workflowId(sw.id), workspace_id: toWorkspaceId(sw.workspaceId), name: sw.name, description: sw.description, + style: sw.style, created_at: "", updated_at: "", }; diff --git a/apps/web/hooks/domains/settings/user-settings-query-data.ts b/apps/web/hooks/domains/settings/user-settings-query-data.ts new file mode 100644 index 0000000000..d8e5757e5c --- /dev/null +++ b/apps/web/hooks/domains/settings/user-settings-query-data.ts @@ -0,0 +1,18 @@ +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import type { UserSettingsState } from "@/lib/state/slices/settings/types"; +import type { UserSettingsResponse } from "@/lib/types/http"; + +export type UserSettingsQueryData = UserSettingsResponse | UserSettingsState; + +function isMappedUserSettings(data: UserSettingsQueryData): data is UserSettingsState { + return "loaded" in data && "repositoryIds" in data; +} + +export function mapUserSettingsQueryData( + data: UserSettingsQueryData | null | undefined, +): UserSettingsState | null { + if (!data) return null; + if (isMappedUserSettings(data)) return data.loaded ? data : null; + const mapped = mapUserSettingsResponse(data); + return mapped.loaded ? mapped : null; +} diff --git a/apps/web/hooks/domains/slack/use-slack-availability.ts b/apps/web/hooks/domains/slack/use-slack-availability.ts index 7acf1bf86a..cf3cc7bdec 100644 --- a/apps/web/hooks/domains/slack/use-slack-availability.ts +++ b/apps/web/hooks/domains/slack/use-slack-availability.ts @@ -7,6 +7,7 @@ import { useIntegrationAvailable, type IntegrationConfigStatus, } from "../integrations/use-integration-availability"; +import { qk } from "@/lib/query/keys"; import { useSlackEnabled } from "./use-slack-enabled"; // Slack stores two secrets (token + cookie) instead of one — the shared @@ -24,13 +25,20 @@ function useSlackStatusLoader(workspaceId?: string | null) { } export function useSlackAuthed(workspaceId?: string | null): boolean { - return useIntegrationAuthed(useSlackStatusLoader(workspaceId)); + const fetchConfig = useSlackStatusLoader(workspaceId); + return useIntegrationAuthed({ + active: workspaceId !== null, + fetchConfig, + queryKey: qk.integrations.slack.config(workspaceId), + }); } export function useSlackAvailable(workspaceId?: string | null): boolean { const fetchConfig = useSlackStatusLoader(workspaceId); return useIntegrationAvailable({ + active: workspaceId !== null, useEnabled: useSlackEnabled, fetchConfig, + queryKey: qk.integrations.slack.config(workspaceId), }); } diff --git a/apps/web/hooks/domains/system/use-backups.ts b/apps/web/hooks/domains/system/use-backups.ts index 2f9a265e0c..3e85518593 100644 --- a/apps/web/hooks/domains/system/use-backups.ts +++ b/apps/web/hooks/domains/system/use-backups.ts @@ -1,13 +1,12 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { fetchBackups } from "@/lib/api/domains/system-api"; +import { useQuery } from "@tanstack/react-query"; +import { backupsQueryOptions } from "@/lib/query/query-options/system"; import type { SnapshotInfo } from "@/lib/types/system"; export function useBackups() { - const backups = useAppStore((s) => s.system.backups); - const setSystemBackups = useAppStore((s) => s.setSystemBackups); + const query = useQuery(backupsQueryOptions()); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -15,22 +14,27 @@ export function useBackups() { setIsLoading(true); setError(null); try { - const items = await fetchBackups({ cache: "no-store" }); - const nextItems = items ?? []; - setSystemBackups(nextItems); - return nextItems; + const res = await query.refetch(); + if (res.error) throw res.error; + return res.data ?? []; } catch (e) { setError(e instanceof Error ? e.message : String(e)); throw e; } finally { setIsLoading(false); } - }, [setSystemBackups]); + }, [query]); useEffect(() => { - if (backups.loaded) return; - void reload().catch(() => undefined); - }, [backups.loaded, reload]); + if (!query.error) return; + setError(query.error instanceof Error ? query.error.message : String(query.error)); + }, [query.error]); - return { backups: backups.items, loaded: backups.loaded, isLoading, error, reload }; + return { + backups: query.data ?? [], + loaded: query.isSuccess, + isLoading: isLoading || (query.isFetching && !query.isSuccess), + error, + reload, + }; } diff --git a/apps/web/hooks/domains/system/use-database-stats.ts b/apps/web/hooks/domains/system/use-database-stats.ts index 1f75db85c0..797d8cb54a 100644 --- a/apps/web/hooks/domains/system/use-database-stats.ts +++ b/apps/web/hooks/domains/system/use-database-stats.ts @@ -1,12 +1,11 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { fetchDatabaseStats } from "@/lib/api/domains/system-api"; +import { useQuery } from "@tanstack/react-query"; +import { databaseStatsQueryOptions } from "@/lib/query/query-options/system"; export function useDatabaseStats() { - const database = useAppStore((s) => s.system.database); - const setSystemDatabase = useAppStore((s) => s.setSystemDatabase); + const query = useQuery(databaseStatsQueryOptions()); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -14,19 +13,23 @@ export function useDatabaseStats() { setIsLoading(true); setError(null); try { - const res = await fetchDatabaseStats({ cache: "no-store" }); - setSystemDatabase(res); + await query.refetch(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsLoading(false); } - }, [setSystemDatabase]); + }, [query]); useEffect(() => { - if (database) return; - void reload(); - }, [database, reload]); + if (!query.error) return; + setError(query.error instanceof Error ? query.error.message : String(query.error)); + }, [query.error]); - return { database, isLoading, error, reload }; + return { + database: query.data ?? null, + isLoading: isLoading || (query.isFetching && !query.isSuccess), + error, + reload, + }; } diff --git a/apps/web/hooks/domains/system/use-disk-usage.ts b/apps/web/hooks/domains/system/use-disk-usage.ts index 4e0657ffb4..0f1a1fd7e2 100644 --- a/apps/web/hooks/domains/system/use-disk-usage.ts +++ b/apps/web/hooks/domains/system/use-disk-usage.ts @@ -1,33 +1,27 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { useAppStore } from "@/components/state-provider"; -import { fetchDiskUsage, refreshDiskUsage } from "@/lib/api/domains/system-api"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { refreshDiskUsage } from "@/lib/api/domains/system-api"; +import { diskUsageQueryOptions, systemJobsQueryOptions } from "@/lib/query/query-options/system"; /** * Fetch-on-mount hook for `/api/v1/system/disk-usage`. The backend serves the * cached value (or null while computing) and publishes a `system.job.update` * event with kind=disk-walk when the background walk finishes. That event is - * already routed into the jobs map by registerSystemEventsHandlers — this hook + * already routed into the Query jobs map — this hook * watches for the transition (running → succeeded/failed) and refetches the * usage payload once so the cards swap in the fresh value without polling. */ export function useDiskUsage() { - const diskUsage = useAppStore((s) => s.system.diskUsage); - const setSystemDiskUsage = useAppStore((s) => s.setSystemDiskUsage); + const query = useQuery(diskUsageQueryOptions()); + const jobsQuery = useQuery(systemJobsQueryOptions()); // Pick the last disk-walk job we have seen, regardless of id. There is at // most one in flight at a time. - // Wrapped in useShallow so the inline derivation doesn't create a fresh - // reference each render and trip "Maximum update depth exceeded" in - // consumers when there are no disk-walk jobs (the array reduces to the - // same null, but a missing memo would still re-run the equality check). - const diskWalkJob = useAppStore( - useShallow((s) => { - const jobs = Object.values(s.system.jobs).filter((j) => j.kind === "disk-walk"); - return jobs.length > 0 ? jobs[jobs.length - 1] : null; - }), - ); + const diskWalkJob = useMemo(() => { + const jobs = Object.values(jobsQuery.data ?? {}).filter((j) => j.kind === "disk-walk"); + return jobs.at(-1) ?? null; + }, [jobsQuery.data]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -36,14 +30,13 @@ export function useDiskUsage() { setIsLoading(true); setError(null); try { - const res = await fetchDiskUsage({ cache: "no-store" }); - setSystemDiskUsage(res); + await query.refetch(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsLoading(false); } - }, [setSystemDiskUsage]); + }, [query]); const refresh = useCallback(async () => { setError(null); @@ -56,11 +49,10 @@ export function useDiskUsage() { } }, [reload]); - // Initial fetch. useEffect(() => { - if (diskUsage) return; - void reload(); - }, [diskUsage, reload]); + if (!query.error) return; + setError(query.error instanceof Error ? query.error.message : String(query.error)); + }, [query.error]); // Refetch when the disk-walk job reports a terminal state. useEffect(() => { @@ -77,12 +69,18 @@ export function useDiskUsage() { // otherwise sit on "Calculating..." forever. Polling stops as soon as the // backend reports the cached value. useEffect(() => { - if (!diskUsage?.computing) return; + if (!query.data?.computing) return; const interval = setInterval(() => { void reload(); }, 1500); return () => clearInterval(interval); - }, [diskUsage?.computing, reload]); + }, [query.data, reload]); - return { diskUsage, isLoading, error, reload, refresh }; + return { + diskUsage: query.data ?? null, + isLoading: isLoading || (query.isFetching && !query.isSuccess), + error, + reload, + refresh, + }; } diff --git a/apps/web/hooks/domains/system/use-log-files.ts b/apps/web/hooks/domains/system/use-log-files.ts index 7762bc1b85..623b31e8d7 100644 --- a/apps/web/hooks/domains/system/use-log-files.ts +++ b/apps/web/hooks/domains/system/use-log-files.ts @@ -1,34 +1,35 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { fetchLogFiles } from "@/lib/api/domains/system-api"; +import { useQuery } from "@tanstack/react-query"; +import { logFilesQueryOptions } from "@/lib/query/query-options/system"; export function useLogFiles() { - const files = useAppStore((s) => s.system.logs.files); - const setSystemLogs = useAppStore((s) => s.setSystemLogs); + const query = useQuery(logFilesQueryOptions()); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [fetched, setFetched] = useState(false); const reload = useCallback(async () => { setIsLoading(true); setError(null); try { - const res = await fetchLogFiles({ cache: "no-store" }); - setSystemLogs(res ?? []); - setFetched(true); + await query.refetch(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsLoading(false); } - }, [setSystemLogs]); + }, [query]); useEffect(() => { - if (fetched) return; - void reload(); - }, [fetched, reload]); + if (!query.error) return; + setError(query.error instanceof Error ? query.error.message : String(query.error)); + }, [query.error]); - return { files, isLoading, error, reload }; + return { + files: query.data ?? [], + isLoading: isLoading || (query.isFetching && !query.isSuccess), + error, + reload, + }; } diff --git a/apps/web/hooks/domains/system/use-log-tail.ts b/apps/web/hooks/domains/system/use-log-tail.ts index ac652732b8..f164b79d74 100644 --- a/apps/web/hooks/domains/system/use-log-tail.ts +++ b/apps/web/hooks/domains/system/use-log-tail.ts @@ -1,17 +1,15 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { fetchLogTail } from "@/lib/api/domains/system-api"; +import { useQuery } from "@tanstack/react-query"; +import { logTailQueryOptions } from "@/lib/query/query-options/system"; /** * Fetches the last `n` lines of the current lumberjack log. The Logs page * also exposes a Refresh button which re-invokes `reload()`. */ export function useLogTail(n = 1000) { - const tail = useAppStore((s) => s.system.logs.tail); - const tailLoaded = useAppStore((s) => s.system.logs.tailLoaded); - const setSystemLogTail = useAppStore((s) => s.setSystemLogTail); + const query = useQuery(logTailQueryOptions(n)); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -19,19 +17,24 @@ export function useLogTail(n = 1000) { setIsLoading(true); setError(null); try { - const res = await fetchLogTail(n); - setSystemLogTail(res?.lines ?? []); + await query.refetch(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsLoading(false); } - }, [n, setSystemLogTail]); + }, [query]); useEffect(() => { - if (tailLoaded) return; - void reload(); - }, [tailLoaded, reload]); + if (!query.error) return; + setError(query.error instanceof Error ? query.error.message : String(query.error)); + }, [query.error]); - return { tail, loaded: tailLoaded, isLoading, error, reload }; + return { + tail: query.data?.lines ?? [], + loaded: query.isSuccess, + isLoading: isLoading || (query.isFetching && !query.isSuccess), + error, + reload, + }; } diff --git a/apps/web/hooks/domains/system/use-system-info.ts b/apps/web/hooks/domains/system/use-system-info.ts index ece53436dc..b0783b60de 100644 --- a/apps/web/hooks/domains/system/use-system-info.ts +++ b/apps/web/hooks/domains/system/use-system-info.ts @@ -1,17 +1,15 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { fetchSystemInfo } from "@/lib/api/domains/system-api"; +import { useQuery } from "@tanstack/react-query"; +import { systemInfoQueryOptions } from "@/lib/query/query-options/system"; /** - * Fetches `/api/v1/system/info` once on mount and exposes the cached value - * from the store. The endpoint is read-only build metadata so a single - * fetch is sufficient. + * Fetches `/api/v1/system/info` once on mount. The endpoint is read-only + * build metadata so a single fetch is sufficient. */ export function useSystemInfo() { - const info = useAppStore((s) => s.system.info); - const setSystemInfo = useAppStore((s) => s.setSystemInfo); + const query = useQuery(systemInfoQueryOptions()); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -19,19 +17,23 @@ export function useSystemInfo() { setIsLoading(true); setError(null); try { - const res = await fetchSystemInfo({ cache: "no-store" }); - setSystemInfo(res); + await query.refetch(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsLoading(false); } - }, [setSystemInfo]); + }, [query]); useEffect(() => { - if (info) return; - void reload(); - }, [info, reload]); + if (!query.error) return; + setError(query.error instanceof Error ? query.error.message : String(query.error)); + }, [query.error]); - return { info, isLoading, error, reload }; + return { + info: query.data ?? null, + isLoading: isLoading || (query.isFetching && !query.isSuccess), + error, + reload, + }; } diff --git a/apps/web/hooks/domains/system/use-system-jobs.ts b/apps/web/hooks/domains/system/use-system-jobs.ts index 54c1420f81..4460e7bb60 100644 --- a/apps/web/hooks/domains/system/use-system-jobs.ts +++ b/apps/web/hooks/domains/system/use-system-jobs.ts @@ -1,31 +1,22 @@ "use client"; import { useEffect } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { useAppStore } from "@/components/state-provider"; -import { fetchSystemJob } from "@/lib/api/domains/system-api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import { systemJobQueryOptions, systemJobsQueryOptions } from "@/lib/query/query-options/system"; import type { SystemJob } from "@/lib/types/system"; /** - * Reads the system-job map from the store. The map is kept in sync with the - * backend by the central `system.job.update` WS handler in - * `lib/ws/handlers/system-events.ts`, so this hook is purely a selector. + * Reads the system-job map from the Query cache. The map is kept in sync with + * the backend by the central `system.job.update` query bridge. * * When `kind` is provided, only jobs with that kind are returned. - * - * Uses `useShallow` so the derived array reference is reused when its element - * identities haven't changed — without it, the inline selector returns a - * fresh array on every render and triggers "Maximum update depth" in any - * consumer that renders a `JobProgressIndicator`. */ export function useSystemJobs(kind?: SystemJob["kind"]): SystemJob[] { - return useAppStore( - useShallow((s) => { - const all = Object.values(s.system.jobs); - if (!kind) return all; - return all.filter((j) => j.kind === kind); - }), - ); + const query = useQuery(systemJobsQueryOptions()); + const queryJobs = Object.values(query.data ?? {}); + if (!kind) return queryJobs; + return queryJobs.filter((job) => job.kind === kind); } const POLL_INTERVAL_MS = 800; @@ -36,39 +27,32 @@ const POLL_INTERVAL_MS = 800; * Polling fallback: while `jobId` is set and the locally observed job has not * reached a terminal state (succeeded/failed), this hook fetches * `GET /api/v1/system/jobs/:id` every ~800ms and upserts the response into - * the store. This is needed because the primary signal (the - * `system.job.update` WS broadcast) can be dropped when the WS connection + * the Query jobs map. This is needed because the primary signal (the + * `system.job.update` query bridge) can be missed when the WS connection * isn't open at the moment the job transitions - typical for fast operations * (restore is a tiny copy) and for factory-reset which tears down the * orchestrator first. */ export function useSystemJob(jobId: string | null | undefined): SystemJob | undefined { - const job = useAppStore((s) => (jobId ? s.system.jobs[jobId] : undefined)); - const upsertSystemJob = useAppStore((s) => s.upsertSystemJob); + const queryClient = useQueryClient(); + const jobsQuery = useQuery(systemJobsQueryOptions()); + const cachedJob = jobId ? jobsQuery.data?.[jobId] : undefined; + const query = useQuery({ + ...systemJobQueryOptions(jobId ?? ""), + refetchInterval: (result) => { + const state = result.state.data?.state ?? cachedJob?.state; + return state === "succeeded" || state === "failed" ? false : POLL_INTERVAL_MS; + }, + }); + const job = query.data ?? cachedJob; useEffect(() => { - if (!jobId) return; - if (job?.state === "succeeded" || job?.state === "failed") return; - - let cancelled = false; - const tick = async () => { - try { - const fresh = await fetchSystemJob(jobId); - if (!cancelled) upsertSystemJob(fresh); - } catch { - // 404 / network error - keep polling; the WS event may still arrive, - // or the next tick will succeed. - } - }; - // Kick once immediately to close out fast jobs that already finished - // before we started listening. - void tick(); - const interval = setInterval(() => void tick(), POLL_INTERVAL_MS); - return () => { - cancelled = true; - clearInterval(interval); - }; - }, [jobId, job?.state, upsertSystemJob]); + if (!query.data) return; + queryClient.setQueryData>(qk.system.jobs(), (prev) => ({ + ...(prev ?? {}), + [query.data.id]: query.data, + })); + }, [query.data, queryClient]); return job; } diff --git a/apps/web/hooks/domains/system/use-updates.ts b/apps/web/hooks/domains/system/use-updates.ts index 9ceec8445d..f1530168cd 100644 --- a/apps/web/hooks/domains/system/use-updates.ts +++ b/apps/web/hooks/domains/system/use-updates.ts @@ -1,12 +1,14 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { checkUpdates, fetchUpdates } from "@/lib/api/domains/system-api"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { checkUpdates } from "@/lib/api/domains/system-api"; +import { qk } from "@/lib/query/keys"; +import { updatesQueryOptions } from "@/lib/query/query-options/system"; export function useUpdates() { - const updates = useAppStore((s) => s.system.updates); - const setSystemUpdates = useAppStore((s) => s.setSystemUpdates); + const queryClient = useQueryClient(); + const query = useQuery(updatesQueryOptions()); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isChecking, setIsChecking] = useState(false); @@ -15,14 +17,13 @@ export function useUpdates() { setIsLoading(true); setError(null); try { - const res = await fetchUpdates({ cache: "no-store" }); - setSystemUpdates(res); + await query.refetch(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setIsLoading(false); } - }, [setSystemUpdates]); + }, [query]); /** * Triggers a server-side re-poll of the GitHub releases endpoint. The @@ -34,7 +35,7 @@ export function useUpdates() { setError(null); try { const res = await checkUpdates(); - setSystemUpdates(res); + queryClient.setQueryData(qk.system.updates(), res); return res; } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -42,12 +43,19 @@ export function useUpdates() { } finally { setIsChecking(false); } - }, [setSystemUpdates]); + }, [queryClient]); useEffect(() => { - if (updates) return; - void reload(); - }, [updates, reload]); + if (!query.error) return; + setError(query.error instanceof Error ? query.error.message : String(query.error)); + }, [query.error]); - return { updates, isLoading, isChecking, error, reload, check }; + return { + updates: query.data ?? null, + isLoading: isLoading || (query.isFetching && !query.isSuccess), + isChecking, + error, + reload, + check, + }; } diff --git a/apps/web/hooks/domains/workspace/use-repositories.test.ts b/apps/web/hooks/domains/workspace/use-repositories.test.ts deleted file mode 100644 index bad3e17fce..0000000000 --- a/apps/web/hooks/domains/workspace/use-repositories.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; - -const mockListRepositories = vi.fn(); -const mockSetRepositories = vi.fn(); -const mockSetRepositoriesLoading = vi.fn(); - -type Repos = { id: string; name: string }[]; -type MockState = { - repositories: { - itemsByWorkspaceId: Record; - loadingByWorkspaceId: Record; - loadedByWorkspaceId: Record; - }; - setRepositories: typeof mockSetRepositories; - setRepositoriesLoading: typeof mockSetRepositoriesLoading; -}; - -let mockState: MockState; - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (s: MockState) => unknown) => selector(mockState), -})); - -vi.mock("@/lib/api", () => ({ - listRepositories: (...args: unknown[]) => mockListRepositories(...args), -})); - -import { useRepositories } from "./use-repositories"; - -function setup(loaded: boolean) { - vi.clearAllMocks(); - mockState = { - repositories: { - itemsByWorkspaceId: {}, - loadingByWorkspaceId: {}, - loadedByWorkspaceId: { "ws-1": loaded }, - }, - setRepositories: mockSetRepositories, - setRepositoriesLoading: mockSetRepositoriesLoading, - }; -} - -describe("useRepositories", () => { - beforeEach(() => { - mockListRepositories.mockResolvedValue({ repositories: [{ id: "r1", name: "Repo One" }] }); - }); - - it("force-refreshes a fresh list even when the workspace is already loaded", async () => { - setup(/* loaded */ true); - renderHook(() => useRepositories("ws-1", true, true)); - await waitFor(() => expect(mockSetRepositories).toHaveBeenCalled()); - expect(mockListRepositories).toHaveBeenCalledWith("ws-1", undefined, { cache: "no-store" }); - expect(mockSetRepositories).toHaveBeenCalledWith("ws-1", [{ id: "r1", name: "Repo One" }]); - }); - - it("does not fetch on the lazy path when already loaded", async () => { - setup(/* loaded */ true); - renderHook(() => useRepositories("ws-1", true, false)); - await Promise.resolve(); - expect(mockListRepositories).not.toHaveBeenCalled(); - }); - - it("fetches on the lazy path when not yet loaded", async () => { - setup(/* loaded */ false); - renderHook(() => useRepositories("ws-1", true, false)); - await waitFor(() => expect(mockSetRepositories).toHaveBeenCalled()); - expect(mockListRepositories).toHaveBeenCalledWith("ws-1", undefined, { cache: "no-store" }); - }); - - it("does nothing when disabled or workspace is null", async () => { - setup(/* loaded */ false); - renderHook(() => useRepositories(null, true, true)); - renderHook(() => useRepositories("ws-1", false, true)); - await Promise.resolve(); - expect(mockListRepositories).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/hooks/domains/workspace/use-repositories.test.tsx b/apps/web/hooks/domains/workspace/use-repositories.test.tsx new file mode 100644 index 0000000000..12f307d230 --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-repositories.test.tsx @@ -0,0 +1,158 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { listRepositories } from "@/lib/api/domains/workspace-api"; +import { qk } from "@/lib/query/keys"; +import type { Repository } from "@/lib/types/http"; +import { useRepositories } from "./use-repositories"; +import { useRepository } from "./use-repository"; + +const WORKSPACE_ID = "workspace-1"; +const REPO_ID = "repo-1"; +const OTHER_REPO_ID = "repo-2"; + +vi.mock("@/lib/api/domains/workspace-api", () => ({ + listRepositories: vi.fn(), +})); + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function repo(id: string, name: string): Repository { + return { + id, + workspace_id: WORKSPACE_ID, + name, + local_path: `/workspace/${name}`, + } as Repository; +} + +describe("useRepositories", () => { + beforeEach(() => { + vi.mocked(listRepositories).mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("reads workspace repositories from the Query cache without a Zustand store", () => { + const queryClient = createQueryClient(); + const repositories = [repo(REPO_ID, "frontend")]; + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), repositories); + + const { result } = renderHook(() => useRepositories(WORKSPACE_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current).toMatchObject({ + repositories, + isLoading: false, + }); + }); + + it("cold-loads workspace repositories through the query option", async () => { + const repositories = [repo(REPO_ID, "frontend")]; + vi.mocked(listRepositories).mockResolvedValue({ repositories, total: 1 }); + const queryClient = createQueryClient(); + + const { result } = renderHook(() => useRepositories(WORKSPACE_ID), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.repositories).toEqual(repositories)); + + expect(listRepositories).toHaveBeenCalledWith(WORKSPACE_ID, undefined, expect.any(Object)); + expect(queryClient.getQueryData(qk.workspaces.repositories(WORKSPACE_ID))).toEqual( + repositories, + ); + }); + + it("force-refreshes a fresh list even when cached repositories exist", async () => { + const cached = [repo(REPO_ID, "cached")]; + const fresh = [repo(OTHER_REPO_ID, "fresh")]; + vi.mocked(listRepositories).mockResolvedValue({ repositories: fresh, total: 1 }); + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), cached); + + const { result } = renderHook(() => useRepositories(WORKSPACE_ID, true, true), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current.repositories).toEqual(cached); + await waitFor(() => expect(result.current.repositories).toEqual(fresh)); + + expect(listRepositories).toHaveBeenCalledWith(WORKSPACE_ID, undefined, expect.any(Object)); + }); + + it("does not retry a failed forced refresh on rerender", async () => { + const cached = [repo(REPO_ID, "cached")]; + vi.mocked(listRepositories).mockRejectedValueOnce(new Error("temporary failure")); + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), cached); + + const { result, rerender } = renderHook(() => useRepositories(WORKSPACE_ID, true, true), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current.repositories).toEqual(cached); + await waitFor(() => expect(listRepositories).toHaveBeenCalledTimes(1)); + + rerender(); + await Promise.resolve(); + + expect(listRepositories).toHaveBeenCalledTimes(1); + expect(result.current.repositories).toEqual(cached); + }); + + it("does not fetch when disabled or workspace is missing", async () => { + const queryClient = createQueryClient(); + + renderHook(() => useRepositories(null, true, true), { + wrapper: wrapperFor(queryClient), + }); + renderHook(() => useRepositories(WORKSPACE_ID, false, true), { + wrapper: wrapperFor(queryClient), + }); + + await Promise.resolve(); + + expect(listRepositories).not.toHaveBeenCalled(); + }); +}); + +describe("useRepository", () => { + afterEach(() => { + cleanup(); + }); + + it("finds a repository across workspace Query caches without a Zustand store", () => { + const queryClient = createQueryClient(); + const target = repo(REPO_ID, "frontend"); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), [ + repo(OTHER_REPO_ID, "backend"), + target, + ]); + + const { result } = renderHook(() => useRepository(REPO_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current).toEqual(target); + }); +}); diff --git a/apps/web/hooks/domains/workspace/use-repositories.ts b/apps/web/hooks/domains/workspace/use-repositories.ts index ae893ebcd1..aa580680f0 100644 --- a/apps/web/hooks/domains/workspace/use-repositories.ts +++ b/apps/web/hooks/domains/workspace/use-repositories.ts @@ -1,92 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; import { useEffect, useRef } from "react"; -import { useAppStore } from "@/components/state-provider"; import type { Repository } from "@/lib/types/http"; -import { listRepositories } from "@/lib/api"; +import { workspaceRepositoriesQueryOptions } from "@/lib/query/query-options"; const EMPTY_REPOSITORIES: Repository[] = []; /** - * Loads a workspace's repositories from the store, fetching once when not yet - * loaded. Pass `forceRefresh` to instead pull a fresh list once per workspace on - * mount (e.g. a picker that must reflect repos created since the slice was first - * loaded) — the lazy path is disabled in that mode so there's no double fetch, - * and the per-workspace guard is only marked on success so a failed fetch can - * retry on the next mount. + * Loads a workspace's repositories from TanStack Query. Pass `forceRefresh` to + * pull a fresh list once per workspace on mount while preserving cached data. */ export function useRepositories(workspaceId: string | null, enabled = true, forceRefresh = false) { - const repositories = useAppStore((state) => - workspaceId - ? (state.repositories.itemsByWorkspaceId[workspaceId] ?? EMPTY_REPOSITORIES) - : EMPTY_REPOSITORIES, - ); - const isLoading = useAppStore((state) => - workspaceId ? (state.repositories.loadingByWorkspaceId[workspaceId] ?? false) : false, - ); - const isLoaded = useAppStore((state) => - workspaceId ? (state.repositories.loadedByWorkspaceId[workspaceId] ?? false) : false, - ); - const setRepositories = useAppStore((state) => state.setRepositories); - const setRepositoriesLoading = useAppStore((state) => state.setRepositoriesLoading); - // No in-flight ref: the effect deps (enabled/forceRefresh/workspaceId + stable - // store actions) don't change mid-fetch, so the effect can't re-run and start - // a duplicate fetch for the same workspace; `cancelled` discards stale results - // on workspace switch, and forcedRef/isLoaded gate re-fetches after success. + const query = useQuery({ + ...workspaceRepositoriesQueryOptions(workspaceId ?? ""), + enabled: enabled && Boolean(workspaceId), + }); + const { data, isFetching, refetch } = query; + const repositories = data ?? EMPTY_REPOSITORIES; const forcedRef = useRef(null); useEffect(() => { - if (!enabled || !workspaceId) return; - if (isLoaded && isLoading) { - setRepositoriesLoading(workspaceId, false); - } - }, [enabled, isLoaded, isLoading, setRepositoriesLoading, workspaceId]); - - // Force-refresh: pull a fresh list once per workspace, bypassing the - // isLoaded cache. forcedRef is set only on success so a failed fetch retries. - useEffect(() => { - if (!enabled || !workspaceId || !forceRefresh) return; + if (!enabled || !forceRefresh || !workspaceId) return; if (forcedRef.current === workspaceId) return; - let cancelled = false; - setRepositoriesLoading(workspaceId, true); - listRepositories(workspaceId, undefined, { cache: "no-store" }) - .then((response) => { - if (cancelled) return; - forcedRef.current = workspaceId; - setRepositories(workspaceId, response.repositories); - }) - .catch(() => { - // Leave forcedRef unset so the next mount retries; keep cached repos. - }) - .finally(() => { - if (cancelled) return; - setRepositoriesLoading(workspaceId, false); - }); - return () => { - cancelled = true; - }; - }, [enabled, forceRefresh, workspaceId, setRepositories, setRepositoriesLoading]); - - useEffect(() => { - if (!enabled || !workspaceId || forceRefresh) return; - if (isLoaded) return; - let cancelled = false; - setRepositoriesLoading(workspaceId, true); - listRepositories(workspaceId, undefined, { cache: "no-store" }) - .then((response) => { - if (cancelled) return; - setRepositories(workspaceId, response.repositories); - }) - .catch(() => { - if (cancelled) return; - setRepositories(workspaceId, []); - }) - .finally(() => { - if (cancelled) return; - setRepositoriesLoading(workspaceId, false); - }); - return () => { - cancelled = true; - }; - }, [enabled, forceRefresh, isLoaded, setRepositories, setRepositoriesLoading, workspaceId]); + forcedRef.current = workspaceId; + void refetch(); + }, [enabled, forceRefresh, refetch, workspaceId]); - return { repositories, isLoading }; + return { repositories, isLoading: isFetching && repositories.length === 0 }; } diff --git a/apps/web/hooks/domains/workspace/use-repository-branches.test.tsx b/apps/web/hooks/domains/workspace/use-repository-branches.test.tsx new file mode 100644 index 0000000000..05e1950129 --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-repository-branches.test.tsx @@ -0,0 +1,150 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { listBranches, listRepositoryBranches } from "@/lib/api/domains/workspace-api"; +import { qk } from "@/lib/query/keys"; +import type { Branch } from "@/lib/types/http"; +import { useBranches, type BranchSource } from "./use-repository-branches"; + +const WORKSPACE_ID = "workspace-1"; +const REPO_ID = "repo-1"; +const LOCAL_PATH = "/repo"; + +vi.mock("@/lib/api/domains/workspace-api", () => ({ + listBranches: vi.fn(), + listRepositoryBranches: vi.fn(), +})); + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useBranches", () => { + beforeEach(() => { + vi.mocked(listBranches).mockReset(); + vi.mocked(listRepositoryBranches).mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("reads repository branches from the workspace Query cache without a Zustand store", () => { + const queryClient = createQueryClient(); + const branches: Branch[] = [{ name: "main", type: "local" }]; + const source: BranchSource = { + kind: "id", + workspaceId: WORKSPACE_ID, + repositoryId: REPO_ID, + }; + queryClient.setQueryData( + qk.workspaces.branches(WORKSPACE_ID, { repositoryId: REPO_ID }), + branches, + ); + + const { result } = renderHook(() => useBranches(source), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current).toMatchObject({ + branches, + isLoading: false, + }); + }); + + it("reads path-based branches from the workspace Query cache without a Zustand store", () => { + const queryClient = createQueryClient(); + const branches: Branch[] = [{ name: "develop", type: "local" }]; + const source: BranchSource = { + kind: "path", + workspaceId: WORKSPACE_ID, + path: LOCAL_PATH, + }; + queryClient.setQueryData(qk.workspaces.branches(WORKSPACE_ID, { path: LOCAL_PATH }), branches); + + const { result } = renderHook(() => useBranches(source), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current).toMatchObject({ + branches, + isLoading: false, + }); + }); + + it("cold-loads id-based rows through the workspace branch endpoint", async () => { + vi.mocked(listBranches).mockResolvedValue({ + branches: [{ name: "remote-main", type: "remote" }], + total: 1, + }); + const queryClient = createQueryClient(); + const source: BranchSource = { + kind: "id", + workspaceId: WORKSPACE_ID, + repositoryId: REPO_ID, + }; + + const { result } = renderHook(() => useBranches(source), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.branches).toHaveLength(1)); + + expect(listBranches).toHaveBeenCalledWith( + WORKSPACE_ID, + { repositoryId: REPO_ID }, + expect.any(Object), + ); + expect(listRepositoryBranches).not.toHaveBeenCalled(); + }); + + it("manual refresh forces the repository refresh endpoint and updates the active workspace cache", async () => { + const initialBranches: Branch[] = [{ name: "main", type: "local" }]; + const refreshedBranches: Branch[] = [{ name: "feature/refreshed", type: "local" }]; + vi.mocked(listRepositoryBranches).mockResolvedValue({ + branches: refreshedBranches, + total: 1, + }); + const queryClient = createQueryClient(); + const source: BranchSource = { + kind: "id", + workspaceId: WORKSPACE_ID, + repositoryId: REPO_ID, + }; + queryClient.setQueryData( + qk.workspaces.branches(WORKSPACE_ID, { repositoryId: REPO_ID }), + initialBranches, + ); + + const { result } = renderHook(() => useBranches(source), { + wrapper: wrapperFor(queryClient), + }); + + await act(async () => { + await result.current.refresh?.(); + }); + + expect(listRepositoryBranches).toHaveBeenCalledWith( + REPO_ID, + { refresh: true }, + expect.any(Object), + ); + expect( + queryClient.getQueryData(qk.workspaces.branches(WORKSPACE_ID, { repositoryId: REPO_ID })), + ).toEqual(refreshedBranches); + }); +}); diff --git a/apps/web/hooks/domains/workspace/use-repository-branches.ts b/apps/web/hooks/domains/workspace/use-repository-branches.ts index 7a37dd2dd3..6adbe63f2f 100644 --- a/apps/web/hooks/domains/workspace/use-repository-branches.ts +++ b/apps/web/hooks/domains/workspace/use-repository-branches.ts @@ -1,17 +1,21 @@ "use client"; -import { useCallback, useEffect, useRef } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { listBranches, listRepositoryBranches } from "@/lib/api"; +import { useCallback } from "react"; +import { useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query"; +import { + repositoryBranchesQueryOptions, + workspaceBranchesQueryOptions, +} from "@/lib/query/query-options"; +import { qk } from "@/lib/query/keys"; import type { Branch } from "@/lib/types/http"; const EMPTY_BRANCHES: Branch[] = []; /** * Source of branches for a row: either a workspace repo (by id) or an - * on-machine folder (by path). Both routes go through one backend endpoint - * (`/workspaces/:id/branches`) and share one Zustand cache slice — id-based - * entries are keyed by the repo id, path-based entries get a synthetic key. + * on-machine folder (by path). Both routes go through Query option factories: + * row branch lists use `/workspaces/:id/branches`, while explicit refresh for + * imported repository rows uses `/repositories/:id/branches?refresh=true`. * * `workspaceId` is always required because the route segment needs it. */ @@ -19,9 +23,46 @@ export type BranchSource = | { kind: "id"; workspaceId: string; repositoryId: string } | { kind: "path"; workspaceId: string; path: string }; -function cacheKeyFor(source: BranchSource | null): string { - if (!source) return ""; - return source.kind === "id" ? source.repositoryId : `path::${source.workspaceId}::${source.path}`; +async function fetchFreshBranches( + source: BranchSource, + queryClient: ReturnType, + pathQuery: Pick, "refetch">, +): Promise { + if (source.kind === "id") { + const branches = await queryClient.fetchQuery({ + ...repositoryBranchesQueryOptions(source.repositoryId, { refresh: true }), + staleTime: 0, + }); + queryClient.setQueryData( + qk.workspaces.branches(source.workspaceId, { repositoryId: source.repositoryId }), + branches, + ); + return branches; + } + return (await pathQuery.refetch()).data ?? EMPTY_BRANCHES; +} + +function idBranchQueryOptions(source: BranchSource | null) { + if (source?.kind !== "id") return workspaceBranchesQueryOptions("", { repositoryId: "" }); + return workspaceBranchesQueryOptions(source.workspaceId, { repositoryId: source.repositoryId }); +} + +function pathBranchQueryOptions(source: BranchSource | null) { + if (source?.kind !== "path") return workspaceBranchesQueryOptions("", { path: "" }); + return workspaceBranchesQueryOptions(source.workspaceId, { path: source.path }); +} + +function useBranchQueries(source: BranchSource | null, enabled: boolean) { + const idQuery = useQuery({ + ...idBranchQueryOptions(source), + enabled: enabled && source?.kind === "id", + }); + const pathQuery = useQuery({ + ...pathBranchQueryOptions(source), + enabled: enabled && source?.kind === "path", + }); + const activeQuery = source?.kind === "id" ? idQuery : pathQuery; + return { activeQuery, pathQuery }; } /** @@ -42,63 +83,25 @@ export type UseBranchesResult = { }; export function useBranches(source: BranchSource | null, enabled = true): UseBranchesResult { - const key = cacheKeyFor(source); - const branches = useAppStore((state) => - key ? (state.repositoryBranches.itemsByRepositoryId[key] ?? EMPTY_BRANCHES) : EMPTY_BRANCHES, - ); - const isLoaded = useAppStore((state) => - key ? (state.repositoryBranches.loadedByRepositoryId[key] ?? false) : false, - ); - const isLoading = useAppStore((state) => - key ? (state.repositoryBranches.loadingByRepositoryId[key] ?? false) : false, - ); - const setRepositoryBranches = useAppStore((state) => state.setRepositoryBranches); - const setRepositoryBranchesLoading = useAppStore((state) => state.setRepositoryBranchesLoading); - const inFlightRef = useRef(false); - - useEffect(() => { - if (!enabled || !source) return; - if (isLoaded || inFlightRef.current) return; - inFlightRef.current = true; - setRepositoryBranchesLoading(key, true); - - const promise = - source.kind === "id" - ? listBranches(source.workspaceId, { repositoryId: source.repositoryId }) - : listBranches(source.workspaceId, { path: source.path }); - - promise - .then((response) => setRepositoryBranches(key, response.branches)) - .catch(() => setRepositoryBranches(key, [])) - .finally(() => { - inFlightRef.current = false; - setRepositoryBranchesLoading(key, false); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- key encodes source identity; listing every field re-fires on every render - }, [enabled, isLoaded, key, setRepositoryBranches, setRepositoryBranchesLoading]); + const queryClient = useQueryClient(); + const { activeQuery, pathQuery } = useBranchQueries(source, enabled); + const branches = activeQuery.data ?? EMPTY_BRANCHES; const refresh = useCallback(async () => { if (!source) return; - setRepositoryBranchesLoading(key, true); try { - const response = - source.kind === "id" - ? await listRepositoryBranches(source.repositoryId, { refresh: true }) - : await listBranches(source.workspaceId, { path: source.path }); - setRepositoryBranches(key, response.branches); + await fetchFreshBranches(source, queryClient, pathQuery); } catch { // Refresh failures leave the existing branch list in place; the user // can retry manually. Errors are surfaced via the BranchRefreshButton's // tooltip when wired with `fetchError`, but the hook does not own // error state today. - } finally { - setRepositoryBranchesLoading(key, false); } - }, [source, key, setRepositoryBranches, setRepositoryBranchesLoading]); + }, [pathQuery, queryClient, source]); return { branches, - isLoading, + isLoading: activeQuery.isFetching && branches.length === 0, refresh: source ? refresh : undefined, }; } diff --git a/apps/web/hooks/domains/workspace/use-repository-cache.ts b/apps/web/hooks/domains/workspace/use-repository-cache.ts new file mode 100644 index 0000000000..2aa1b67a1e --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-repository-cache.ts @@ -0,0 +1,92 @@ +import { useCallback, useRef, useSyncExternalStore } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import type { Repository } from "@/lib/types/http"; + +export type RepositoriesByWorkspace = Record; + +const EMPTY_BY_WORKSPACE: RepositoriesByWorkspace = {}; +const EMPTY_REPOSITORIES: Repository[] = []; + +type RepositoryCacheSnapshot = { + signature: string; + repositoriesByWorkspace: RepositoriesByWorkspace; +}; + +export function useRepositoriesByWorkspace(): RepositoriesByWorkspace { + const queryClient = useQueryClient(); + const snapshotRef = useRef({ + signature: "", + repositoriesByWorkspace: EMPTY_BY_WORKSPACE, + }); + const getSnapshot = useCallback(() => { + const snapshot = readRepositoriesByWorkspace(queryClient); + if (snapshot.signature === snapshotRef.current.signature) { + return snapshotRef.current.repositoriesByWorkspace; + } + snapshotRef.current = snapshot; + return snapshot.repositoriesByWorkspace; + }, [queryClient]); + + return useSyncExternalStore( + (onStoreChange) => queryClient.getQueryCache().subscribe(onStoreChange), + getSnapshot, + () => EMPTY_BY_WORKSPACE, + ); +} + +export function useCachedRepositories(workspaceId: string | null | undefined): Repository[] { + const repositoriesByWorkspace = useRepositoriesByWorkspace(); + if (!workspaceId) return EMPTY_REPOSITORIES; + return repositoriesByWorkspace[workspaceId] ?? EMPTY_REPOSITORIES; +} + +export function useAllCachedRepositories(): Repository[] { + return Object.values(useRepositoriesByWorkspace()).flat(); +} + +export function readRepositoriesByWorkspace(queryClient: QueryClient): RepositoryCacheSnapshot { + const queries = queryClient + .getQueryCache() + .findAll() + .filter((query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key[0] === "workspaces" && + typeof key[1] === "string" && + key[2] === "repositories" && + Array.isArray(query.state.data) + ); + }) + .sort((a, b) => a.state.dataUpdatedAt - b.state.dataUpdatedAt); + + const grouped = new Map>(); + for (const query of queries) { + const key = query.queryKey; + const workspaceId = key[1] as string; + const workspaceRepos = grouped.get(workspaceId) ?? new Map(); + for (const repository of query.state.data as Repository[]) { + workspaceRepos.set(repository.id, { + ...workspaceRepos.get(repository.id), + ...repository, + }); + } + grouped.set(workspaceId, workspaceRepos); + } + + const repositoriesByWorkspace = Object.fromEntries( + [...grouped.entries()].map(([workspaceId, repositories]) => [ + workspaceId, + [...repositories.values()], + ]), + ); + + return { + signature: queries + .map( + (query) => `${query.queryHash}:${query.state.dataUpdatedAt}:${query.state.dataUpdateCount}`, + ) + .join("|"), + repositoriesByWorkspace, + }; +} diff --git a/apps/web/hooks/domains/workspace/use-repository-scripts.test.tsx b/apps/web/hooks/domains/workspace/use-repository-scripts.test.tsx new file mode 100644 index 0000000000..e0161349da --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-repository-scripts.test.tsx @@ -0,0 +1,55 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { qk } from "@/lib/query/keys"; +import { repositoryId as toRepositoryId, type RepositoryScript } from "@/lib/types/http"; +import { useRepositoryScripts } from "./use-repository-scripts"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useRepositoryScripts", () => { + afterEach(() => { + cleanup(); + }); + + it("reads repository scripts from TanStack Query without a Zustand store", () => { + const queryClient = createQueryClient(); + const scripts: RepositoryScript[] = [ + { + id: "script-1", + repository_id: toRepositoryId("repo-1"), + name: "Setup", + command: "pnpm install", + position: 0, + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + }, + ]; + queryClient.setQueryData(qk.workspaces.repositoryScripts("repo-1"), scripts); + + const { result } = renderHook(() => useRepositoryScripts("repo-1"), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current).toMatchObject({ + scripts, + isLoaded: true, + }); + }); +}); diff --git a/apps/web/hooks/domains/workspace/use-repository-scripts.ts b/apps/web/hooks/domains/workspace/use-repository-scripts.ts index 5cac2c9296..3c6efd75a2 100644 --- a/apps/web/hooks/domains/workspace/use-repository-scripts.ts +++ b/apps/web/hooks/domains/workspace/use-repository-scripts.ts @@ -1,62 +1,19 @@ -import { useEffect, useRef } from "react"; -import { useAppStore } from "@/components/state-provider"; +import { useQuery } from "@tanstack/react-query"; import type { RepositoryScript } from "@/lib/types/http"; -import { listRepositoryScripts } from "@/lib/api"; +import { repositoryScriptsQueryOptions } from "@/lib/query/query-options"; const EMPTY_SCRIPTS: RepositoryScript[] = []; export function useRepositoryScripts(repositoryId: string | null, enabled = true) { - const scripts = useAppStore((state) => - repositoryId - ? (state.repositoryScripts.itemsByRepositoryId[repositoryId] ?? EMPTY_SCRIPTS) - : EMPTY_SCRIPTS, - ); - const isLoading = useAppStore((state) => - repositoryId ? (state.repositoryScripts.loadingByRepositoryId[repositoryId] ?? false) : false, - ); - const isLoaded = useAppStore((state) => - repositoryId ? (state.repositoryScripts.loadedByRepositoryId[repositoryId] ?? false) : false, - ); - const setRepositoryScripts = useAppStore((state) => state.setRepositoryScripts); - const setRepositoryScriptsLoading = useAppStore((state) => state.setRepositoryScriptsLoading); - const inFlightRef = useRef(false); - - useEffect(() => { - if (!enabled || !repositoryId) return; - if (isLoaded && isLoading) { - setRepositoryScriptsLoading(repositoryId, false); - } - }, [enabled, isLoaded, isLoading, setRepositoryScriptsLoading, repositoryId]); - - useEffect(() => { - if (!enabled || !repositoryId) return; - if (isLoaded || inFlightRef.current) return; - - let cancelled = false; - inFlightRef.current = true; - setRepositoryScriptsLoading(repositoryId, true); - - listRepositoryScripts(repositoryId, { cache: "no-store" }) - .then((response) => { - if (cancelled) return; - setRepositoryScripts(repositoryId, response.scripts ?? []); - }) - .catch((error) => { - console.error("[useRepositoryScripts] Fetch error:", { repositoryId, error }); - if (cancelled) return; - setRepositoryScripts(repositoryId, []); - }) - .finally(() => { - inFlightRef.current = false; - if (cancelled) return; - setRepositoryScriptsLoading(repositoryId, false); - }); - - return () => { - cancelled = true; - inFlightRef.current = false; - }; - }, [enabled, isLoaded, setRepositoryScripts, setRepositoryScriptsLoading, repositoryId]); - - return { scripts, isLoading, isLoaded }; + const query = useQuery({ + ...repositoryScriptsQueryOptions(repositoryId ?? ""), + enabled: enabled && Boolean(repositoryId), + }); + const scripts = query.data ?? EMPTY_SCRIPTS; + + return { + scripts, + isLoading: query.isFetching && scripts.length === 0, + isLoaded: query.isFetched || Boolean(query.data), + }; } diff --git a/apps/web/hooks/domains/workspace/use-repository.ts b/apps/web/hooks/domains/workspace/use-repository.ts index 3e10998d4c..f24ea8b7a2 100644 --- a/apps/web/hooks/domains/workspace/use-repository.ts +++ b/apps/web/hooks/domains/workspace/use-repository.ts @@ -1,13 +1,11 @@ import { useMemo } from "react"; -import { useAppStore } from "@/components/state-provider"; -import type { Repository } from "@/lib/types/http"; +import { useAllCachedRepositories } from "./use-repository-cache"; export function useRepository(repositoryId: string | null) { - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const repositories = useAllCachedRepositories(); return useMemo(() => { if (!repositoryId) return null; - const repositories = Object.values(repositoriesByWorkspace).flat() as Repository[]; - return repositories.find((repo: Repository) => repo.id === repositoryId) ?? null; - }, [repositoriesByWorkspace, repositoryId]); + return repositories.find((repo) => repo.id === repositoryId) ?? null; + }, [repositories, repositoryId]); } diff --git a/apps/web/hooks/domains/workspace/use-workspaces.test.tsx b/apps/web/hooks/domains/workspace/use-workspaces.test.tsx new file mode 100644 index 0000000000..8421903563 --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-workspaces.test.tsx @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { type ReactNode } from "react"; +import { StateProvider, useAppStore } from "@/components/state-provider"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import type { Workspace } from "@/lib/types/http"; +import { useWorkspaces } from "./use-workspaces"; + +function workspace(id: string, name = id): Workspace { + return { + id, + name, + owner_id: "user-1", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + } as Workspace; +} + +function renderUseWorkspaces({ + activeId = null, + activeWorkflowId = null, + workspaces = [], +}: { + activeId?: string | null; + activeWorkflowId?: string | null; + workspaces?: Workspace[]; +} = {}) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workspaces.all(), workspaces); + const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + + ); + + return { ...renderHook(() => useWorkspaces(), { wrapper }), queryClient }; +} + +describe("useWorkspaces", () => { + it("reads the workspace list from the query cache", () => { + const { result } = renderUseWorkspaces({ + activeId: "ws-1", + workspaces: [workspace("ws-1", "Main")], + }); + + expect(result.current.items).toEqual([expect.objectContaining({ id: "ws-1", name: "Main" })]); + expect(result.current.activeWorkspace?.name).toBe("Main"); + }); + + it("repairs a missing active workspace id from the query list", async () => { + const { result } = renderUseWorkspaces({ + activeId: "missing", + activeWorkflowId: "wf-1", + workspaces: [workspace("ws-2", "Next")], + }); + + await waitFor(() => expect(result.current.activeId).toBe("ws-2")); + expect(result.current.activeWorkspace?.name).toBe("Next"); + }); + + it("clears active workspace and workflow when the query list is empty", async () => { + const { result } = renderHook( + () => ({ + workspaces: useWorkspaces(), + activeWorkflowId: useAppStore((state) => state.workflows.activeId), + }), + { + wrapper: ({ children }) => { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workspaces.all(), []); + return ( + + + {children} + + + ); + }, + }, + ); + + await waitFor(() => expect(result.current.workspaces.activeId).toBeNull()); + expect(result.current.activeWorkflowId).toBeNull(); + }); +}); diff --git a/apps/web/hooks/domains/workspace/use-workspaces.ts b/apps/web/hooks/domains/workspace/use-workspaces.ts new file mode 100644 index 0000000000..624514699d --- /dev/null +++ b/apps/web/hooks/domains/workspace/use-workspaces.ts @@ -0,0 +1,45 @@ +"use client"; + +import { useEffect, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useAppStore } from "@/components/state-provider"; +import { workspacesQueryOptions } from "@/lib/query/query-options"; +import type { Workspace } from "@/lib/types/http"; + +const EMPTY_WORKSPACES: Workspace[] = []; + +export function useWorkspaces() { + const query = useQuery(workspacesQueryOptions()); + const items = query.data ?? EMPTY_WORKSPACES; + const activeId = useAppStore((state) => state.workspaces.activeId); + const setActiveWorkspace = useAppStore((state) => state.setActiveWorkspace); + const setActiveWorkflow = useAppStore((state) => state.setActiveWorkflow); + + useEffect(() => { + if (query.data === undefined) return; + const nextActiveId = resolveActiveWorkspaceId(items, activeId); + if (nextActiveId === activeId) return; + setActiveWorkspace(nextActiveId); + if (activeId && nextActiveId !== activeId) setActiveWorkflow(null); + }, [activeId, items, query.data, setActiveWorkflow, setActiveWorkspace]); + + const activeWorkspace = useMemo( + () => items.find((workspace) => workspace.id === activeId) ?? null, + [activeId, items], + ); + + return { + items, + activeId, + activeWorkspace, + isLoading: query.isLoading, + isFetching: query.isFetching, + query, + }; +} + +function resolveActiveWorkspaceId(items: Workspace[], activeId: string | null): string | null { + if (items.length === 0) return null; + if (activeId && items.some((workspace) => workspace.id === activeId)) return activeId; + return items[0].id; +} diff --git a/apps/web/hooks/use-drag-and-drop.ts b/apps/web/hooks/use-drag-and-drop.ts deleted file mode 100644 index 2441b70561..0000000000 --- a/apps/web/hooks/use-drag-and-drop.ts +++ /dev/null @@ -1,137 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { DragStartEvent, DragEndEvent } from "@dnd-kit/core"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; -import { useTaskActions } from "@/hooks/use-task-actions"; -import type { Task } from "@/components/kanban-card"; -import type { KanbanState } from "@/lib/state/slices"; - -export type MoveTaskError = { - message: string; - taskId: string; - sessionId: string | null; -}; - -export type DragAndDropOptions = { - visibleTasks: Task[]; - onMoveError?: (error: MoveTaskError) => void; -}; - -type UseDragAndDropRefsInput = Pick; - -/** Compute optimistic task list after a move. */ -function applyOptimisticMove( - tasks: KanbanState["tasks"], - taskId: string, - targetStepId: string, - nextPosition: number, -): KanbanState["tasks"] { - return tasks.map((t: KanbanState["tasks"][number]) => - t.id === taskId ? { ...t, workflowStepId: targetStepId, position: nextPosition } : t, - ); -} - -/** Calculate the next position in the target column. */ -function calcNextPosition( - tasks: KanbanState["tasks"], - taskId: string, - targetStepId: string, -): number { - return tasks - .filter( - (t: KanbanState["tasks"][number]) => t.workflowStepId === targetStepId && t.id !== taskId, - ) - .sort( - (a: KanbanState["tasks"][number], b: KanbanState["tasks"][number]) => a.position - b.position, - ).length; -} - -function useDragAndDropRefs({ onMoveError }: UseDragAndDropRefsInput) { - const onMoveErrorRef = useRef(onMoveError); - useEffect(() => { - onMoveErrorRef.current = onMoveError; - }, [onMoveError]); - return { onMoveErrorRef }; -} - -export function useDragAndDrop({ visibleTasks, onMoveError }: DragAndDropOptions) { - const [activeTaskId, setActiveTaskId] = useState(null); - const [isMovingTask, setIsMovingTask] = useState(false); - const { moveTaskById } = useTaskActions(); - const kanban = useAppStore((state) => state.kanban); - const store = useAppStoreApi(); - const { onMoveErrorRef } = useDragAndDropRefs({ onMoveError }); - const performTaskMove = useCallback( - async (task: Task, targetStepId: string) => { - const currentKanban = store.getState().kanban; - if (!currentKanban.workflowId) return; - const nextPosition = calcNextPosition(currentKanban.tasks, task.id, targetStepId); - const originalTasks = currentKanban.tasks; - store.getState().hydrate({ - kanban: { - ...currentKanban, - tasks: applyOptimisticMove(currentKanban.tasks, task.id, targetStepId, nextPosition), - }, - }); - try { - setIsMovingTask(true); - await moveTaskById(task.id, { - workflow_id: currentKanban.workflowId, - workflow_step_id: targetStepId, - position: nextPosition, - }); - } catch (error) { - store.getState().hydrate({ kanban: { ...store.getState().kanban, tasks: originalTasks } }); - const message = error instanceof Error ? error.message : "Failed to move task"; - onMoveErrorRef.current?.({ - message, - taskId: task.id, - sessionId: task.primarySessionId ?? null, - }); - } finally { - setIsMovingTask(false); - } - }, - [moveTaskById, onMoveErrorRef, store], - ); - const handleDragStart = (event: DragStartEvent) => { - setActiveTaskId(event.active.id as string); - }; - const handleDragEnd = useCallback( - async (event: DragEndEvent) => { - const { active, over } = event; - setActiveTaskId(null); - if (!over) return; - const taskId = active.id as string; - const targetStepId = over.id as string; - if (!kanban.workflowId || isMovingTask) return; - const movedTask = visibleTasks.find((t) => t.id === taskId); - if (!movedTask) return; - await performTaskMove(movedTask, targetStepId); - }, - [kanban.workflowId, isMovingTask, visibleTasks, performTaskMove], - ); - const handleDragCancel = () => { - setActiveTaskId(null); - }; - const moveTaskToStep = useCallback( - async (task: Task, targetStepId: string) => { - if (!kanban.workflowId || isMovingTask) return; - if (task.workflowStepId === targetStepId) return; - await performTaskMove(task, targetStepId); - }, - [kanban.workflowId, isMovingTask, performTaskMove], - ); - const activeTask = visibleTasks.find((task) => task.id === activeTaskId) ?? null; - - return { - activeTaskId, - activeTask, - isMovingTask, - handleDragStart, - handleDragEnd, - handleDragCancel, - moveTaskToStep, - }; -} diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts deleted file mode 100644 index e2ea77b14d..0000000000 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { renderHook, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { UserSettingsState } from "@/lib/state/slices/settings/types"; - -type PendingTaskCreateLastUsed = { - repositoryId?: string; - branch?: string; - agentProfileId?: string; - executorProfileId?: string; -}; - -const mockFetchUserSettings = vi.fn(); -const mockSetUserSettings = vi.fn((settings: UserSettingsState) => { - mockState.userSettings = settings; -}); -const mockReadQueuedTaskCreateLastUsedState = vi.fn<[], PendingTaskCreateLastUsed>(() => ({ - repositoryId: undefined, - branch: undefined, - agentProfileId: undefined, - executorProfileId: undefined, -})); - -type MockState = { - userSettings: UserSettingsState; - setUserSettings: typeof mockSetUserSettings; -}; - -let mockState: MockState; - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), -})); - -vi.mock("@/lib/api/domains/settings-api", () => ({ - fetchUserSettings: (...args: unknown[]) => mockFetchUserSettings(...args), -})); - -vi.mock("@/components/task-create-dialog-handlers", () => ({ - readQueuedTaskCreateLastUsedState: () => mockReadQueuedTaskCreateLastUsedState(), -})); - -import { - __resetEnsureUserSettingsForTests, - useEnsureUserSettings, -} from "./use-ensure-user-settings"; - -function makeUnloadedSettings(): UserSettingsState { - return { - workspaceId: null, - workflowId: null, - kanbanViewMode: null, - repositoryIds: [], - tasksListSort: "updated_desc", - tasksListGroup: "state", - preferredShell: null, - shellOptions: [], - defaultEditorId: null, - enablePreviewOnClick: false, - chatSubmitKey: "cmd_enter", - reviewAutoMarkOnScroll: true, - showReleaseNotification: true, - releaseNotesLastSeenVersion: null, - savedLayouts: [], - sidebarViews: [], - sidebarActiveViewId: null, - sidebarDraft: null, - sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} }, - taskCreateLastUsed: { - repositoryId: null, - branch: null, - agentProfileId: null, - executorProfileId: null, - }, - jiraSavedViews: undefined, - jiraTaskPresets: undefined, - githubSavedPresets: undefined, - githubDefaultQueryPresets: undefined, - gitlabSavedPresets: undefined, - defaultUtilityAgentId: null, - keyboardShortcuts: {}, - terminalLinkBehavior: "new_tab", - terminalFontFamily: null, - terminalFontSize: null, - changesPanelLayout: "tree", - systemMetricsDisplay: { showInTopbar: false }, - voiceMode: { - enabled: true, - engine: "auto", - language: "auto", - mode: "toggle", - autoSend: false, - whisperWebModel: "base", - }, - lspAutoStartLanguages: [], - lspAutoInstallLanguages: [], - lspServerConfigs: {}, - loaded: false, - }; -} - -function userSettingsResponse(taskCreateLastUsed = {}) { - return { - shell_options: [], - settings: { - task_create_last_used: taskCreateLastUsed, - }, - }; -} - -beforeEach(() => { - __resetEnsureUserSettingsForTests(); - vi.clearAllMocks(); - mockState = { - userSettings: makeUnloadedSettings(), - setUserSettings: mockSetUserSettings, - }; -}); - -describe("useEnsureUserSettings", () => { - it("fetches and stores user settings on first enabled mount", async () => { - mockFetchUserSettings.mockResolvedValue( - userSettingsResponse({ repository_id: "repo-1", branch: "main" }), - ); - - renderHook(() => useEnsureUserSettings(true)); - - await waitFor(() => expect(mockSetUserSettings).toHaveBeenCalled()); - expect(mockFetchUserSettings).toHaveBeenCalledWith({ cache: "no-store" }); - expect(mockSetUserSettings.mock.calls[0]![0].loaded).toBe(true); - expect(mockSetUserSettings.mock.calls[0]![0].taskCreateLastUsed).toMatchObject({ - repositoryId: "repo-1", - branch: "main", - }); - }); - - it("joins an in-flight settings fetch instead of starting a duplicate request", async () => { - let resolveFetch: (value: unknown) => void = () => undefined; - mockFetchUserSettings.mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - - renderHook(() => useEnsureUserSettings(true)); - renderHook(() => useEnsureUserSettings(true)); - - await waitFor(() => expect(mockFetchUserSettings).toHaveBeenCalledTimes(1)); - resolveFetch(userSettingsResponse()); - await waitFor(() => expect(mockSetUserSettings).toHaveBeenCalled()); - }); - - it("merges only defined queued task-create fields over fetched settings", async () => { - mockReadQueuedTaskCreateLastUsedState.mockReturnValue({ - repositoryId: undefined, - branch: undefined, - agentProfileId: "agent-2", - executorProfileId: undefined, - }); - mockFetchUserSettings.mockResolvedValue( - userSettingsResponse({ - repository_id: "repo-1", - branch: "main", - agent_profile_id: "agent-1", - executor_profile_id: "exec-profile-1", - }), - ); - - renderHook(() => useEnsureUserSettings(true)); - - await waitFor(() => expect(mockSetUserSettings).toHaveBeenCalled()); - expect(mockSetUserSettings.mock.calls[0]![0].taskCreateLastUsed).toEqual({ - repositoryId: "repo-1", - branch: "main", - agentProfileId: "agent-2", - executorProfileId: "exec-profile-1", - synced: true, - }); - }); - - it("preserves queued task-create fields when multiple callers join the same fetch", async () => { - let resolveFetch: (value: unknown) => void = () => undefined; - mockReadQueuedTaskCreateLastUsedState.mockReturnValue({ - repositoryId: undefined, - branch: "feature", - agentProfileId: undefined, - executorProfileId: undefined, - }); - mockFetchUserSettings.mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - - renderHook(() => useEnsureUserSettings(true)); - renderHook(() => useEnsureUserSettings(true)); - resolveFetch( - userSettingsResponse({ - repository_id: "repo-1", - branch: "main", - agent_profile_id: "agent-1", - }), - ); - - await waitFor(() => expect(mockSetUserSettings).toHaveBeenCalledTimes(2)); - expect(mockSetUserSettings.mock.calls.at(-1)![0].taskCreateLastUsed).toMatchObject({ - repositoryId: "repo-1", - branch: "feature", - agentProfileId: "agent-1", - }); - }); - - it("does not fetch while disabled", () => { - const { result } = renderHook(() => useEnsureUserSettings(false)); - - expect(mockFetchUserSettings).not.toHaveBeenCalled(); - expect(result.current.loaded).toBe(false); - }); - - it("settles a failed fetch for the current mount but retries on the next mount", async () => { - mockFetchUserSettings - .mockRejectedValueOnce(new Error("temporary")) - .mockResolvedValueOnce(userSettingsResponse({ repository_id: "repo-2" })); - - const first = renderHook(() => useEnsureUserSettings(true)); - - await waitFor(() => expect(first.result.current.loaded).toBe(true)); - expect(mockSetUserSettings).not.toHaveBeenCalled(); - first.unmount(); - - renderHook(() => useEnsureUserSettings(true)); - - await waitFor(() => expect(mockFetchUserSettings).toHaveBeenCalledTimes(2)); - await waitFor(() => expect(mockSetUserSettings).toHaveBeenCalled()); - expect(mockSetUserSettings.mock.calls[0]![0].taskCreateLastUsed.repositoryId).toBe("repo-2"); - }); -}); - -describe("useEnsureUserSettings — loaded settings overlay", () => { - it("applies queued task-create fields when settings are already loaded", () => { - mockState.userSettings = { - ...makeUnloadedSettings(), - loaded: true, - taskCreateLastUsed: { - repositoryId: "repo-1", - branch: "main", - agentProfileId: "agent-1", - executorProfileId: "exec-1", - }, - }; - mockReadQueuedTaskCreateLastUsedState.mockReturnValue({ - repositoryId: "repo-2", - branch: "feature", - agentProfileId: undefined, - executorProfileId: undefined, - }); - - const { result } = renderHook(() => useEnsureUserSettings(true)); - - expect(mockFetchUserSettings).not.toHaveBeenCalled(); - expect(result.current.userSettings.taskCreateLastUsed).toEqual({ - repositoryId: "repo-2", - branch: "feature", - agentProfileId: "agent-1", - executorProfileId: "exec-1", - }); - }); -}); - -describe("useEnsureUserSettings — stale settings fetches", () => { - it("keeps task-create edits made while a settings fetch is in flight", async () => { - let resolveFetch: (value: unknown) => void = () => undefined; - mockFetchUserSettings.mockReturnValue( - new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - - renderHook(() => useEnsureUserSettings(true)); - await waitFor(() => expect(mockFetchUserSettings).toHaveBeenCalledTimes(1)); - - mockReadQueuedTaskCreateLastUsedState.mockReturnValue({ - repositoryId: "repo-2", - branch: "feature", - agentProfileId: undefined, - executorProfileId: undefined, - }); - resolveFetch( - userSettingsResponse({ - repository_id: "repo-1", - branch: "main", - }), - ); - - await waitFor(() => expect(mockSetUserSettings).toHaveBeenCalled()); - expect(mockSetUserSettings.mock.calls[0]![0].taskCreateLastUsed).toMatchObject({ - repositoryId: "repo-2", - branch: "feature", - }); - }); -}); diff --git a/apps/web/hooks/use-ensure-user-settings.ts b/apps/web/hooks/use-ensure-user-settings.ts deleted file mode 100644 index 70d3eb0045..0000000000 --- a/apps/web/hooks/use-ensure-user-settings.ts +++ /dev/null @@ -1,105 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useAppStore } from "@/components/state-provider"; -import { readQueuedTaskCreateLastUsedState } from "@/components/task-create-dialog-handlers"; -import { fetchUserSettings } from "@/lib/api/domains/settings-api"; -import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; -import type { TaskCreateLastUsedState, UserSettingsState } from "@/lib/state/slices/settings/types"; - -type LoadedUserSettings = { - settings: UserSettingsState; -}; - -let userSettingsFetchPromise: Promise | null = null; - -function loadUserSettingsOnce() { - if (!userSettingsFetchPromise) { - userSettingsFetchPromise = fetchUserSettings({ cache: "no-store" }) - .then((response) => { - if (!response?.settings) return null; - const mapped = mapUserSettingsResponse(response); - return mapped.loaded ? { settings: mapped } : null; - }) - .catch(() => null) - .finally(() => { - userSettingsFetchPromise = null; - }); - } - return userSettingsFetchPromise; -} - -function mergeTaskCreateLastUsedOverlay( - settings: UserSettingsState, - pending: Partial, -): UserSettingsState { - const definedPending = compactTaskCreateLastUsedOverlay(pending); - if (Object.keys(definedPending).length === 0) return settings; - return { - ...settings, - taskCreateLastUsed: { - ...settings.taskCreateLastUsed, - ...definedPending, - }, - }; -} - -function compactTaskCreateLastUsedOverlay(pending: Partial) { - return Object.fromEntries( - Object.entries(pending).filter(([, value]) => value !== undefined), - ) as Partial; -} - -function mergeTaskCreateLastUsedForFetch(result: LoadedUserSettings): UserSettingsState { - return mergeTaskCreateLastUsedOverlay(result.settings, { - ...compactTaskCreateLastUsedOverlay(readQueuedTaskCreateLastUsedState()), - }); -} - -function mergeTaskCreateLastUsedForLoadedSettings(settings: UserSettingsState): UserSettingsState { - if (!settings.loaded) return settings; - return mergeTaskCreateLastUsedOverlay(settings, readQueuedTaskCreateLastUsedState()); -} - -export function __resetEnsureUserSettingsForTests() { - userSettingsFetchPromise = null; -} - -export function useEnsureUserSettings(enabled = true) { - const userSettings = useAppStore((state) => state.userSettings); - const setUserSettings = useAppStore((state) => state.setUserSettings); - const [fetchSettled, setFetchSettled] = useState(false); - - useEffect(() => { - if (!enabled) { - setFetchSettled(false); - return; - } - if (userSettings.loaded) { - setFetchSettled(true); - return; - } - let cancelled = false; - setFetchSettled(false); - loadUserSettingsOnce() - .then((result) => { - if (cancelled || !result) return; - const next = mergeTaskCreateLastUsedForFetch(result); - setUserSettings(next); - }) - .finally(() => { - if (!cancelled) setFetchSettled(true); - }); - - return () => { - cancelled = true; - }; - }, [enabled, setUserSettings, userSettings.loaded]); - - const effectiveUserSettings = mergeTaskCreateLastUsedForLoadedSettings(userSettings); - - return { - loaded: effectiveUserSettings.loaded || fetchSettled, - userSettings: effectiveUserSettings, - }; -} diff --git a/apps/web/hooks/use-inline-mention.ts b/apps/web/hooks/use-inline-mention.ts index c7de00659e..7048eeff69 100644 --- a/apps/web/hooks/use-inline-mention.ts +++ b/apps/web/hooks/use-inline-mention.ts @@ -1,6 +1,14 @@ "use client"; -import { useState, useCallback, useRef, useMemo, useEffect } from "react"; +import { + useState, + useCallback, + useRef, + useMemo, + useEffect, + type Dispatch, + type SetStateAction, +} from "react"; import { useCustomPrompts } from "@/hooks/domains/settings/use-custom-prompts"; import { getWebSocketClient } from "@/lib/ws/connection"; import { searchWorkspaceFiles } from "@/lib/ws/workspace-files"; @@ -36,6 +44,10 @@ type Position = { y: number; }; +type DetectionFrameRef = { + current: number | null; +}; + // Debounce delay for file search (ms) const FILE_SEARCH_DEBOUNCE = 300; @@ -48,6 +60,23 @@ function isValidMentionTrigger(text: string, pos: number): boolean { return charBefore === " " || charBefore === "\n" || charBefore === "\t"; } +function cancelDetectionFrame(ref: DetectionFrameRef): void { + if (ref.current === null) return; + cancelAnimationFrame(ref.current); + ref.current = null; +} + +function useResetMentionSelection( + itemCount: number, + setSelectedIndex: Dispatch>, +): void { + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + setSelectedIndex(0); + }, [itemCount, setSelectedIndex]); + /* eslint-enable react-hooks/set-state-in-effect */ +} + /** Exported for tests. */ export function filterItems(items: MentionItem[], query: string): MentionItem[] { if (!query) return items; @@ -259,21 +288,25 @@ function useMentionKeyboard({ switch (event.key) { case "ArrowDown": event.preventDefault(); + event.stopPropagation(); setSelectedIndex((prev) => Math.min(prev + 1, filteredItems.length - 1)); break; case "ArrowUp": event.preventDefault(); + event.stopPropagation(); setSelectedIndex((prev) => Math.max(prev - 1, 0)); break; case "Enter": case "Tab": if (filteredItems.length > 0) { event.preventDefault(); + event.stopPropagation(); handleSelect(filteredItems[selectedIndex]); } break; case "Escape": event.preventDefault(); + event.stopPropagation(); closeMenu(); break; } @@ -339,6 +372,7 @@ export function useInlineMention({ const [triggerStart, setTriggerStart] = useState(-1); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); + const detectionFrameRef = useRef(null); const { prompts } = useCustomPrompts(); const { fileResults, isLoading } = useFileSearch(sessionId, isOpen, query); @@ -352,10 +386,7 @@ export function useInlineMention({ promptInsertMode, }); - /* eslint-disable react-hooks/set-state-in-effect */ - useEffect(() => { - setSelectedIndex(0); - }, [filteredItems.length]); + useResetMentionSelection(filteredItems.length, setSelectedIndex); useEffect(() => { if (!isOpen || isLoading) return; @@ -365,12 +396,20 @@ export function useInlineMention({ }, [isOpen, isLoading, filteredItems.length, query.length]); /* eslint-enable react-hooks/set-state-in-effect */ + const cancelPendingDetection = useCallback(() => { + cancelDetectionFrame(detectionFrameRef); + }, []); + + useEffect(() => cancelPendingDetection, [cancelPendingDetection]); + const handleChange = useCallback( (newValue: string) => { onChange(newValue); const input = inputRef.current; if (!input) return; - requestAnimationFrame(() => { + cancelPendingDetection(); + detectionFrameRef.current = requestAnimationFrame(() => { + detectionFrameRef.current = null; const cursorPos = input.getSelectionStart(); const trigger = detectMentionTrigger(newValue, cursorPos); if (trigger) { @@ -388,12 +427,13 @@ export function useInlineMention({ } }); }, - [inputRef, isOpen, onChange], + [cancelPendingDetection, inputRef, isOpen, onChange], ); const closeMenu = useCallback(() => { + cancelPendingDetection(); clearMentionState(setIsOpen, setTriggerStart, setQuery); - }, []); + }, [cancelPendingDetection]); const handleSelect = useCallback( (item: MentionItem) => { diff --git a/apps/web/hooks/use-inline-slash.test.tsx b/apps/web/hooks/use-inline-slash.test.tsx new file mode 100644 index 0000000000..1215e32e72 --- /dev/null +++ b/apps/web/hooks/use-inline-slash.test.tsx @@ -0,0 +1,47 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { createRef } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { RichTextInputHandle } from "@/components/task/chat/rich-text-input"; +import { qk } from "@/lib/query/keys"; +import { useInlineSlash } from "./use-inline-slash"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +describe("useInlineSlash", () => { + afterEach(() => { + cleanup(); + }); + + it("reads available slash commands from TanStack Query without a Zustand store", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.sessionRuntime.availableCommands("session-1"), [ + { name: "pr-fixup", description: "Address PR review feedback" }, + { name: "internal", description: "Hidden (bundled)" }, + ]); + + const { result } = renderHook( + () => + useInlineSlash(createRef(), "", vi.fn(), { sessionId: "session-1" }), + { wrapper: wrapperFor(queryClient) }, + ); + + expect(result.current.commands.map((command) => command.label)).toEqual(["/pr-fixup"]); + }); +}); diff --git a/apps/web/hooks/use-inline-slash.ts b/apps/web/hooks/use-inline-slash.ts new file mode 100644 index 0000000000..a31b598e67 --- /dev/null +++ b/apps/web/hooks/use-inline-slash.ts @@ -0,0 +1,221 @@ +"use client"; + +import { useState, useCallback, useMemo, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import type { RichTextInputHandle } from "@/components/task/chat/rich-text-input"; +import { availableCommandsQueryOptions } from "@/lib/query/query-options"; + +export type SlashCommandAction = "agent"; + +export type SlashCommand = { + id: string; + label: string; + description: string; + action: SlashCommandAction; + agentCommandName?: string; +}; + +type Position = { + x: number; + y: number; +}; + +function isValidSlashTrigger(text: string, pos: number): boolean { + if (pos === 0) return true; + const charBefore = text[pos - 1]; + return charBefore === " " || charBefore === "\n" || charBefore === "\t"; +} + +/** Detect a /-trigger in text before cursor and return the query, or null if none. */ +function detectSlashTrigger( + text: string, + cursorPos: number, +): { triggerStart: number; query: string } | null { + const textBeforeCursor = text.substring(0, cursorPos); + const lastSlashIndex = textBeforeCursor.lastIndexOf("/"); + if (lastSlashIndex < 0 || !isValidSlashTrigger(text, lastSlashIndex)) return null; + const textAfterSlash = textBeforeCursor.substring(lastSlashIndex + 1); + if (/\s/.test(textAfterSlash) || !/^[\w-]*$/.test(textAfterSlash)) return null; + return { triggerStart: lastSlashIndex, query: textAfterSlash }; +} + +function filterCommands(query: string, allCommands: SlashCommand[]): SlashCommand[] { + if (!query) return allCommands; + const lowerQuery = query.toLowerCase(); + + return allCommands + .filter((cmd) => { + const label = cmd.label.toLowerCase(); + const cmdName = cmd.agentCommandName?.toLowerCase(); + return label.startsWith("/" + lowerQuery) || cmdName?.startsWith(lowerQuery); + }) + .sort((a, b) => { + const aName = a.agentCommandName?.toLowerCase(); + const bName = b.agentCommandName?.toLowerCase(); + const aStartsWithQuery = aName?.startsWith(lowerQuery) ?? false; + const bStartsWithQuery = bName?.startsWith(lowerQuery) ?? false; + if (aStartsWithQuery && !bStartsWithQuery) return -1; + if (!aStartsWithQuery && bStartsWithQuery) return 1; + return 0; + }); +} + +type SlashKeyboardParams = { + isOpen: boolean; + filteredCommands: SlashCommand[]; + selectedIndex: number; + setSelectedIndex: (v: number | ((prev: number) => number)) => void; + handleSelect: (cmd: SlashCommand) => void; + closeMenu: () => void; +}; + +function useSlashKeyboard({ + isOpen, + filteredCommands, + selectedIndex, + setSelectedIndex, + handleSelect, + closeMenu, +}: SlashKeyboardParams) { + return useCallback( + (event: React.KeyboardEvent) => { + if (!isOpen) return; + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setSelectedIndex((prev) => Math.min(prev + 1, filteredCommands.length - 1)); + break; + case "ArrowUp": + event.preventDefault(); + setSelectedIndex((prev) => Math.max(prev - 1, 0)); + break; + case "Enter": + case "Tab": + if (filteredCommands.length > 0) { + event.preventDefault(); + handleSelect(filteredCommands[selectedIndex]); + } + break; + case "Escape": + event.preventDefault(); + closeMenu(); + break; + } + }, + [isOpen, filteredCommands, selectedIndex, setSelectedIndex, handleSelect, closeMenu], + ); +} + +type UseInlineSlashOptions = { + sessionId?: string | null; + onAgentCommand?: (commandName: string) => void; +}; + +export function useInlineSlash( + inputRef: React.RefObject, + value: string, + onChange: (value: string) => void, + options?: UseInlineSlashOptions, +) { + const { sessionId, onAgentCommand } = options ?? {}; + const [isOpen, setIsOpen] = useState(false); + const [position, setPosition] = useState(null); + const [triggerStart, setTriggerStart] = useState(-1); + const [query, setQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + + const commandsQuery = useQuery(availableCommandsQueryOptions(sessionId ?? "")); + const agentCommands = commandsQuery.data; + + const allCommands = useMemo(() => { + if (!agentCommands || agentCommands.length === 0) return []; + return agentCommands + .filter((cmd) => { + const desc = cmd.description || ""; + return !desc.includes("(bundled)"); + }) + .map((cmd) => ({ + id: `agent-${cmd.name}`, + label: `/${cmd.name}`, + description: cmd.description || `Run /${cmd.name} command`, + action: "agent" as const, + agentCommandName: cmd.name, + })); + }, [agentCommands]); + + const filteredCommands = useMemo(() => filterCommands(query, allCommands), [query, allCommands]); + + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + setSelectedIndex(0); + }, [filteredCommands.length]); + /* eslint-enable react-hooks/set-state-in-effect */ + + const closeMenu = useCallback(() => { + setIsOpen(false); + setTriggerStart(-1); + setQuery(""); + }, []); + + const handleChange = useCallback( + (newValue: string) => { + onChange(newValue); + const input = inputRef.current; + if (!input) return; + requestAnimationFrame(() => { + const cursorPos = input.getSelectionStart(); + const trigger = detectSlashTrigger(newValue, cursorPos); + if (trigger) { + const caretRect = input.getCaretRect(); + if (caretRect) { + setPosition({ x: caretRect.x, y: caretRect.y }); + setTriggerStart(trigger.triggerStart); + setQuery(trigger.query); + setIsOpen(true); + return; + } + } + if (isOpen) closeMenu(); + }); + }, + [inputRef, isOpen, onChange, closeMenu], + ); + + const handleSelect = useCallback( + (command: SlashCommand) => { + const input = inputRef.current; + if (!input || triggerStart < 0) return; + const cursorPos = input.getSelectionStart(); + onChange(value.substring(0, triggerStart) + value.substring(cursorPos)); + if (command.agentCommandName && onAgentCommand) { + onAgentCommand(command.agentCommandName); + } + closeMenu(); + requestAnimationFrame(() => { + input.focus(); + }); + }, + [inputRef, triggerStart, value, onChange, onAgentCommand, closeMenu], + ); + + const handleKeyDown = useSlashKeyboard({ + isOpen, + filteredCommands, + selectedIndex, + setSelectedIndex, + handleSelect, + closeMenu, + }); + + return { + isOpen, + position, + commands: filteredCommands, + selectedIndex, + setSelectedIndex, + handleChange, + handleSelect, + handleKeyDown, + closeMenu, + }; +} diff --git a/apps/web/hooks/use-kanban-display-settings.ts b/apps/web/hooks/use-kanban-display-settings.ts index d2526ac660..36e89cc67c 100644 --- a/apps/web/hooks/use-kanban-display-settings.ts +++ b/apps/web/hooks/use-kanban-display-settings.ts @@ -2,8 +2,9 @@ import { useCallback } from "react"; import { useAppStore } from "@/components/state-provider"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useUserDisplaySettings } from "@/hooks/use-user-display-settings"; -import type { WorkflowsState } from "@/lib/state/slices"; +import { useWorkflows } from "@/hooks/use-workflows"; type UserSettingsFields = { workspaceId: string | null; @@ -20,21 +21,23 @@ function baseSettingsPayload(settings: UserSettingsFields): UserSettingsFields { }; } +function useWorkspaceSelectionData() { + const { items: workspaceItems, activeId } = useWorkspaces(); + const activeWorkflowId = useAppStore((state) => state.workflows.activeId); + return { workspaces: workspaceItems, activeWorkspaceId: activeId, activeWorkflowId }; +} + /** * Custom hook that consolidates all kanban display settings and eliminates prop drilling. * This hook provides access to workspaces, workflows, repositories, and preview settings, * along with handlers for changing these settings. */ export function useKanbanDisplaySettings() { - // Access store directly - const workspaces = useAppStore((state) => state.workspaces.items); - const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); - const workflows = useAppStore((state) => state.workflows.items); - const activeWorkflowId = useAppStore((state) => state.workflows.activeId); + const { workspaces, activeWorkspaceId, activeWorkflowId } = useWorkspaceSelectionData(); const setActiveWorkspace = useAppStore((state) => state.setActiveWorkspace); const setActiveWorkflow = useAppStore((state) => state.setActiveWorkflow); + const { workflows } = useWorkflows(activeWorkspaceId, Boolean(activeWorkspaceId)); - // Use existing compound hook for user settings const { settings: userSettings, commitSettings, @@ -46,11 +49,8 @@ export function useKanbanDisplaySettings() { workflowId: activeWorkflowId, }); - // Get preview setting from store const enablePreviewOnClick = useAppStore((state) => state.userSettings.enablePreviewOnClick); - // Use pushState instead of router.push to avoid triggering SSR re-fetches. - // Filter changes only update client state; all data is already available. const handleWorkspaceChange = useCallback( (nextWorkspaceId: string | null) => { setActiveWorkspace(nextWorkspaceId); @@ -70,7 +70,7 @@ export function useKanbanDisplaySettings() { setActiveWorkflow(nextWorkflowId); if (nextWorkflowId) { const workspaceId = workflows.find( - (workflow: WorkflowsState["items"][number]) => workflow.id === nextWorkflowId, + (workflow) => workflow.id === nextWorkflowId, )?.workspaceId; const workspaceParam = workspaceId ? `&workspaceId=${workspaceId}` : ""; window.history.pushState({}, "", `/?workflowId=${nextWorkflowId}${workspaceParam}`); @@ -116,7 +116,6 @@ export function useKanbanDisplaySettings() { ); return { - // Data workspaces, workflows, activeWorkspaceId, @@ -128,7 +127,6 @@ export function useKanbanDisplaySettings() { enablePreviewOnClick, kanbanViewMode: userSettings.kanbanViewMode, - // Handlers onWorkspaceChange: handleWorkspaceChange, onWorkflowChange: handleWorkflowChange, onRepositoryChange: handleRepositoryChange, diff --git a/apps/web/hooks/use-lazy-load-messages.ts b/apps/web/hooks/use-lazy-load-messages.ts index c4e87cb6fb..31db3ab7d7 100644 --- a/apps/web/hooks/use-lazy-load-messages.ts +++ b/apps/web/hooks/use-lazy-load-messages.ts @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef } from "react"; -import { listTaskSessionMessages } from "@/lib/api"; +import { useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; import { createDebugLogger } from "@/lib/debug/log"; +import { sessionMessagesQueryOptions } from "@/lib/query/query-options"; const debug = createDebugLogger("messages:lazyload"); @@ -59,6 +60,7 @@ function logLoadMoreResponse(args: LoadMoreResponseLog) { } export function useLazyLoadMessages(sessionId: string | null) { + const queryClient = useQueryClient(); // Use refs for values that should not trigger callback recreation const hasMore = useAppStore((state) => sessionId ? (state.messages.metaBySession[sessionId]?.hasMore ?? false) : false, @@ -99,10 +101,13 @@ export function useLazyLoadMessages(sessionId: string | null) { stateRef.current.isLoading = true; setMessagesMetadata(sessionId, { isLoading: true }); try { - const response = await listTaskSessionMessages(sessionId, { - limit: 20, - before: oldestCursor, - sort: "desc", + const response = await queryClient.fetchQuery({ + ...sessionMessagesQueryOptions(sessionId, { + limit: 20, + before: oldestCursor, + sort: "desc", + }), + staleTime: 0, }); const orderedMessages = [...(response.messages ?? [])].reverse(); // After reversing, orderedMessages[0] is the oldest message in this batch @@ -133,7 +138,7 @@ export function useLazyLoadMessages(sessionId: string | null) { setMessagesMetadata(sessionId, { isLoading: false }); return 0; } - }, [sessionId, prependMessages, setMessagesMetadata]); + }, [sessionId, prependMessages, queryClient, setMessagesMetadata]); return { loadMore, hasMore, isLoading }; } diff --git a/apps/web/hooks/use-message-handler.test.ts b/apps/web/hooks/use-message-handler.test.ts index 6205f494f8..32460592c7 100644 --- a/apps/web/hooks/use-message-handler.test.ts +++ b/apps/web/hooks/use-message-handler.test.ts @@ -1,24 +1,24 @@ import { describe, it, expect } from "vitest"; import { buildContextFilesContext, buildTaskMentionsContext } from "./use-message-handler"; -import type { AppState } from "@/lib/state/store"; import type { TaskMentionData } from "./use-inline-mention"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; const IMPROVE_HARNESS_PROMPT = "improve-harness"; const IMPROVE_HARNESS_CONTENT = "Review this session for durable harness improvements."; -function makeState(overrides: Partial = {}): AppState { - const base = { - kanban: { workflowId: "wf-1", steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - workflows: { items: [], activeId: null }, - tasks: { activeTaskId: null, activeSessionId: null, pinnedSessionId: null }, - } as unknown as AppState; - return { ...base, ...overrides } as AppState; +function snapshot(overrides: Partial = {}): WorkflowSnapshotData { + return { + workflowId: "wf-1", + workflowName: "Main flow", + steps: [], + tasks: [], + ...overrides, + }; } describe("buildTaskMentionsContext", () => { it("returns an empty string when no task mentions are supplied", () => { - expect(buildTaskMentionsContext([], makeState())).toBe(""); + expect(buildTaskMentionsContext([], {})).toBe(""); }); it("emits a kandev-system block with workflow_id / step / state for each task", () => { @@ -31,19 +31,13 @@ describe("buildTaskMentionsContext", () => { state: "in_progress", }, ]; - const state = makeState({ - kanban: { - workflowId: "wf-1", + const snapshots = { + "wf-1": snapshot({ steps: [{ id: "step-1", title: "Todo", color: "", position: 0 }], - tasks: [], - }, - workflows: { - items: [{ id: "wf-1", workspaceId: "ws-1", name: "Main flow" }], - activeId: "wf-1", - }, - } as unknown as Partial); + }), + }; - const out = buildTaskMentionsContext(tasks, state); + const out = buildTaskMentionsContext(tasks, snapshots); expect(out).toContain(""); expect(out).toContain( "- Implement auth (id: task-a, workflow_id: wf-1, step: Todo, state: in_progress)", @@ -61,7 +55,7 @@ describe("buildTaskMentionsContext", () => { state: null, }, ]; - const out = buildTaskMentionsContext(tasks, makeState()); + const out = buildTaskMentionsContext(tasks, {}); expect(out).toContain("workflow_id: wf-missing"); expect(out).toContain("step: Step"); expect(out).not.toContain(", state:"); @@ -77,14 +71,14 @@ describe("buildTaskMentionsContext", () => { state: "in_progress\nrm -rf", }, ]; - const out = buildTaskMentionsContext(tasks, makeState()); - // Only the wrapping opening/closing tags should remain — interpolated + const out = buildTaskMentionsContext(tasks, {}); + // Only the wrapping opening/closing tags should remain; interpolated // strings must not be able to introduce extra markers // or terminate the block early. expect(out.match(//g)).toHaveLength(1); expect(out.match(/<\/kandev-system>/g)).toHaveLength(1); - // Newlines from interpolated values must not survive (they're the - // primary vector for closing the block). + // Newlines from interpolated values must not survive; they're the + // primary vector for closing the block. const innerLines = out.split("\n").filter((l) => l.startsWith("- ")); expect(innerLines).toHaveLength(1); // The sanitised data still surfaces, just with hostile chars neutered. @@ -92,7 +86,7 @@ describe("buildTaskMentionsContext", () => { expect(out).toContain("wf- bad "); }); - it("resolves step titles from kanbanMulti snapshots when not in current workflow", () => { + it("resolves step titles from workflow snapshot Query caches", () => { const tasks: TaskMentionData[] = [ { taskId: "task-d", @@ -102,21 +96,15 @@ describe("buildTaskMentionsContext", () => { state: "todo", }, ]; - const state = makeState({ - kanbanMulti: { - snapshots: { - "wf-2": { - workflowId: "wf-2", - workflowName: "Other flow", - steps: [{ id: "step-9", title: "Review", color: "", position: 0 }], - tasks: [], - }, - }, - isLoading: false, - }, - } as unknown as Partial); + const snapshots = { + "wf-2": snapshot({ + workflowId: "wf-2", + workflowName: "Other flow", + steps: [{ id: "step-9", title: "Review", color: "", position: 0 }], + }), + }; - const out = buildTaskMentionsContext(tasks, state); + const out = buildTaskMentionsContext(tasks, snapshots); expect(out).toContain("workflow_id: wf-2"); expect(out).toContain("step: Review"); }); diff --git a/apps/web/hooks/use-message-handler.ts b/apps/web/hooks/use-message-handler.ts index b48d980190..50c563becd 100644 --- a/apps/web/hooks/use-message-handler.ts +++ b/apps/web/hooks/use-message-handler.ts @@ -1,7 +1,8 @@ import { useCallback } from "react"; import { getWebSocketClient } from "@/lib/ws/connection"; -import { useAppStoreApi } from "@/components/state-provider"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useQueue } from "./domains/session/use-queue"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import type { MessageAttachment } from "@/components/task/chat/chat-input-container"; import type { ActiveDocument } from "@/lib/state/slices/ui/types"; import type { PlanComment } from "@/lib/state/slices/comments"; @@ -9,7 +10,7 @@ import { toBlockquote } from "@/lib/state/slices/comments/format"; import type { ContextFile } from "@/lib/state/context-files-store"; import type { CustomPrompt, Message } from "@/lib/types/http"; import type { TaskMentionData } from "@/hooks/use-inline-mention"; -import type { AppState } from "@/lib/state/store"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import { collectPromptReferenceExpansions, formatPromptReferenceExpansions, @@ -44,10 +45,10 @@ function buildDocumentContext( return `\n\n\nACTIVE DOCUMENT: The user is editing "${activeDocument.name}" (${activeDocument.path}) side-by-side with this chat.\nRead this file to understand the context before responding.\n`; } -function resolveStepTitle(stepId: string, state: AppState): string { - const step = state.kanban.steps.find((s) => s.id === stepId); - if (step) return step.title; - for (const snap of Object.values(state.kanbanMulti.snapshots)) { +type WorkflowSnapshots = Record; + +function resolveStepTitle(stepId: string, snapshots: WorkflowSnapshots): string { + for (const snap of Object.values(snapshots)) { const found = (snap.steps ?? []).find((s) => s.id === stepId); if (found) return found.title; } @@ -55,17 +56,20 @@ function resolveStepTitle(stepId: string, state: AppState): string { } // Strips characters that could break out of the block when -// task strings are interpolated verbatim — newlines (close-tag injection) +// task strings are interpolated verbatim: newlines (close-tag injection) // and angle brackets. Task titles can come from Jira/Linear sync or other // users in a shared workspace, so the data is not trusted. function sanitizeForPrompt(value: string): string { return value.replace(/[\r\n<>]/g, " "); } -export function buildTaskMentionsContext(tasks: TaskMentionData[], state: AppState): string { +export function buildTaskMentionsContext( + tasks: TaskMentionData[], + snapshots: WorkflowSnapshots, +): string { if (tasks.length === 0) return ""; const lines = tasks.map((t) => { - const stepTitle = resolveStepTitle(t.workflowStepId, state); + const stepTitle = resolveStepTitle(t.workflowStepId, snapshots); const title = sanitizeForPrompt(t.title); const taskId = sanitizeForPrompt(t.taskId); const workflowId = sanitizeForPrompt(t.workflowId); @@ -200,6 +204,8 @@ export function useMessageHandler({ prompts = [], }: UseMessageHandlerParams) { const { queue } = useQueue(resolvedSessionId); + const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); + const { snapshots } = useAllWorkflowSnapshots(activeWorkspaceId); const storeApi = useAppStoreApi(); const buildFinalMessage = useCallback( @@ -208,14 +214,14 @@ export function useMessageHandler({ const documentContext = buildDocumentContext(activeDocument, planModeEnabled, planComments); const contextFilesContext = buildContextFilesContext(allContextFiles, prompts); const taskMentionsContext = inlineTaskMentions?.length - ? buildTaskMentionsContext(inlineTaskMentions, storeApi.getState()) + ? buildTaskMentionsContext(inlineTaskMentions, snapshots) : ""; return { finalMessage: message.trim() + documentContext + contextFilesContext + taskMentionsContext, allContextFiles, }; }, - [contextFiles, activeDocument, planModeEnabled, planComments, prompts, storeApi], + [contextFiles, activeDocument, planModeEnabled, planComments, prompts, snapshots], ); const handleSendMessage = useCallback( diff --git a/apps/web/hooks/use-office-refetch.ts b/apps/web/hooks/use-office-refetch.ts deleted file mode 100644 index 4afcb0654b..0000000000 --- a/apps/web/hooks/use-office-refetch.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useLayoutEffect, useRef } from "react"; -import { useAppStore } from "@/components/state-provider"; - -/** - * Calls `onRefetch` when the office refetch trigger matches `triggerType`. - * Supports exact match ("dashboard") or prefix match ("comments:" matches "comments:task-123"). - * - * @param triggerType - The trigger type to watch for (e.g. "dashboard", "tasks", "comments") - * @param onRefetch - Callback invoked when a matching trigger fires - */ -export function useOfficeRefetch(triggerType: string, onRefetch: () => void) { - const trigger = useAppStore((s) => s.office.refetchTrigger); - const callbackRef = useRef(onRefetch); - // Update ref in a layout effect to avoid mutating during render - useLayoutEffect(() => { - callbackRef.current = onRefetch; - }); - - useEffect(() => { - if (!trigger) return; - const matches = trigger.type === triggerType || trigger.type.startsWith(triggerType + ":"); - if (matches) { - callbackRef.current(); - } - }, [trigger, triggerType]); -} diff --git a/apps/web/hooks/use-optimistic-task-mutation.test.tsx b/apps/web/hooks/use-optimistic-task-mutation.test.tsx index 8bf20dba52..b4bb1d4935 100644 --- a/apps/web/hooks/use-optimistic-task-mutation.test.tsx +++ b/apps/web/hooks/use-optimistic-task-mutation.test.tsx @@ -1,13 +1,12 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, render } from "@testing-library/react"; import type { ReactNode } from "react"; -import { StateProvider, useAppStoreApi } from "@/components/state-provider"; +import { StateProvider } from "@/components/state-provider"; import { TaskOptimisticContextProvider, useOptimisticTaskMutation, } from "./use-optimistic-task-mutation"; import type { Task } from "@/app/office/tasks/[id]/types"; -import type { OfficeTask } from "@/lib/state/slices/office/types"; vi.mock("sonner", () => ({ toast: { @@ -44,18 +43,7 @@ const baseTask: Task = { updatedAt: TS, }; -const baseOfficeTask: OfficeTask = { - id: "t-1", - workspaceId: "ws-1", - identifier: "TASK-1", - title: "First task", - status: "todo", - priority: "medium", - createdAt: TS, - updatedAt: TS, -}; - -function makeHarness(initialTask: Task, initialOffice: OfficeTask | null) { +function makeHarness(initialTask: Task) { const state = { task: initialTask, patches: [] as Partial[], @@ -72,19 +60,10 @@ function makeHarness(initialTask: Task, initialOffice: OfficeTask | null) { state.task = snapshot; }, }; - function StoreSeed({ children }: { children: ReactNode }) { - const api = useAppStoreApi(); - if (initialOffice) { - api.getState().setTasks([initialOffice]); - } - return <>{children}; - } function Wrapper({ children }: { children: ReactNode }) { return ( - - {children} - + {children} ); } @@ -103,7 +82,7 @@ function HookProbe({ describe("useOptimisticTaskMutation", () => { it("applies the patch and keeps it on success", async () => { - const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask); + const { Wrapper, state } = makeHarness(baseTask); let mutate: ReturnType | null = null; render( @@ -122,7 +101,7 @@ describe("useOptimisticTaskMutation", () => { }); it("rolls back local state and toasts on api failure", async () => { - const { Wrapper, state } = makeHarness(baseTask, baseOfficeTask); + const { Wrapper, state } = makeHarness(baseTask); let mutate: ReturnType | null = null; render( @@ -140,7 +119,7 @@ describe("useOptimisticTaskMutation", () => { }); it("uses a generic error message when the rejection isn't an Error", async () => { - const { Wrapper } = makeHarness(baseTask, baseOfficeTask); + const { Wrapper } = makeHarness(baseTask); let mutate: ReturnType | null = null; render( @@ -154,8 +133,8 @@ describe("useOptimisticTaskMutation", () => { expect(toast.error).toHaveBeenCalledWith("Update failed"); }); - it("works when the office store has no entry for the task", async () => { - const { Wrapper, state } = makeHarness(baseTask, null); + it("works with local task state only", async () => { + const { Wrapper, state } = makeHarness(baseTask); let mutate: ReturnType | null = null; render( diff --git a/apps/web/hooks/use-optimistic-task-mutation.ts b/apps/web/hooks/use-optimistic-task-mutation.ts index c2694412dc..78423576a6 100644 --- a/apps/web/hooks/use-optimistic-task-mutation.ts +++ b/apps/web/hooks/use-optimistic-task-mutation.ts @@ -2,14 +2,12 @@ import { createContext, useCallback, useContext } from "react"; import { toast } from "sonner"; -import { useAppStoreApi } from "@/components/state-provider"; import type { Task } from "@/app/office/tasks/[id]/types"; -import type { OfficeTask } from "@/lib/state/slices/office/types"; /** - * Context for the local (page-level) task representation. The office store - * holds the canonical OfficeTask but the detail page maintains a richer - * Task object with extra fields (reviewers, approvers, blockedBy, etc.). + * Context for the local (page-level) task representation. The detail page + * maintains a richer Task object with extra fields (reviewers, approvers, + * blockedBy, etc.). * * Pickers live inside on the detail page; the * provider exposes a way to patch / restore the local task state so @@ -35,14 +33,12 @@ export function useTaskOptimisticContext(): TaskOptimisticContextValue { /** * Returns a function that performs an optimistic mutation on the current - * task. Snapshots the local + store state, applies the patch immediately, - * runs the API call, and rolls back + toasts on failure. On success the - * optimistic patch is left in place; the canonical reconciliation happens - * via the `office.task.updated` WS handler (re-fetches the task DTO). + * task. Snapshots local state, applies the patch immediately, runs the API + * call, and rolls back + toasts on failure. On success the optimistic patch + * is left in place; canonical reconciliation happens via Query invalidation. */ export function useOptimisticTaskMutation() { const ctx = useTaskOptimisticContext(); - const storeApi = useAppStoreApi(); return useCallback( async ( @@ -51,46 +47,20 @@ export function useOptimisticTaskMutation() { apiCall: () => Promise, ): Promise => { const snapshot = ctx.task; - const storePatch = toOfficeTaskPatch(patch); - const storeSnapshot = storeApi.getState().office.tasks.items.find((t) => t.id === taskId); - // Apply optimistic patches (local + store). + // Keep taskId in the signature for callers that already bind the task being mutated. + void taskId; ctx.applyPatch(patch); - if (storeSnapshot) { - storeApi.getState().patchTaskInStore(taskId, storePatch); - } try { await apiCall(); } catch (err) { - // Rollback both layers. ctx.restore(snapshot); - if (storeSnapshot) { - storeApi.getState().patchTaskInStore(taskId, storeSnapshot); - } const message = err instanceof Error ? err.message : "Update failed"; toast.error(message); throw err; } }, - [ctx, storeApi], + [ctx], ); } - -/** - * Maps a Task patch to the subset of fields that exist on OfficeTask, so we - * can keep both the local and store representations in sync. - */ -function toOfficeTaskPatch(patch: Partial): Partial { - const out: Partial = {}; - if (patch.status !== undefined) out.status = patch.status; - if (patch.priority !== undefined) out.priority = patch.priority; - if (patch.assigneeAgentProfileId !== undefined) { - out.assigneeAgentProfileId = patch.assigneeAgentProfileId; - } - if (patch.projectId !== undefined) out.projectId = patch.projectId; - if (patch.parentId !== undefined) out.parentId = patch.parentId; - if (patch.labels !== undefined) out.labels = patch.labels; - if (patch.blockedBy !== undefined) out.blockedBy = patch.blockedBy; - return out; -} diff --git a/apps/web/hooks/use-plan-panel-auto-open.test.ts b/apps/web/hooks/use-plan-panel-auto-open.test.ts index d255e6e018..c59c7feb38 100644 --- a/apps/web/hooks/use-plan-panel-auto-open.test.ts +++ b/apps/web/hooks/use-plan-panel-auto-open.test.ts @@ -1,11 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { TaskPlan } from "@/lib/types/http"; const mockAddPlanPanel = vi.fn(); const mockGetPanel = vi.fn(); -const mockSetTaskPlan = vi.fn(); -const mockSetTaskPlanLoading = vi.fn(); +const mockHydrateTaskPlanLastSeen = vi.fn(); const mockMarkTaskPlanSeen = vi.fn(); const mockGetTaskPlan = vi.fn(); @@ -29,21 +32,17 @@ function buildState() { return { tasks: { activeTaskId: mockActiveTaskId }, taskPlans: { - byTaskId: mockActiveTaskId && mockPlan ? { [mockActiveTaskId]: mockPlan } : {}, - loadedByTaskId: mockActiveTaskId ? { [mockActiveTaskId]: mockIsLoaded } : {}, lastSeenUpdatedAtByTaskId: mockActiveTaskId && mockLastSeen !== undefined ? { [mockActiveTaskId]: mockLastSeen } : {}, }, connection: { status: mockConnectionStatus }, - setTaskPlan: mockSetTaskPlan, - setTaskPlanLoading: mockSetTaskPlanLoading, + hydrateTaskPlanLastSeen: mockHydrateTaskPlanLastSeen, markTaskPlanSeen: mockMarkTaskPlanSeen, }; } vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: Record) => unknown) => selector(buildState()), - useAppStoreApi: () => ({ getState: () => buildState() }), })); vi.mock("@/lib/state/dockview-store", () => ({ @@ -56,7 +55,13 @@ vi.mock("@/lib/state/dockview-store", () => ({ })); vi.mock("@/lib/api/domains/plan-api", () => ({ + createTaskPlan: vi.fn(), + deleteTaskPlan: vi.fn(), + getPlanRevision: vi.fn(), getTaskPlan: (...args: unknown[]) => mockGetTaskPlan(...args), + listPlanRevisions: vi.fn(), + revertPlanRevision: vi.fn(), + updateTaskPlan: vi.fn(), })); import { usePlanPanelAutoOpen } from "./use-plan-panel-auto-open"; @@ -76,6 +81,16 @@ function agentPlan(updated_at = TS): TaskPlan { }; } +function renderPlanPanelAutoOpen() { + const client = makeQueryClient(); + if (mockActiveTaskId && mockIsLoaded) { + client.setQueryData(qk.taskPlan.detail(mockActiveTaskId), mockPlan); + } + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); + return { ...renderHook(() => usePlanPanelAutoOpen(), { wrapper }), client }; +} + describe("usePlanPanelAutoOpen", () => { beforeEach(() => { vi.clearAllMocks(); @@ -92,50 +107,50 @@ describe("usePlanPanelAutoOpen", () => { }); it("opens plan panel for unseen agent plan", () => { - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockAddPlanPanel).toHaveBeenCalledWith({ quiet: true, inCenter: true }); }); it("does not open when isRestoringLayout is true", () => { mockIsRestoringLayout = true; - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockAddPlanPanel).not.toHaveBeenCalled(); }); it("does not open when api is null", () => { mockApi = null; - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockAddPlanPanel).not.toHaveBeenCalled(); }); it("does not open when plan created_by is user", () => { mockPlan = { ...agentPlan(), created_by: "user" }; - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockAddPlanPanel).not.toHaveBeenCalled(); }); it("does not open when plan is already seen (lastSeen === updated_at)", () => { mockLastSeen = TS; - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockAddPlanPanel).not.toHaveBeenCalled(); }); it("opens again when plan is updated after being seen", () => { mockLastSeen = TS; mockPlan = agentPlan(TS_LATER); - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockAddPlanPanel).toHaveBeenCalledWith({ quiet: true, inCenter: true }); }); it("does not open when plan panel already exists in layout", () => { mockGetPanel.mockReturnValue({ id: "plan" }); - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockAddPlanPanel).not.toHaveBeenCalled(); }); it("does not open when plan is null", () => { mockPlan = null; - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockAddPlanPanel).not.toHaveBeenCalled(); }); }); @@ -158,15 +173,14 @@ describe("usePlanPanelAutoOpen — eager fetch", () => { it("eagerly fetches the plan when not yet loaded", () => { mockIsLoaded = false; mockPlan = null; - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockGetTaskPlan).toHaveBeenCalledWith("task-1"); - expect(mockSetTaskPlanLoading).toHaveBeenCalledWith("task-1", true); }); it("does not fetch when WS is disconnected", () => { mockIsLoaded = false; mockConnectionStatus = "connecting"; - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockGetTaskPlan).not.toHaveBeenCalled(); }); @@ -175,8 +189,8 @@ describe("usePlanPanelAutoOpen — eager fetch", () => { mockActivePanelId = "plan"; mockApi = makeApi(); mockLastSeen = undefined; - renderHook(() => usePlanPanelAutoOpen()); - expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1"); + renderPlanPanelAutoOpen(); + expect(mockMarkTaskPlanSeen).toHaveBeenCalledWith("task-1", TS); expect(mockAddPlanPanel).not.toHaveBeenCalled(); }); @@ -185,7 +199,7 @@ describe("usePlanPanelAutoOpen — eager fetch", () => { mockActivePanelId = "chat"; mockApi = makeApi(); mockLastSeen = undefined; - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockMarkTaskPlanSeen).not.toHaveBeenCalled(); expect(mockAddPlanPanel).not.toHaveBeenCalled(); }); @@ -194,7 +208,7 @@ describe("usePlanPanelAutoOpen — eager fetch", () => { mockGetPanel.mockReturnValue({ id: "plan" }); mockLastSeen = TS; mockPlan = agentPlan(TS_LATER); - renderHook(() => usePlanPanelAutoOpen()); + renderPlanPanelAutoOpen(); expect(mockMarkTaskPlanSeen).not.toHaveBeenCalled(); expect(mockAddPlanPanel).not.toHaveBeenCalled(); }); @@ -202,7 +216,7 @@ describe("usePlanPanelAutoOpen — eager fetch", () => { it("does not acknowledge a panel it just auto-opened when the eager fetch re-applies the plan", () => { // First render: panel doesn't exist yet, so we auto-open it. mockGetPanel.mockReturnValue(null); - const { rerender } = renderHook(() => usePlanPanelAutoOpen()); + const { client, rerender } = renderPlanPanelAutoOpen(); expect(mockAddPlanPanel).toHaveBeenCalledTimes(1); // The eager getTaskPlan self-heal resolves after the WS push and @@ -212,6 +226,7 @@ describe("usePlanPanelAutoOpen — eager fetch", () => { // mark the plan seen — that would suppress the indicator the user expects. mockGetPanel.mockReturnValue({ id: "plan" }); mockPlan = agentPlan(); + client.setQueryData(qk.taskPlan.detail("task-1"), mockPlan); rerender(); expect(mockMarkTaskPlanSeen).not.toHaveBeenCalled(); @@ -227,7 +242,7 @@ describe("usePlanPanelAutoOpen — eager fetch", () => { rejectFn = reject; }), ); - const { rerender } = renderHook(() => usePlanPanelAutoOpen()); + const { rerender } = renderPlanPanelAutoOpen(); expect(mockGetTaskPlan).toHaveBeenCalledTimes(1); rejectFn(new Error("boom")); await new Promise((r) => setTimeout(r, 0)); @@ -262,13 +277,15 @@ describe("usePlanPanelAutoOpen — race guards", () => { resolveFn = resolve; }), ); - renderHook(() => usePlanPanelAutoOpen()); + const { client } = renderPlanPanelAutoOpen(); // WS delivers the latest plan while the HTTP fetch is in flight. - mockPlan = agentPlan(TS_LATER); + client.setQueryData(qk.taskPlan.detail("task-1"), agentPlan(TS_LATER)); // HTTP resolves with an older snapshot of the same plan. resolveFn(agentPlan(TS)); await new Promise((r) => setTimeout(r, 0)); - expect(mockSetTaskPlan).not.toHaveBeenCalled(); + expect(client.getQueryData(qk.taskPlan.detail("task-1"))).toMatchObject({ + updated_at: TS_LATER, + }); }); it("does not overwrite a WS-delivered plan when the fetch resolves with null", async () => { @@ -281,12 +298,14 @@ describe("usePlanPanelAutoOpen — race guards", () => { resolveFn = resolve; }), ); - renderHook(() => usePlanPanelAutoOpen()); - // Simulate a WS event populating the store while the HTTP fetch is in flight. - mockPlan = agentPlan(); + const { client } = renderPlanPanelAutoOpen(); + // Simulate a WS event populating the query cache while the HTTP fetch is in flight. + client.setQueryData(qk.taskPlan.detail("task-1"), agentPlan()); resolveFn(null); await new Promise((r) => setTimeout(r, 0)); - expect(mockSetTaskPlan).not.toHaveBeenCalled(); + expect(client.getQueryData(qk.taskPlan.detail("task-1"))).toMatchObject({ + updated_at: TS, + }); }); it("retries the eager fetch after WS reconnects following a failure", async () => { @@ -295,7 +314,7 @@ describe("usePlanPanelAutoOpen — race guards", () => { mockGetTaskPlan.mockRejectedValueOnce(new Error("boom")); mockGetTaskPlan.mockResolvedValueOnce(null); - const { rerender } = renderHook(() => usePlanPanelAutoOpen()); + const { rerender } = renderPlanPanelAutoOpen(); expect(mockGetTaskPlan).toHaveBeenCalledTimes(1); await new Promise((r) => setTimeout(r, 0)); @@ -306,6 +325,7 @@ describe("usePlanPanelAutoOpen — race guards", () => { // WS reconnect — fetches again mockConnectionStatus = "connected"; rerender(); + await new Promise((r) => setTimeout(r, 0)); expect(mockGetTaskPlan).toHaveBeenCalledTimes(2); }); }); diff --git a/apps/web/hooks/use-plan-panel-auto-open.ts b/apps/web/hooks/use-plan-panel-auto-open.ts index fc2d26a5dc..7a5a3f9665 100644 --- a/apps/web/hooks/use-plan-panel-auto-open.ts +++ b/apps/web/hooks/use-plan-panel-auto-open.ts @@ -1,8 +1,11 @@ "use client"; import { useEffect, useRef } from "react"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useAppStore } from "@/components/state-provider"; import { useDockviewStore } from "@/lib/state/dockview-store"; +import { taskPlanQueryOptions } from "@/lib/query/query-options"; +import type { TaskPlan } from "@/lib/types/http"; import { getTaskPlan } from "@/lib/api/domains/plan-api"; /** @@ -17,19 +20,20 @@ import { getTaskPlan } from "@/lib/api/domains/plan-api"; * orderings. */ export function usePlanPanelAutoOpen() { + const queryClient = useQueryClient(); const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); - const plan = useAppStore((s) => (activeTaskId ? s.taskPlans.byTaskId[activeTaskId] : null)); - const isLoaded = useAppStore((s) => - activeTaskId ? (s.taskPlans.loadedByTaskId[activeTaskId] ?? false) : false, - ); + const planQuery = useQuery({ + ...taskPlanQueryOptions(activeTaskId ?? "", false), + enabled: false, + }); + const plan = planQuery.data ?? null; + const isLoaded = planQuery.isSuccess; const lastSeen = useAppStore((s) => activeTaskId ? s.taskPlans.lastSeenUpdatedAtByTaskId[activeTaskId] : undefined, ); - const setTaskPlan = useAppStore((s) => s.setTaskPlan); - const setTaskPlanLoading = useAppStore((s) => s.setTaskPlanLoading); + const hydrateTaskPlanLastSeen = useAppStore((s) => s.hydrateTaskPlanLastSeen); const markTaskPlanSeen = useAppStore((s) => s.markTaskPlanSeen); const connectionStatus = useAppStore((s) => s.connection.status); - const storeApi = useAppStoreApi(); const api = useDockviewStore((s) => s.api); const isRestoringLayout = useDockviewStore((s) => s.isRestoringLayout); const addPlanPanel = useDockviewStore((s) => s.addPlanPanel); @@ -38,6 +42,16 @@ export function usePlanPanelAutoOpen() { // doesn't put us in an infinite retry loop. Cleared on WS disconnect so a // reconnect can retry any failed or not-yet-attempted tasks. const attemptedRef = useRef>(new Set()); + const prevConnectionStatusRef = useRef(connectionStatus); + const allowRetryAfterReconnectRef = useRef(false); + if (prevConnectionStatusRef.current !== connectionStatus) { + if (connectionStatus !== "connected") { + attemptedRef.current.clear(); + } else if (prevConnectionStatusRef.current !== "connected") { + allowRetryAfterReconnectRef.current = true; + } + prevConnectionStatusRef.current = connectionStatus; + } // Whether *this* hook added the plan panel for the current task. Lets the // reload-heal branch below tell "panel restored from a saved layout" (heal) @@ -45,39 +59,52 @@ export function usePlanPanelAutoOpen() { const addedPlanPanelRef = useRef(false); useEffect(() => { addedPlanPanelRef.current = false; - }, [activeTaskId]); + if (activeTaskId) hydrateTaskPlanLastSeen(activeTaskId); + }, [activeTaskId, hydrateTaskPlanLastSeen]); // Eagerly fetch the plan on task load. The Plan panel mounts `useTaskPlan` // only after the panel exists, so without this fetch a plan written by the // agent before the browser's WS connected (fast auto-start path) would never // populate the store and the auto-open below would never fire. useEffect(() => { - if (!activeTaskId || connectionStatus !== "connected") return; + if (connectionStatus !== "connected") { + attemptedRef.current.clear(); + return; + } + if (!activeTaskId) return; if (isLoaded) return; - if (attemptedRef.current.has(activeTaskId)) return; + if (attemptedRef.current.has(activeTaskId) && !allowRetryAfterReconnectRef.current) return; + const isReconnectRetry = allowRetryAfterReconnectRef.current; + allowRetryAfterReconnectRef.current = false; const taskId = activeTaskId; + const options = taskPlanQueryOptions(taskId); + if (isReconnectRetry) { + queryClient.removeQueries({ exact: true, queryKey: options.queryKey }); + } attemptedRef.current.add(taskId); - setTaskPlanLoading(taskId, true); getTaskPlan(taskId) .then((fetched) => { - // Race guard: if a WS event populated the store while our HTTP + // Race guard: if a WS event populated the query cache while our HTTP // request was in flight, don't overwrite a real plan with a // stale response — neither a `null` (server didn't have it yet // at fetch time) nor an older non-null version (HTTP saw an // earlier write than the WS event we already applied). - const live = storeApi.getState().taskPlans.byTaskId[taskId]; - if (live) { - if (fetched === null) return; - if (Date.parse(fetched.updated_at) < Date.parse(live.updated_at)) return; + const livePlan = queryClient.getQueryData(options.queryKey); + if (livePlan && fetched === null) return; + if ( + livePlan && + fetched && + Date.parse(fetched.updated_at) < Date.parse(livePlan.updated_at) + ) { + return; } - setTaskPlan(taskId, fetched); + queryClient.setQueryData(options.queryKey, fetched); }) .catch(() => { /* swallow — the disconnect/reconnect effect clears `attemptedRef` * so a transient failure retries automatically after recovery. */ - }) - .finally(() => setTaskPlanLoading(taskId, false)); - }, [activeTaskId, connectionStatus, isLoaded, setTaskPlan, setTaskPlanLoading, storeApi]); + }); + }, [activeTaskId, connectionStatus, isLoaded, queryClient]); // Clear the attempt set on WS disconnect so that when the WS reconnects // the fetch effect can retry any tasks that previously failed or were pending. @@ -109,7 +136,7 @@ export function usePlanPanelAutoOpen() { !addedPlanPanelRef.current && api.activePanel?.id === planPanel.id ) { - markTaskPlanSeen(plan.task_id); + markTaskPlanSeen(plan.task_id, plan.updated_at); } return; } diff --git a/apps/web/hooks/use-session-layout-state.test.ts b/apps/web/hooks/use-session-layout-state.test.ts index c1bbc14983..242ed2598a 100644 --- a/apps/web/hooks/use-session-layout-state.test.ts +++ b/apps/web/hooks/use-session-layout-state.test.ts @@ -1,12 +1,28 @@ -import { renderHook } from "@testing-library/react"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { sessionId as toSessionId, taskId as toTaskId, type TaskSession } from "@/lib/types/http"; +import { makeQueryClient } from "@/lib/query/client"; +import { + sessionId as toSessionId, + taskId as toTaskId, + type TaskPlan, + type TaskSession, +} from "@/lib/types/http"; const mockSetTaskSession = vi.fn(); const mockSetMobileSessionPanel = vi.fn(); const mockSetMobileSessionTaskSwitcherOpen = vi.fn(); +const planApiMocks = vi.hoisted(() => ({ + getPlanRevision: vi.fn(), + getTaskPlan: vi.fn(), + listPlanRevisions: vi.fn(), +})); type MockStoreState = { + connection: { + status: "disconnected" | "connecting" | "connected" | "error" | "reconnecting"; + }; tasks: { activeTaskId: string | null; activeSessionId: string | null; @@ -37,10 +53,25 @@ vi.mock("@/hooks/domains/session/use-session-changes-count", () => ({ useSessionChangesCount: () => 0, })); +vi.mock("@/hooks/domains/session/use-session", () => ({ + useSession: (sessionId: string | null) => ({ + session: sessionId ? (mockStoreState.taskSessions.items[sessionId] ?? null) : null, + isActive: false, + isFailed: false, + errorMessage: undefined, + }), +})); + vi.mock("@/lib/local-storage", () => ({ getPlanLastSeen: () => null, })); +vi.mock("@/lib/api/domains/plan-api", () => ({ + getPlanRevision: planApiMocks.getPlanRevision, + getTaskPlan: planApiMocks.getTaskPlan, + listPlanRevisions: planApiMocks.listPlanRevisions, +})); + vi.mock("@/lib/services/session-approve", () => ({ executeApprove: vi.fn(), })); @@ -51,6 +82,13 @@ vi.mock("@/lib/session/is-passthrough-session", () => ({ import { useSessionLayoutState } from "./use-session-layout-state"; +function renderLayoutHook(options?: Parameters[0]) { + const queryClient = makeQueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); + return renderHook(() => useSessionLayoutState(options), { wrapper }); +} + const ACTIVE_TASK_ID = "task-active"; const OTHER_TASK_ID = "task-other"; const ACTIVE_SESSION_ID = "session-active"; @@ -67,8 +105,24 @@ function makeSession(id: string, taskId: string): TaskSession { } as TaskSession; } +function makePlan(overrides: Partial = {}): TaskPlan { + return { + id: "plan-1", + task_id: ACTIVE_TASK_ID, + title: "Plan", + content: "Do the work", + created_by: "agent", + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:01:00Z", + ...overrides, + }; +} + function setupStore(overrides: Partial = {}) { mockStoreState = { + connection: { + status: "connected", + }, tasks: { activeTaskId: ACTIVE_TASK_ID, activeSessionId: null, @@ -93,6 +147,7 @@ function setupStore(overrides: Partial = {}) { beforeEach(() => { vi.clearAllMocks(); + planApiMocks.getTaskPlan.mockResolvedValue(null); setupStore(); }); @@ -111,7 +166,7 @@ describe("useSessionLayoutState active session selection", () => { }, }); - const { result } = renderHook(() => useSessionLayoutState({ sessionId: ROUTE_SESSION_ID })); + const { result } = renderLayoutHook({ sessionId: ROUTE_SESSION_ID }); expect(result.current.effectiveSessionId).toBe(ACTIVE_SESSION_ID); }); @@ -130,7 +185,7 @@ describe("useSessionLayoutState active session selection", () => { }, }); - const { result } = renderHook(() => useSessionLayoutState({ sessionId: ROUTE_SESSION_ID })); + const { result } = renderLayoutHook({ sessionId: ROUTE_SESSION_ID }); expect(result.current.effectiveSessionId).toBe(ROUTE_SESSION_ID); }); @@ -149,7 +204,7 @@ describe("useSessionLayoutState active session selection", () => { }, }); - const { result } = renderHook(() => useSessionLayoutState()); + const { result } = renderLayoutHook(); expect(result.current.effectiveSessionId).toBeNull(); }); @@ -168,7 +223,7 @@ describe("useSessionLayoutState active session selection", () => { }, }); - const { result } = renderHook(() => useSessionLayoutState()); + const { result } = renderLayoutHook(); expect(result.current.effectiveSessionId).toBeNull(); }); @@ -189,7 +244,7 @@ describe("useSessionLayoutState rowless active sessions", () => { }, }); - const { result } = renderHook(() => useSessionLayoutState({ sessionId: ROUTE_SESSION_ID })); + const { result } = renderLayoutHook({ sessionId: ROUTE_SESSION_ID }); expect(result.current.effectiveSessionId).toBe(ACTIVE_SESSION_ID); }); @@ -208,7 +263,7 @@ describe("useSessionLayoutState rowless active sessions", () => { }, }); - const { result } = renderHook(() => useSessionLayoutState({ sessionId: ROUTE_SESSION_ID })); + const { result } = renderLayoutHook({ sessionId: ROUTE_SESSION_ID }); expect(result.current.effectiveSessionId).toBe(ROUTE_SESSION_ID); }); @@ -227,7 +282,7 @@ describe("useSessionLayoutState rowless active sessions", () => { }, }); - const { result } = renderHook(() => useSessionLayoutState({ sessionId: ROUTE_SESSION_ID })); + const { result } = renderLayoutHook({ sessionId: ROUTE_SESSION_ID }); expect(result.current.effectiveSessionId).toBe(ROUTE_SESSION_ID); }); @@ -235,14 +290,40 @@ describe("useSessionLayoutState rowless active sessions", () => { describe("useSessionLayoutState fallback sessions", () => { it("uses the provided session when no active session exists", () => { - const { result } = renderHook(() => useSessionLayoutState({ sessionId: ROUTE_SESSION_ID })); + const { result } = renderLayoutHook({ sessionId: ROUTE_SESSION_ID }); expect(result.current.effectiveSessionId).toBe(ROUTE_SESSION_ID); }); it("returns null without an active or provided session", () => { - const { result } = renderHook(() => useSessionLayoutState()); + const { result } = renderLayoutHook(); expect(result.current.effectiveSessionId).toBeNull(); }); }); + +describe("useSessionLayoutState plan badge", () => { + it("loads an existing plan before deriving the unseen mobile badge", async () => { + const plan = makePlan(); + planApiMocks.getTaskPlan.mockResolvedValueOnce(plan); + + const { result } = renderLayoutHook(); + + await waitFor(() => expect(result.current.plan).toEqual(plan)); + expect(planApiMocks.getTaskPlan).toHaveBeenCalledWith(ACTIVE_TASK_ID); + expect(result.current.hasUnseenPlanUpdate).toBe(true); + }); + + it("does not fetch the plan while the websocket is disconnected", async () => { + setupStore({ + connection: { + status: "disconnected", + }, + }); + + renderLayoutHook(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(planApiMocks.getTaskPlan).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/hooks/use-session-layout-state.ts b/apps/web/hooks/use-session-layout-state.ts index ac731c96e0..65e532b0cf 100644 --- a/apps/web/hooks/use-session-layout-state.ts +++ b/apps/web/hooks/use-session-layout-state.ts @@ -1,13 +1,17 @@ "use client"; import { useState, useCallback, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; +import { useSession } from "@/hooks/domains/session/use-session"; import { useSessionChangesCount } from "@/hooks/domains/session/use-session-changes-count"; import { getPlanLastSeen } from "@/lib/local-storage"; +import { taskPlanQueryOptions } from "@/lib/query/query-options"; import { executeApprove } from "@/lib/services/session-approve"; import type { OpenFileTab } from "@/lib/types/backend"; import type { MobileSessionPanel } from "@/lib/state/slices/ui/types"; import { isPassthroughSession } from "@/lib/session/is-passthrough-session"; +import type { TaskPlan } from "@/lib/types/http"; export type SelectedDiff = { path: string; @@ -57,6 +61,30 @@ function useOpenFileRequestState() { return { openFileRequest, handleOpenFile, handleFileOpenHandled }; } +function hasUnseenAgentPlanUpdate( + activeTaskId: string | null, + plan: TaskPlan | null, + currentMobilePanel: MobileSessionPanel, +) { + if (!activeTaskId || !plan || currentMobilePanel === "plan") return false; + if (plan.created_by !== "agent") return false; + return plan.updated_at !== getPlanLastSeen(activeTaskId); +} + +function usePlanBadgeState(activeTaskId: string | null, currentMobilePanel: MobileSessionPanel) { + const connectionStatus = useAppStore((state) => state.connection.status); + const planQuery = useQuery( + taskPlanQueryOptions(activeTaskId ?? "", connectionStatus === "connected"), + ); + const plan = planQuery.data ?? null; + + const hasUnseenPlanUpdate = useMemo(() => { + return hasUnseenAgentPlanUpdate(activeTaskId, plan, currentMobilePanel); + }, [activeTaskId, plan, currentMobilePanel]); + + return { plan, hasUnseenPlanUpdate }; +} + /** * Shared hook for session layout state used across mobile, tablet, and desktop layouts. * Consolidates common state and logic to avoid duplication. @@ -67,9 +95,7 @@ export function useSessionLayoutState(options: UseSessionLayoutStateOptions = {} // --- Core session state --- const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); - const activeSessionData = useAppStore((state) => - activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null, - ); + const { session: activeSessionData } = useSession(activeSessionId); const lastSessionForActiveTask = useAppStore((state) => activeTaskId ? state.tasks.lastSessionByTaskId[activeTaskId] : null, ); @@ -82,9 +108,7 @@ export function useSessionLayoutState(options: UseSessionLayoutStateOptions = {} ); const sessionKey = effectiveSessionId ?? ""; - const activeSession = useAppStore((state) => - effectiveSessionId ? (state.taskSessions.items[effectiveSessionId] ?? null) : null, - ); + const { session: activeSession } = useSession(effectiveSessionId); const setTaskSession = useAppStore((state) => state.setTaskSession); // --- Agent state --- @@ -114,17 +138,7 @@ export function useSessionLayoutState(options: UseSessionLayoutStateOptions = {} : "chat"; // --- Plan badge --- - const plan = useAppStore((state) => - activeTaskId ? state.taskPlans.byTaskId[activeTaskId] : null, - ); - - const hasUnseenPlanUpdate = useMemo(() => { - // Don't show badge if we're viewing the plan - if (!activeTaskId || !plan || currentMobilePanel === "plan") return false; - if (plan.created_by !== "agent") return false; - const lastSeen = getPlanLastSeen(activeTaskId); - return plan.updated_at !== lastSeen; - }, [activeTaskId, plan, currentMobilePanel]); + const { plan, hasUnseenPlanUpdate } = usePlanBadgeState(activeTaskId, currentMobilePanel); // --- Approve button logic --- const showApproveButton = diff --git a/apps/web/hooks/use-sidebar-multi-select.test.ts b/apps/web/hooks/use-sidebar-multi-select.test.ts index def0d8e13c..6adfb1b5f7 100644 --- a/apps/web/hooks/use-sidebar-multi-select.test.ts +++ b/apps/web/hooks/use-sidebar-multi-select.test.ts @@ -1,14 +1,16 @@ import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createElement, type ReactNode } from "react"; const archiveTaskById = vi.fn(); const deleteTaskById = vi.fn(); const archiveAndSwitch = vi.fn(); const removeTaskFromBoard = vi.fn(); -const removeTasksFromStore = vi.fn(); const moveTasks = vi.fn(); const toast = vi.fn(); let activeTaskId: string | null = null; +let queryClient: QueryClient; vi.mock("./use-task-actions", () => ({ useTaskActions: () => ({ archiveTaskById, deleteTaskById }), @@ -22,20 +24,41 @@ vi.mock("@/components/state-provider", () => ({ getState: () => ({ tasks: { activeTaskId, activeSessionId: null } }), }), })); -vi.mock("./use-task-multi-select", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, useTaskMultiSelectStore: () => ({ removeTasksFromStore }) }; -}); import { useSidebarMultiSelect } from "./use-sidebar-multi-select"; +function wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); +} + +function renderSidebarMultiSelect(workspaceId: string | null) { + return renderHook(() => useSidebarMultiSelect(workspaceId), { wrapper }); +} + +function seedSnapshot(taskIds: string[]) { + queryClient.setQueryData(["workflows", "wf1", "snapshot"], { + workflow: { id: "wf1" }, + steps: [], + tasks: taskIds.map((id) => ({ id })), + }); +} + +function snapshotTaskIds(): string[] { + const snapshot = queryClient.getQueryData<{ tasks: Array<{ id: string }> }>([ + "workflows", + "wf1", + "snapshot", + ]); + return snapshot?.tasks.map((task) => task.id) ?? []; +} + beforeEach(() => { + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); activeTaskId = null; archiveTaskById.mockReset().mockResolvedValue(undefined); deleteTaskById.mockReset().mockResolvedValue(undefined); archiveAndSwitch.mockReset().mockResolvedValue(undefined); removeTaskFromBoard.mockReset().mockResolvedValue(undefined); - removeTasksFromStore.mockReset(); moveTasks.mockReset().mockResolvedValue(undefined); toast.mockReset(); }); @@ -45,7 +68,7 @@ afterEach(() => { describe("useSidebarMultiSelect", () => { it("toggles, ranges, and clears the selection", () => { - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + const { result } = renderSidebarMultiSelect("ws1"); expect(result.current.isSelecting).toBe(false); act(() => result.current.toggleSelect("a")); @@ -58,7 +81,7 @@ describe("useSidebarMultiSelect", () => { }); it("pruneToVisible drops selected ids that are no longer visible", () => { - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + const { result } = renderSidebarMultiSelect("ws1"); act(() => result.current.toggleSelect("a")); act(() => result.current.toggleSelect("b")); @@ -71,7 +94,7 @@ describe("useSidebarMultiSelect", () => { }); it("pruneToVisible realigns the anchor so a later range starts from a visible id", () => { - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + const { result } = renderSidebarMultiSelect("ws1"); act(() => result.current.toggleSelect("a")); act(() => result.current.toggleSelect("b")); // anchor is now "b" act(() => result.current.pruneToVisible(["a"])); // drops "b", anchor must realign to "a" @@ -85,6 +108,7 @@ describe("useSidebarMultiSelect", () => { it("resets the selection when the workspace changes", () => { const { result, rerender } = renderHook(({ ws }) => useSidebarMultiSelect(ws), { initialProps: { ws: "ws1" }, + wrapper, }); act(() => result.current.toggleSelect("a")); expect(result.current.selectedIds.size).toBe(1); @@ -96,12 +120,13 @@ describe("useSidebarMultiSelect", () => { describe("useSidebarMultiSelect — bulk actions", () => { it("bulkArchive removes all on full success and clears the selection", async () => { - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + seedSnapshot(["a", "b"]); + const { result } = renderSidebarMultiSelect("ws1"); await act(async () => { await result.current.bulkArchive(["a", "b"]); }); expect(archiveTaskById).toHaveBeenCalledTimes(2); - expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a", "b"])); + expect(snapshotTaskIds()).toEqual([]); expect(result.current.selectedIds.size).toBe(0); expect(toast).not.toHaveBeenCalled(); }); @@ -110,29 +135,31 @@ describe("useSidebarMultiSelect — bulk actions", () => { archiveTaskById.mockImplementation((id) => id === "b" ? Promise.reject(new Error("nope")) : Promise.resolve(), ); - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + seedSnapshot(["a", "b"]); + const { result } = renderSidebarMultiSelect("ws1"); await act(async () => { await result.current.bulkArchive(["a", "b"]); }); - expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a"])); + expect(snapshotTaskIds()).toEqual(["b"]); expect(result.current.selectedIds).toEqual(new Set(["b"])); expect(toast).toHaveBeenCalledWith(expect.objectContaining({ variant: "error" })); }); it("bulkArchive routes the active task through the switch-aware path", async () => { activeTaskId = "a"; - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + seedSnapshot(["a", "b"]); + const { result } = renderSidebarMultiSelect("ws1"); await act(async () => { await result.current.bulkArchive(["a", "b"]); }); expect(archiveAndSwitch).toHaveBeenCalledWith("a", undefined); expect(archiveTaskById).toHaveBeenCalledTimes(1); expect(archiveTaskById).toHaveBeenCalledWith("b", undefined); - expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["b"])); + expect(snapshotTaskIds()).toEqual(["a"]); }); it("bulkArchive ignores an empty id list", async () => { - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + const { result } = renderSidebarMultiSelect("ws1"); await act(async () => { await result.current.bulkArchive([]); }); @@ -140,12 +167,13 @@ describe("useSidebarMultiSelect — bulk actions", () => { }); it("bulkDelete removes all on full success and clears the selection", async () => { - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + seedSnapshot(["a", "b"]); + const { result } = renderSidebarMultiSelect("ws1"); await act(async () => { await result.current.bulkDelete(["a", "b"]); }); expect(deleteTaskById).toHaveBeenCalledTimes(2); - expect(removeTasksFromStore).toHaveBeenCalledWith(new Set(["a", "b"])); + expect(snapshotTaskIds()).toEqual([]); expect(result.current.selectedIds.size).toBe(0); expect(toast).not.toHaveBeenCalled(); }); @@ -154,7 +182,7 @@ describe("useSidebarMultiSelect — bulk actions", () => { deleteTaskById.mockImplementation((id: string) => id === "b" ? Promise.reject(new Error("nope")) : Promise.resolve(), ); - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + const { result } = renderSidebarMultiSelect("ws1"); await act(async () => { await result.current.bulkDelete(["a", "b"]); }); @@ -164,7 +192,7 @@ describe("useSidebarMultiSelect — bulk actions", () => { it("bulkDelete routes the active task through the switch-aware removal", async () => { activeTaskId = "a"; - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + const { result } = renderSidebarMultiSelect("ws1"); await act(async () => { await result.current.bulkDelete(["a", "b"]); }); @@ -178,7 +206,7 @@ describe("useSidebarMultiSelect — bulk actions", () => { }); it("bulkMove clears the selection on success", async () => { - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + const { result } = renderSidebarMultiSelect("ws1"); act(() => result.current.toggleSelect("a")); await act(async () => { await result.current.bulkMove(["a"], "wf1", "s1"); @@ -189,7 +217,7 @@ describe("useSidebarMultiSelect — bulk actions", () => { it("bulkMove keeps the selection and swallows the rejection on failure", async () => { moveTasks.mockRejectedValue(new Error("locked")); - const { result } = renderHook(() => useSidebarMultiSelect("ws1")); + const { result } = renderSidebarMultiSelect("ws1"); act(() => result.current.toggleSelect("a")); await act(async () => { await result.current.bulkMove(["a"], "wf1", "s1"); diff --git a/apps/web/hooks/use-sidebar-multi-select.ts b/apps/web/hooks/use-sidebar-multi-select.ts index 421e223c41..05650d9fde 100644 --- a/apps/web/hooks/use-sidebar-multi-select.ts +++ b/apps/web/hooks/use-sidebar-multi-select.ts @@ -1,16 +1,14 @@ "use client"; import { useCallback, useEffect, useReducer, useState, type Dispatch } from "react"; -import { - INITIAL_STATE, - multiSelectReducer, - useTaskMultiSelectStore, -} from "./use-task-multi-select"; +import { useQueryClient } from "@tanstack/react-query"; +import { INITIAL_STATE, multiSelectReducer } from "./use-task-multi-select"; import { useTaskActions, useArchiveAndSwitchTask } from "./use-task-actions"; import { useTaskRemoval } from "./use-task-removal"; import { useTaskWorkflowMove } from "./use-task-workflow-move"; import { useToast } from "@/components/toast-provider"; import { useAppStoreApi } from "@/components/state-provider"; +import { removeTasksFromWorkflowSnapshotQueries } from "@/lib/query/workflow-snapshot-cache"; type BulkOpts = { cascade?: boolean }; @@ -25,10 +23,10 @@ function useSidebarBulkActions( clearSelection: () => void, ) { const store = useAppStoreApi(); + const queryClient = useQueryClient(); const { archiveTaskById, deleteTaskById } = useTaskActions(); const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true }); const { removeTaskFromBoard } = useTaskRemoval({ store, useLayoutSwitch: true }); - const { removeTasksFromStore } = useTaskMultiSelectStore(); const moveTasks = useTaskWorkflowMove(); const { toast } = useToast(); const [isArchiving, setIsArchiving] = useState(false); @@ -51,7 +49,9 @@ function useSidebarBulkActions( const results = await Promise.allSettled(restIds.map((id) => per(id))); const failed = restIds.filter((_, i) => results[i].status === "rejected"); const succeeded = restIds.filter((_, i) => results[i].status === "fulfilled"); - if (succeeded.length > 0) removeTasksFromStore(new Set(succeeded)); + if (succeeded.length > 0) { + removeTasksFromWorkflowSnapshotQueries(queryClient, new Set(succeeded)); + } if (activeInSet) { try { await handleActive(activeId!); @@ -73,7 +73,7 @@ function useSidebarBulkActions( setBusy(false); } }, - [store, removeTasksFromStore, dispatch, toast, clearSelection], + [store, queryClient, dispatch, toast, clearSelection], ); const bulkArchive = useCallback( diff --git a/apps/web/hooks/use-subtask-count.test.ts b/apps/web/hooks/use-subtask-count.test.ts index 14e069f7e9..ffcc1e4669 100644 --- a/apps/web/hooks/use-subtask-count.test.ts +++ b/apps/web/hooks/use-subtask-count.test.ts @@ -1,21 +1,34 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; const mockGetSubtaskCount = vi.fn(); -vi.mock("@/lib/api", () => ({ +vi.mock("@/lib/api/domains/kanban-api", () => ({ getSubtaskCount: (...args: unknown[]) => mockGetSubtaskCount(...args), })); import { useSubtaskCount } from "./use-subtask-count"; +function wrapper() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function TestWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + beforeEach(() => { mockGetSubtaskCount.mockReset(); }); describe("useSubtaskCount", () => { it("returns 0 when the dialog is closed", () => { - const { result } = renderHook(() => useSubtaskCount(false, "task-1")); + const { result } = renderHook(() => useSubtaskCount(false, "task-1"), { + wrapper: wrapper(), + }); expect(result.current).toBe(0); expect(mockGetSubtaskCount).not.toHaveBeenCalled(); }); @@ -27,7 +40,9 @@ describe("useSubtaskCount", () => { resolveFetch = res; }), ); - const { result } = renderHook(() => useSubtaskCount(true, "task-1")); + const { result } = renderHook(() => useSubtaskCount(true, "task-1"), { + wrapper: wrapper(), + }); expect(result.current).toBe(0); resolveFetch({ count: 7 }); await waitFor(() => expect(result.current).toBe(7)); @@ -39,7 +54,10 @@ describe("useSubtaskCount", () => { ); const { result, rerender } = renderHook( ({ open, taskId }: { open: boolean; taskId: string }) => useSubtaskCount(open, taskId), - { initialProps: { open: true, taskId: "a" } }, + { + initialProps: { open: true, taskId: "a" }, + wrapper: wrapper(), + }, ); await waitFor(() => expect(result.current).toBe(3)); @@ -57,7 +75,28 @@ describe("useSubtaskCount", () => { mockGetSubtaskCount.mockImplementation((id: string) => Promise.resolve({ count: id === "a" ? 2 : 5 }), ); - const { result } = renderHook(() => useSubtaskCount(true, undefined, ["a", "b"])); + const { result } = renderHook(() => useSubtaskCount(true, undefined, ["a", "b"]), { + wrapper: wrapper(), + }); await waitFor(() => expect(result.current).toBe(7)); }); + + it("returns 0 when reopened for the same task until a fresh query resolves", async () => { + mockGetSubtaskCount.mockResolvedValueOnce({ count: 3 }).mockResolvedValueOnce({ count: 4 }); + const { result, rerender } = renderHook( + ({ open }: { open: boolean }) => useSubtaskCount(open, "same-task"), + { + initialProps: { open: true }, + wrapper: wrapper(), + }, + ); + await waitFor(() => expect(result.current).toBe(3)); + + rerender({ open: false }); + expect(result.current).toBe(0); + + rerender({ open: true }); + expect(result.current).toBe(0); + await waitFor(() => expect(result.current).toBe(4)); + }); }); diff --git a/apps/web/hooks/use-subtask-count.ts b/apps/web/hooks/use-subtask-count.ts index 68c5dda694..bbd26140ee 100644 --- a/apps/web/hooks/use-subtask-count.ts +++ b/apps/web/hooks/use-subtask-count.ts @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { getSubtaskCount } from "@/lib/api"; +import { useQueryClient } from "@tanstack/react-query"; +import { subtaskCountQueryOptions } from "@/lib/query/query-options"; // useSubtaskCount fetches the subtask count for an archive / delete // confirmation dialog when it opens. Returns 0 while the request for @@ -15,6 +16,7 @@ import { getSubtaskCount } from "@/lib/api"; // stops the bulk toolbar's per-render `[...selectedIds]` from fanning // out a new Promise.all on every render. export function useSubtaskCount(open: boolean, taskId?: string, taskIds?: string[]): number { + const queryClient = useQueryClient(); const idsKey = taskIds?.join(",") ?? taskId ?? ""; const [result, setResult] = useState<{ key: string; total: number }>({ key: "", @@ -28,22 +30,32 @@ export function useSubtaskCount(open: boolean, taskId?: string, taskIds?: string setResult({ key: "", total: 0 }); return; } - if (!idsKey) return; + if (!idsKey) { + setResult({ key: "", total: 0 }); + return; + } const ids = taskIds ?? (taskId ? [taskId] : []); let cancelled = false; // Per-id .catch already maps every failure to { count: 0 }, so // Promise.all never rejects — no outer .catch needed. - Promise.all(ids.map((id) => getSubtaskCount(id).catch(() => ({ count: 0 })))).then( - (results) => { - if (cancelled) return; - setResult({ key: idsKey, total: results.reduce((sum, r) => sum + r.count, 0) }); - }, - ); + Promise.all( + ids.map((id) => + queryClient + .fetchQuery({ + ...subtaskCountQueryOptions(id), + staleTime: 0, + }) + .catch(() => ({ count: 0 })), + ), + ).then((results) => { + if (cancelled) return; + setResult({ key: idsKey, total: results.reduce((sum, r) => sum + r.count, 0) }); + }); return () => { cancelled = true; }; // taskId / taskIds intentionally excluded — idsKey is their stable summary. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, idsKey]); + }, [open, idsKey, queryClient]); return open && result.key === idsKey ? result.total : 0; } diff --git a/apps/web/hooks/use-summarize-session.test.ts b/apps/web/hooks/use-summarize-session.test.ts index 65f2e84767..3ee6fa4abb 100644 --- a/apps/web/hooks/use-summarize-session.test.ts +++ b/apps/web/hooks/use-summarize-session.test.ts @@ -1,5 +1,8 @@ import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; import { useSummarizeSession } from "./use-summarize-session"; const mockListMessages = vi.fn(); @@ -13,6 +16,13 @@ vi.mock("@/lib/api/domains/utility-api", () => ({ executeUtilityPrompt: (...args: unknown[]) => mockExecuteUtilityPrompt(...args), })); +function renderSummarizeSession() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: queryClient }, children); + return { queryClient, ...renderHook(() => useSummarizeSession(), { wrapper }) }; +} + describe("useSummarizeSession", () => { beforeEach(() => { vi.clearAllMocks(); @@ -23,7 +33,7 @@ describe("useSummarizeSession", () => { messages: [{ type: "message", author_type: "user", content: "hello" }], }); mockExecuteUtilityPrompt.mockResolvedValue({ success: true, response: "summary" }); - const { result } = renderHook(() => useSummarizeSession()); + const { result } = renderSummarizeSession(); let summary; await act(async () => { @@ -38,7 +48,7 @@ describe("useSummarizeSession", () => { messages: [{ type: "message", author_type: "user", content: "hello" }], }); mockExecuteUtilityPrompt.mockRejectedValue(new Error("connection refused")); - const { result } = renderHook(() => useSummarizeSession()); + const { result } = renderSummarizeSession(); let summary; await act(async () => { @@ -48,4 +58,24 @@ describe("useSummarizeSession", () => { expect(summary).toEqual({ summary: null, error: "connection refused" }); expect(result.current.isSummarizing).toBe(false); }); + + it("fetches a fresh transcript even when the messages page cache is fresh", async () => { + mockListMessages.mockResolvedValue({ + messages: [{ type: "message", author_type: "assistant", content: "fresh transcript" }], + }); + mockExecuteUtilityPrompt.mockResolvedValue({ success: true, response: "summary" }); + const { queryClient, result } = renderSummarizeSession(); + queryClient.setQueryData(qk.session.messagesPage("session-1", { sort: "asc" }), { + messages: [{ type: "message", author_type: "assistant", content: "cached transcript" }], + }); + + await act(async () => { + await result.current.summarize("session-1"); + }); + + expect(mockListMessages).toHaveBeenCalledTimes(1); + expect(mockExecuteUtilityPrompt).toHaveBeenCalledWith( + expect.objectContaining({ conversation_history: "Agent: fresh transcript" }), + ); + }); }); diff --git a/apps/web/hooks/use-summarize-session.ts b/apps/web/hooks/use-summarize-session.ts index d8f7846487..c1960d633f 100644 --- a/apps/web/hooks/use-summarize-session.ts +++ b/apps/web/hooks/use-summarize-session.ts @@ -1,6 +1,7 @@ import { useCallback, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { executeUtilityPrompt } from "@/lib/api/domains/utility-api"; -import { listTaskSessionMessages } from "@/lib/api/domains/session-api"; +import { sessionMessagesQueryOptions } from "@/lib/query/query-options"; import type { Message } from "@/lib/types/http"; export type SummarizeSessionResult = { @@ -19,38 +20,45 @@ function formatTranscript(messages: Message[]): string { } export function useSummarizeSession() { + const queryClient = useQueryClient(); const [isSummarizing, setIsSummarizing] = useState(false); - const summarize = useCallback(async (sessionId: string): Promise => { - setIsSummarizing(true); - try { - // Fetch messages from API — they may not be in the store for non-active sessions - const resp = await listTaskSessionMessages(sessionId, { sort: "asc" }); - const messages = resp.messages ?? []; - if (!messages.length) return { summary: null }; + const summarize = useCallback( + async (sessionId: string): Promise => { + setIsSummarizing(true); + try { + // Fetch messages from API — they may not be in the store for non-active sessions + const resp = await queryClient.fetchQuery({ + ...sessionMessagesQueryOptions(sessionId, { sort: "asc" }), + staleTime: 0, + }); + const messages = resp.messages ?? []; + if (!messages.length) return { summary: null }; - const transcript = formatTranscript(messages); - if (!transcript) return { summary: null }; + const transcript = formatTranscript(messages); + if (!transcript) return { summary: null }; - // Sessionless: handoff often runs against a completed session whose - // agentctl is gone. Host utility executes the builtin summarize agent. - const result = await executeUtilityPrompt({ - utility_agent_id: "builtin-summarize-session", - conversation_history: transcript, - }); - if (!result.success) { - return { summary: null, error: result.error || "Summarize utility returned no result" }; + // Sessionless: handoff often runs against a completed session whose + // agentctl is gone. Host utility executes the builtin summarize agent. + const result = await executeUtilityPrompt({ + utility_agent_id: "builtin-summarize-session", + conversation_history: transcript, + }); + if (!result.success) { + return { summary: null, error: result.error || "Summarize utility returned no result" }; + } + return { summary: result.response ?? null }; + } catch (error) { + return { + summary: null, + error: error instanceof Error ? error.message : "Could not generate a summary", + }; + } finally { + setIsSummarizing(false); } - return { summary: result.response ?? null }; - } catch (error) { - return { - summary: null, - error: error instanceof Error ? error.message : "Could not generate a summary", - }; - } finally { - setIsSummarizing(false); - } - }, []); + }, + [queryClient], + ); return { summarize, isSummarizing }; } diff --git a/apps/web/hooks/use-task-crud.test.tsx b/apps/web/hooks/use-task-crud.test.tsx new file mode 100644 index 0000000000..4a0728bbfe --- /dev/null +++ b/apps/web/hooks/use-task-crud.test.tsx @@ -0,0 +1,160 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import type { AppState } from "@/lib/state/store"; +import { + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, +} from "@/lib/types/ids"; +import type { Task, WorkflowSnapshot } from "@/lib/types/http"; +import { useTaskCRUD } from "./use-task-crud"; + +const actionMocks = vi.hoisted(() => ({ + archiveTaskById: vi.fn(), + deleteTaskById: vi.fn(), +})); + +vi.mock("@/hooks/use-task-actions", () => ({ + useTaskActions: () => ({ + archiveTaskById: actionMocks.archiveTaskById, + deleteTaskById: actionMocks.deleteTaskById, + }), +})); + +const WORKSPACE_ID = toWorkspaceId("workspace-1"); +const WORKFLOW_ID = toWorkflowId("workflow-1"); +const STEP_ID = "step-1"; +const CREATED_AT = "2026-06-24T00:00:00Z"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + const initialState = { + workflows: { activeId: WORKFLOW_ID }, + kanban: { + workflowId: null, + steps: [], + tasks: [], + isLoading: false, + }, + } as Partial; + + return function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }; +} + +function task(id: string): Task { + return { + id: toTaskId(id), + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: STEP_ID, + position: 0, + title: id, + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: CREATED_AT, + updated_at: CREATED_AT, + } as Task; +} + +function snapshot(tasks: Task[]): WorkflowSnapshot { + return { + workflow: { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 0, + hidden: false, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + steps: [ + { + id: STEP_ID, + workflow_id: WORKFLOW_ID, + name: "Todo", + position: 0, + color: "bg-blue-500", + }, + ], + tasks, + } as WorkflowSnapshot; +} + +function cardTask(id: string) { + return { + id, + title: id, + workflowStepId: STEP_ID, + position: 0, + }; +} + +describe("useTaskCRUD", () => { + beforeEach(() => { + actionMocks.archiveTaskById.mockReset(); + actionMocks.deleteTaskById.mockReset(); + actionMocks.archiveTaskById.mockResolvedValue({}); + actionMocks.deleteTaskById.mockResolvedValue({}); + }); + + afterEach(() => { + cleanup(); + }); + + it("removes deleted tasks from workflow snapshot query caches", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + qk.workflows.snapshot(WORKFLOW_ID), + snapshot([task("task-1"), task("task-2")]), + ); + const { result } = renderHook(() => useTaskCRUD(), { wrapper: wrapperFor(queryClient) }); + + await act(async () => { + await result.current.handleDelete(cardTask("task-1")); + }); + + expect(actionMocks.deleteTaskById).toHaveBeenCalledWith("task-1", undefined); + expect( + queryClient + .getQueryData(qk.workflows.snapshot(WORKFLOW_ID)) + ?.tasks.map((item) => item.id), + ).toEqual(["task-2"]); + }); + + it("removes archived tasks from workflow snapshot query caches", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + qk.workflows.snapshot(WORKFLOW_ID), + snapshot([task("task-1"), task("task-2")]), + ); + const { result } = renderHook(() => useTaskCRUD(), { wrapper: wrapperFor(queryClient) }); + + await act(async () => { + await result.current.handleArchive(cardTask("task-1")); + }); + + expect(actionMocks.archiveTaskById).toHaveBeenCalledWith("task-1", undefined); + expect( + queryClient + .getQueryData(qk.workflows.snapshot(WORKFLOW_ID)) + ?.tasks.map((item) => item.id), + ).toEqual(["task-2"]); + }); +}); diff --git a/apps/web/hooks/use-task-crud.ts b/apps/web/hooks/use-task-crud.ts index 6377b4637e..b1ddd2b526 100644 --- a/apps/web/hooks/use-task-crud.ts +++ b/apps/web/hooks/use-task-crud.ts @@ -1,10 +1,10 @@ "use client"; import { useCallback, useState } from "react"; -import { useAppStoreApi } from "@/components/state-provider"; +import { useQueryClient } from "@tanstack/react-query"; import { useTaskActions } from "@/hooks/use-task-actions"; import type { Task } from "@/components/kanban-card"; -import type { KanbanState } from "@/lib/state/slices"; +import { removeTasksFromWorkflowSnapshotQueries } from "@/lib/query/workflow-snapshot-cache"; /** * Custom hook that extracts task CRUD operations from the Kanban component. @@ -18,7 +18,7 @@ export function useTaskCRUD() { const [deletingTaskId, setDeletingTaskId] = useState(null); const [archivingTaskId, setArchivingTaskId] = useState(null); const { deleteTaskById, archiveTaskById } = useTaskActions(); - const store = useAppStoreApi(); + const queryClient = useQueryClient(); const handleCreate = useCallback(() => { setEditingTask(null); @@ -35,21 +35,12 @@ export function useTaskCRUD() { setDeletingTaskId(task.id); try { await deleteTaskById(task.id, opts); - - // Update UI AFTER successful delete - store.getState().hydrate({ - kanban: { - ...store.getState().kanban, - tasks: store - .getState() - .kanban.tasks.filter((item: KanbanState["tasks"][number]) => item.id !== task.id), - }, - }); + removeTasksFromWorkflowSnapshotQueries(queryClient, new Set([task.id])); } finally { setDeletingTaskId(null); } }, - [deleteTaskById, store], + [deleteTaskById, queryClient], ); const handleArchive = useCallback( @@ -57,21 +48,12 @@ export function useTaskCRUD() { setArchivingTaskId(task.id); try { await archiveTaskById(task.id, opts); - - // Update UI AFTER successful archive - remove from kanban view - store.getState().hydrate({ - kanban: { - ...store.getState().kanban, - tasks: store - .getState() - .kanban.tasks.filter((item: KanbanState["tasks"][number]) => item.id !== task.id), - }, - }); + removeTasksFromWorkflowSnapshotQueries(queryClient, new Set([task.id])); } finally { setArchivingTaskId(null); } }, - [archiveTaskById, store], + [archiveTaskById, queryClient], ); const handleDialogOpenChange = useCallback((open: boolean) => { diff --git a/apps/web/hooks/use-task-multi-select.test.ts b/apps/web/hooks/use-task-multi-select.test.ts index 077fa6b9f1..93cbfef8ff 100644 --- a/apps/web/hooks/use-task-multi-select.test.ts +++ b/apps/web/hooks/use-task-multi-select.test.ts @@ -1,5 +1,151 @@ -import { describe, it, expect } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import type { WorkflowSnapshot } from "@/lib/types/http"; +import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; import { multiSelectReducer, INITIAL_STATE } from "./use-task-multi-select"; +import { useTaskMultiSelect } from "./use-task-multi-select"; + +const archiveTaskById = vi.fn(async () => {}); +const deleteTaskById = vi.fn(async () => {}); +const moveTaskById = vi.fn(async () => {}); + +vi.mock("@/hooks/use-task-actions", () => ({ + useTaskActions: () => ({ + archiveTaskById, + deleteTaskById, + moveTaskById, + }), +})); + +const WORKFLOW_ID = "workflow-1"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} + +function rawSnapshot(): WorkflowSnapshot { + return { + workflow: { + id: WORKFLOW_ID, + workspace_id: "workspace-1", + name: "Build", + sort_order: 0, + hidden: false, + }, + steps: [ + { + id: "step-1", + workflow_id: WORKFLOW_ID, + name: "Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + { + id: "step-2", + workflow_id: WORKFLOW_ID, + name: "Done", + position: 1, + color: "bg-green-500", + allow_manual_move: true, + }, + ], + tasks: [ + { + id: "task-1", + workspace_id: "workspace-1", + workflow_id: WORKFLOW_ID, + workflow_step_id: "step-1", + position: 0, + title: "Task 1", + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + }, + ], + } as unknown as WorkflowSnapshot; +} + +function convertedSnapshots(): Record { + return { + [WORKFLOW_ID]: { + workflowId: WORKFLOW_ID, + workflowName: "Build", + steps: [ + { id: "step-1", title: "Todo", color: "bg-blue-500", position: 0 }, + { id: "step-2", title: "Done", color: "bg-green-500", position: 1 }, + ], + tasks: [{ id: "task-1", workflowStepId: "step-1", title: "Task 1", position: 0 }], + }, + }; +} + +describe("useTaskMultiSelect query cache updates", () => { + beforeEach(() => { + archiveTaskById.mockClear(); + deleteTaskById.mockClear(); + moveTaskById.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it("removes successfully archived tasks from workflow snapshot Query cache without a Zustand store", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), rawSnapshot()); + const { result } = renderHook(() => useTaskMultiSelect(WORKFLOW_ID, convertedSnapshots()), { + wrapper: wrapperFor(queryClient), + }); + + act(() => result.current.toggleSelect("task-1")); + await act(async () => { + await result.current.bulkArchive(); + }); + + expect(archiveTaskById).toHaveBeenCalledWith("task-1", undefined); + expect( + queryClient.getQueryData(qk.workflows.snapshot(WORKFLOW_ID))?.tasks, + ).toEqual([]); + }); + + it("moves successfully moved tasks in workflow snapshot Query cache without a Zustand store", async () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), rawSnapshot()); + const { result } = renderHook(() => useTaskMultiSelect(WORKFLOW_ID, convertedSnapshots()), { + wrapper: wrapperFor(queryClient), + }); + + act(() => result.current.toggleSelect("task-1")); + await act(async () => { + await result.current.bulkMove("step-2"); + }); + + expect(moveTaskById).toHaveBeenCalledWith("task-1", { + workflow_id: WORKFLOW_ID, + workflow_step_id: "step-2", + position: 0, + }); + expect( + queryClient.getQueryData(qk.workflows.snapshot(WORKFLOW_ID))?.tasks[0] + ?.workflow_step_id, + ).toBe("step-2"); + }); +}); describe("multiSelectReducer", () => { it("reset returns initial state", () => { diff --git a/apps/web/hooks/use-task-multi-select.ts b/apps/web/hooks/use-task-multi-select.ts index b23ed47c6b..28336a05b0 100644 --- a/apps/web/hooks/use-task-multi-select.ts +++ b/apps/web/hooks/use-task-multi-select.ts @@ -1,101 +1,59 @@ "use client"; -import { useCallback, useEffect, useLayoutEffect, useReducer, useRef, type RefObject } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useReducer, + useRef, + type Dispatch, + type RefObject, +} from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useTaskActions } from "@/hooks/use-task-actions"; -import { useAppStoreApi } from "@/components/state-provider"; -import type { KanbanState } from "@/lib/state/slices"; +import type { WorkflowSnapshotData } from "@/lib/state/slices"; import { sortIdsByCreatedDesc } from "@/lib/kanban/task-order"; +import { + removeTasksFromWorkflowSnapshotQueries, + updateWorkflowSnapshotQueries, +} from "@/lib/query/workflow-snapshot-cache"; -/** @internal Exported for reuse by the sidebar multi-select hook. */ -export function useTaskMultiSelectStore() { - const store = useAppStoreApi(); - - const removeTasksFromStore = useCallback( - (ids: Set) => { - const state = store.getState(); - // Remove from single-workflow view - const currentKanban = state.kanban; - state.hydrate({ - kanban: { - ...currentKanban, - tasks: currentKanban.tasks.filter((t: KanbanState["tasks"][number]) => !ids.has(t.id)), - }, - }); - // Remove from multi-workflow snapshots - for (const [wfId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { - const affected = snapshot.tasks.some((t: KanbanState["tasks"][number]) => ids.has(t.id)); - if (affected) { - state.setWorkflowSnapshot(wfId, { - ...snapshot, - tasks: snapshot.tasks.filter((t: KanbanState["tasks"][number]) => !ids.has(t.id)), - }); - } - } - }, - [store], - ); - - const applyMoveInStore = useCallback( - (succeededIds: Set, targetStepId: string) => { - const state = store.getState(); - // Update single-workflow view - const currentKanban = state.kanban; - state.hydrate({ - kanban: { - ...currentKanban, - tasks: currentKanban.tasks.map((t: KanbanState["tasks"][number]) => - succeededIds.has(t.id) ? { ...t, workflowStepId: targetStepId } : t, - ), - }, - }); - // Update multi-workflow snapshots - for (const [wfId, snapshot] of Object.entries(state.kanbanMulti.snapshots)) { - const affected = snapshot.tasks.filter((t: KanbanState["tasks"][number]) => - succeededIds.has(t.id), - ); - if (affected.length > 0) { - state.setWorkflowSnapshot(wfId, { - ...snapshot, - tasks: snapshot.tasks.map((t: KanbanState["tasks"][number]) => - succeededIds.has(t.id) ? { ...t, workflowStepId: targetStepId } : t, - ), - }); - } - } - }, - [store], - ); - - const getWorkflowIdForTask = useCallback( - (taskId: string): string | null => { - const snapshots = store.getState().kanbanMulti.snapshots; - for (const [wfId, snapshot] of Object.entries(snapshots)) { - if (snapshot.tasks.some((t: KanbanState["tasks"][number]) => t.id === taskId)) { - return wfId; - } - } - return store.getState().kanban.workflowId; - }, - [store], - ); +function applyMoveInQuerySnapshots( + queryClient: QueryClient, + succeededIds: Set, + targetStepId: string, +): void { + updateWorkflowSnapshotQueries(queryClient, (snapshot) => { + if (!snapshot.tasks.some((task) => succeededIds.has(task.id))) return snapshot; + return { + ...snapshot, + tasks: snapshot.tasks.map((task) => + succeededIds.has(task.id) ? { ...task, workflow_step_id: targetStepId } : task, + ), + }; + }); +} - // Sort ids into the board's visible (created-desc) order. A backward range - // selection leaves `selectedIds` in anchor-first Set order, which would land - // scrambled when the move assigns sequential positions. - const sortByDisplayOrder = useCallback( - (ids: string[]): string[] => { - const state = store.getState(); - const taskById = new Map(); - for (const snap of Object.values(state.kanbanMulti.snapshots)) { - for (const t of snap.tasks) taskById.set(t.id, t); - } - for (const t of state.kanban.tasks) if (!taskById.has(t.id)) taskById.set(t.id, t); - return sortIdsByCreatedDesc(ids, taskById); - }, - [store], - ); +function getWorkflowIdForTask( + snapshots: Record, + taskId: string, + fallbackWorkflowId: string | null, +): string | null { + for (const [workflowId, snapshot] of Object.entries(snapshots)) { + if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId; + } + return fallbackWorkflowId; +} - return { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, sortByDisplayOrder }; +function sortByDisplayOrder( + snapshots: Record, + ids: string[], +): string[] { + const taskById = new Map(); + for (const snapshot of Object.values(snapshots)) { + for (const task of snapshot.tasks) taskById.set(task.id, task); + } + return sortIdsByCreatedDesc(ids, taskById); } function useBulkOperations({ @@ -108,10 +66,10 @@ function useBulkOperations({ moveTaskById, deleteTaskById, archiveTaskById, - removeTasksFromStore, - applyMoveInStore, - getWorkflowIdForTask, - sortByDisplayOrder, + removeTasksFromSnapshots, + applyMoveInSnapshots, + resolveWorkflowIdForTask, + sortSelectedIdsByDisplayOrder, }: { workflowId: string | null; selectedIdsRef: RefObject>; @@ -122,10 +80,10 @@ function useBulkOperations({ moveTaskById: ReturnType["moveTaskById"]; deleteTaskById: ReturnType["deleteTaskById"]; archiveTaskById: ReturnType["archiveTaskById"]; - removeTasksFromStore: (ids: Set) => void; - applyMoveInStore: (ids: Set, stepId: string) => void; - getWorkflowIdForTask: (id: string) => string | null; - sortByDisplayOrder: (ids: string[]) => string[]; + removeTasksFromSnapshots: (ids: Set) => void; + applyMoveInSnapshots: (ids: Set, stepId: string) => void; + resolveWorkflowIdForTask: (id: string) => string | null; + sortSelectedIdsByDisplayOrder: (ids: string[]) => string[]; }) { const runBulk = useCallback( async ( @@ -140,7 +98,7 @@ function useBulkOperations({ const idList = [...ids]; const results = await Promise.allSettled(idList.map((id) => per(id, opts))); const succeeded = new Set(idList.filter((_, i) => results[i].status === "fulfilled")); - removeTasksFromStore(succeeded); + removeTasksFromSnapshots(succeeded); const failed = new Set(idList.filter((_, i) => results[i].status === "rejected")); setSelectedIds(failed); if (failed.size === 0) setIsMultiSelectEnabled(false); @@ -148,7 +106,7 @@ function useBulkOperations({ setBusy(false); } }, - [removeTasksFromStore, selectedIdsRef, setIsMultiSelectEnabled, setSelectedIds], + [removeTasksFromSnapshots, selectedIdsRef, setIsMultiSelectEnabled, setSelectedIds], ); const bulkDelete = useCallback( @@ -165,11 +123,11 @@ function useBulkOperations({ async (targetStepId: string) => { // Move in board order so a backward range selection isn't reordered when // sequential positions are assigned below. - const idList = sortByDisplayOrder([...(selectedIdsRef.current ?? [])]); + const idList = sortSelectedIdsByDisplayOrder([...(selectedIdsRef.current ?? [])]); if (idList.length === 0) return; const results = await Promise.allSettled( idList.map((id, i) => { - const wfId = getWorkflowIdForTask(id) ?? workflowId; + const wfId = resolveWorkflowIdForTask(id) ?? workflowId; if (!wfId) return Promise.reject(new Error("no workflow")); return moveTaskById(id, { workflow_id: wfId, @@ -179,15 +137,15 @@ function useBulkOperations({ }), ); const succeeded = new Set(idList.filter((_, i) => results[i].status === "fulfilled")); - applyMoveInStore(succeeded, targetStepId); + applyMoveInSnapshots(succeeded, targetStepId); }, [ workflowId, moveTaskById, - applyMoveInStore, - getWorkflowIdForTask, - sortByDisplayOrder, + applyMoveInSnapshots, + resolveWorkflowIdForTask, selectedIdsRef, + sortSelectedIdsByDisplayOrder, ], ); @@ -300,39 +258,62 @@ export function multiSelectReducer( } } -export function useTaskMultiSelect(workflowId: string | null) { - const [state, dispatch] = useReducer(multiSelectReducer, INITIAL_STATE); - const { selectedIds, isMultiSelectEnabled, isDeleting, isArchiving } = state; - const selectedIdsRef = useRef(selectedIds); - useLayoutEffect(() => { - selectedIdsRef.current = selectedIds; - }); - const isProcessing = isDeleting || isArchiving; - +function useMultiSelectDispatchers(dispatch: Dispatch) { const setSelectedIds = useCallback( (ids: Set) => dispatch({ type: "set_selected", ids }), - [], + [dispatch], ); const setIsMultiSelectEnabled = useCallback( (value: boolean) => dispatch({ type: "set_enabled", value }), - [], + [dispatch], ); const setIsDeleting = useCallback( (value: boolean) => dispatch({ type: "set_deleting", value }), - [], + [dispatch], ); const setIsArchiving = useCallback( (value: boolean) => dispatch({ type: "set_archiving", value }), - [], + [dispatch], ); + return { setSelectedIds, setIsMultiSelectEnabled, setIsDeleting, setIsArchiving }; +} + +export function useTaskMultiSelect( + workflowId: string | null, + snapshots: Record = {}, +) { + const queryClient = useQueryClient(); + const [state, dispatch] = useReducer(multiSelectReducer, INITIAL_STATE); + const { selectedIds, isMultiSelectEnabled, isDeleting, isArchiving } = state; + const selectedIdsRef = useRef(selectedIds); + useLayoutEffect(() => { + selectedIdsRef.current = selectedIds; + }); + const isProcessing = isDeleting || isArchiving; + const { setSelectedIds, setIsMultiSelectEnabled, setIsDeleting, setIsArchiving } = + useMultiSelectDispatchers(dispatch); useEffect(() => { dispatch({ type: "reset" }); }, [workflowId]); const { moveTaskById, deleteTaskById, archiveTaskById } = useTaskActions(); - const { removeTasksFromStore, applyMoveInStore, getWorkflowIdForTask, sortByDisplayOrder } = - useTaskMultiSelectStore(); + const removeTasksFromSnapshots = useCallback( + (ids: Set) => removeTasksFromWorkflowSnapshotQueries(queryClient, ids), + [queryClient], + ); + const applyMoveInSnapshots = useCallback( + (ids: Set, stepId: string) => applyMoveInQuerySnapshots(queryClient, ids, stepId), + [queryClient], + ); + const resolveWorkflowIdForTask = useCallback( + (taskId: string) => getWorkflowIdForTask(snapshots, taskId, workflowId), + [snapshots, workflowId], + ); + const sortSelectedIdsByDisplayOrder = useCallback( + (ids: string[]) => sortByDisplayOrder(snapshots, ids), + [snapshots], + ); const toggleSelect = useCallback( (taskId: string) => dispatch({ type: "toggle_select", taskId }), @@ -374,10 +355,10 @@ export function useTaskMultiSelect(workflowId: string | null) { moveTaskById, deleteTaskById, archiveTaskById, - removeTasksFromStore, - applyMoveInStore, - getWorkflowIdForTask, - sortByDisplayOrder, + removeTasksFromSnapshots, + applyMoveInSnapshots, + resolveWorkflowIdForTask, + sortSelectedIdsByDisplayOrder, }); return { diff --git a/apps/web/hooks/use-task-removal.test.ts b/apps/web/hooks/use-task-removal.test.ts index 3453b623a2..4206d14447 100644 --- a/apps/web/hooks/use-task-removal.test.ts +++ b/apps/web/hooks/use-task-removal.test.ts @@ -1,6 +1,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; import type { StoreApi } from "zustand"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import { LOCATION_CHANGE_EVENT } from "@/lib/routing/navigation-event"; +import { sessionId, taskId, workspaceId, workflowId } from "@/lib/types/ids"; +import type { Task, TaskSession, WorkflowSnapshot } from "@/lib/types/http"; const replaceTaskUrlMock = vi.fn(); const performLayoutSwitchMock = vi.fn(); @@ -18,6 +25,14 @@ vi.mock("@/lib/api", () => ({ listTaskSessions: (...args: unknown[]) => listTaskSessionsMock(...args), })); +vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(), + listSessionTurns: vi.fn(), + listTaskSessionMessages: vi.fn(), + listTaskSessions: (...args: unknown[]) => listTaskSessionsMock(...args), + searchSessionMessages: vi.fn(), +})); + import { useTaskRemoval } from "./use-task-removal"; import { setRecentTasks } from "@/lib/recent-tasks"; @@ -25,17 +40,15 @@ type TaskRow = { id: string; primarySessionId: string | null }; type FakeState = { tasks: { activeTaskId: string | null; activeSessionId: string | null }; - kanban: { tasks: TaskRow[] }; - kanbanMulti: { snapshots: Record }; + workflowSnapshots: Record; environmentIdBySessionId: Record; taskSessionsByTask: { - itemsByTaskId: Record; + itemsByTaskId: Record; loadedByTaskId: Record; loadingByTaskId: Record; }; setActiveTask: ReturnType; setActiveSession: ReturnType; - setWorkflowSnapshot: ReturnType; setTaskSessionsForTask: ReturnType; setTaskSessionsLoading: ReturnType; }; @@ -50,10 +63,7 @@ function makeStore(init: { activeTaskId: init.activeTaskId, activeSessionId: init.activeSessionId ?? null, }, - kanban: { tasks: [] }, - kanbanMulti: { - snapshots: { "wf-1": { tasks: init.remainingTasks } }, - }, + workflowSnapshots: { "wf-1": { tasks: init.remainingTasks } }, environmentIdBySessionId: { "sess-next": "env-next", "sess-A": "env-A" }, taskSessionsByTask: { itemsByTaskId: {}, @@ -62,9 +72,6 @@ function makeStore(init: { }, setActiveTask: vi.fn() as ReturnType, setActiveSession: vi.fn() as ReturnType, - setWorkflowSnapshot: vi.fn((wfId: string, snapshot: { tasks: TaskRow[] }) => { - state.kanbanMulti.snapshots[wfId] = snapshot; - }) as unknown as ReturnType, setTaskSessionsForTask: vi.fn() as ReturnType, setTaskSessionsLoading: vi.fn() as ReturnType, }; @@ -90,6 +97,88 @@ function makeStore(init: { const nextTask: TaskRow = { id: "task-next", primarySessionId: "sess-next" }; const recentTaskId = "task-recent"; const recentSessionId = "sess-recent"; +const sessionTimestamp = "2026-06-24T00:00:00Z"; + +function makeSession( + id: string, + taskIdValue: string, + overrides: Partial = {}, +): TaskSession { + return { + id: sessionId(id), + task_id: taskId(taskIdValue), + state: "CREATED", + started_at: sessionTimestamp, + updated_at: sessionTimestamp, + ...overrides, + } as TaskSession; +} + +function renderTaskRemoval(store: StoreApi) { + const client = makeQueryClient(); + const fakeState = ( + store as StoreApi & { getRecorded?: () => FakeState } + ).getRecorded?.(); + for (const [workflowId, snapshot] of Object.entries(fakeState?.workflowSnapshots ?? {})) { + client.setQueryData( + qk.workflows.snapshot(workflowId), + makeWorkflowSnapshot( + snapshot.tasks.map((task) => makeTask(task.id, task.primarySessionId)), + workflowId, + ), + ); + } + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); + const hook = renderHook(() => useTaskRemoval({ store: store as unknown as StoreApi }), { + wrapper, + }); + return { ...hook, queryClient: client }; +} + +function makeTask(id: string, primarySessionId: string | null = null): Task { + return { + id: taskId(id), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-1", + position: 0, + title: id, + description: "", + state: "TODO", + priority: 0, + repositories: [], + primary_session_id: primarySessionId ? sessionId(primarySessionId) : null, + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + } as Task; +} + +function makeWorkflowSnapshot(tasks: Task[], workflowIdValue = "wf-1"): WorkflowSnapshot { + return { + workflow: { + id: workflowId(workflowIdValue), + workspace_id: workspaceId("ws-1"), + name: "Workflow", + sort_order: 0, + hidden: false, + }, + steps: [ + { + id: "step-1", + workflow_id: workflowId(workflowIdValue), + name: "Todo", + position: 0, + color: "bg-blue-500", + }, + ], + tasks, + } as WorkflowSnapshot; +} + +function queryTaskIds(snapshot: WorkflowSnapshot | undefined): string[] { + return snapshot?.tasks.map((task) => task.id) ?? []; +} beforeEach(() => { vi.clearAllMocks(); @@ -159,9 +248,7 @@ describe("useTaskRemoval — switch guard (current store wins)", () => { activeSessionId: "sess-A", remainingTasks: [{ id: "task-A", primarySessionId: "sess-A" }, nextTask], }); - const { result } = renderHook(() => - useTaskRemoval({ store: store as unknown as StoreApi }), - ); + const { result } = renderTaskRemoval(store); await result.current.removeTaskFromBoard("task-A", { wasActiveTaskId: "task-A", @@ -178,9 +265,7 @@ describe("useTaskRemoval — switch guard (current store wins)", () => { activeSessionId: "sess-B", remainingTasks: [{ id: "task-B", primarySessionId: "sess-B" }, nextTask], }); - const { result } = renderHook(() => - useTaskRemoval({ store: store as unknown as StoreApi }), - ); + const { result } = renderTaskRemoval(store); await result.current.removeTaskFromBoard("task-A", { wasActiveTaskId: "task-A", @@ -193,6 +278,40 @@ describe("useTaskRemoval — switch guard (current store wins)", () => { }); }); +describe("useTaskRemoval — session hydration", () => { + it("awaits Query hydration instead of returning partial navigation rows", async () => { + const store = makeStore({ + activeTaskId: "task-A", + activeSessionId: "sess-A", + remainingTasks: [{ id: "task-A", primarySessionId: "sess-A" }], + }); + const partialSession = makeSession("sess-next", "task-next", { + task_environment_id: "env-next", + }); + const hydratedSession = makeSession("sess-next", "task-next", { + task_environment_id: "env-next", + is_passthrough: true, + agent_profile_snapshot: { cli_passthrough: true }, + }); + store.getRecorded().taskSessionsByTask.itemsByTaskId["task-next"] = [partialSession]; + store.getRecorded().taskSessionsByTask.loadedByTaskId["task-next"] = true; + listTaskSessionsMock.mockResolvedValueOnce({ sessions: [hydratedSession], total: 1 }); + + const { result, queryClient } = renderTaskRemoval(store); + queryClient.setQueryData(qk.taskSession.byTask("task-next"), { + sessions: [partialSession], + total: 1, + }); + const sessions = await result.current.loadTaskSessionsForTask("task-next"); + + expect(listTaskSessionsMock).toHaveBeenCalledWith("task-next", expect.any(Object)); + expect(store.getRecorded().setTaskSessionsForTask).toHaveBeenCalledWith("task-next", [ + hydratedSession, + ]); + expect(sessions).toEqual([hydratedSession]); + }); +}); + describe("useTaskRemoval — switch guard (WS-clear fallback)", () => { it("switches when WS cleared activeTaskId AND wasActiveTaskId matches removed task", async () => { const store = makeStore({ @@ -200,9 +319,7 @@ describe("useTaskRemoval — switch guard (WS-clear fallback)", () => { activeSessionId: null, remainingTasks: [nextTask], }); - const { result } = renderHook(() => - useTaskRemoval({ store: store as unknown as StoreApi }), - ); + const { result } = renderTaskRemoval(store); await result.current.removeTaskFromBoard("task-A", { wasActiveTaskId: "task-A", @@ -219,9 +336,7 @@ describe("useTaskRemoval — switch guard (WS-clear fallback)", () => { activeSessionId: null, remainingTasks: [nextTask], }); - const { result } = renderHook(() => - useTaskRemoval({ store: store as unknown as StoreApi }), - ); + const { result } = renderTaskRemoval(store); await result.current.removeTaskFromBoard("task-A"); @@ -236,9 +351,7 @@ describe("useTaskRemoval — switch guard (WS-clear fallback)", () => { activeSessionId: null, remainingTasks: [nextTask], }); - const { result } = renderHook(() => - useTaskRemoval({ store: store as unknown as StoreApi }), - ); + const { result } = renderTaskRemoval(store); await result.current.removeTaskFromBoard("task-A", { wasActiveTaskId: "task-B", @@ -250,42 +363,26 @@ describe("useTaskRemoval — switch guard (WS-clear fallback)", () => { expect(replaceTaskUrlMock).not.toHaveBeenCalled(); }); - it("redirects to / when no remaining tasks AND user still on removed task", async () => { + it("replaces the SPA route with / when no remaining tasks AND user still on removed task", async () => { const store = makeStore({ activeTaskId: "task-A", activeSessionId: "sess-A", remainingTasks: [{ id: "task-A", primarySessionId: "sess-A" }], }); - const hrefSetter = vi.fn(); - const originalLocation = window.location; - Object.defineProperty(window, "location", { - configurable: true, - value: { - get href() { - return ""; - }, - set href(value: string) { - hrefSetter(value); - }, - }, + const locationChanged = vi.fn(); + window.history.replaceState({}, "", "/t/task-A"); + window.addEventListener(LOCATION_CHANGE_EVENT, locationChanged); + + const { result } = renderTaskRemoval(store); + const removeResult = await result.current.removeTaskFromBoard("task-A", { + wasActiveTaskId: "task-A", + wasActiveSessionId: "sess-A", }); - try { - const { result } = renderHook(() => - useTaskRemoval({ store: store as unknown as StoreApi }), - ); - const removeResult = await result.current.removeTaskFromBoard("task-A", { - wasActiveTaskId: "task-A", - wasActiveSessionId: "sess-A", - }); - expect(removeResult.switchedTaskId).toBeNull(); - expect(hrefSetter).toHaveBeenCalledWith("/"); - } finally { - Object.defineProperty(window, "location", { - configurable: true, - value: originalLocation, - }); - } + window.removeEventListener(LOCATION_CHANGE_EVENT, locationChanged); + expect(removeResult.switchedTaskId).toBeNull(); + expect(window.location.pathname).toBe("/"); + expect(locationChanged).toHaveBeenCalledOnce(); }); }); @@ -294,9 +391,7 @@ describe("useTaskRemoval — next task selection", () => { const store = makeStoreWithOldAndRecentTasks("task-A"); setRecentTaskFirst(); - const { result } = renderHook(() => - useTaskRemoval({ store: store as unknown as StoreApi }), - ); + const { result } = renderTaskRemoval(store); await result.current.removeTaskFromBoard("task-A", { wasActiveTaskId: "task-A", @@ -314,9 +409,7 @@ describe("useTaskRemoval — next task selection", () => { const store = makeStoreWithOldAndRecentTasks("task-A"); setRemovedTaskFirst(); - const { result } = renderHook(() => - useTaskRemoval({ store: store as unknown as StoreApi }), - ); + const { result } = renderTaskRemoval(store); await result.current.removeTaskFromBoard("task-A", { wasActiveTaskId: "task-A", @@ -334,9 +427,7 @@ describe("useTaskRemoval — next task selection", () => { const store = makeStoreWithOldAndRecentTasks("task-A"); setRemovedTaskFirst(); - const { result } = renderHook(() => - useTaskRemoval({ store: store as unknown as StoreApi }), - ); + const { result } = renderTaskRemoval(store); await result.current.removeTaskFromBoard("task-A", { wasActiveTaskId: "task-A", @@ -344,7 +435,6 @@ describe("useTaskRemoval — next task selection", () => { switchOnly: true, }); - expect(store.getRecorded().setWorkflowSnapshot).not.toHaveBeenCalled(); expect(store.getRecorded().setActiveSession).toHaveBeenCalledWith( recentTaskId, recentSessionId, @@ -352,3 +442,67 @@ describe("useTaskRemoval — next task selection", () => { expect(replaceTaskUrlMock).toHaveBeenCalledWith(recentTaskId); }); }); + +describe("useTaskRemoval — workflow snapshot Query cache", () => { + it("does not expose a legacy active kanban task mirror", async () => { + const store = makeStore({ + activeTaskId: "task-other", + activeSessionId: "sess-A", + remainingTasks: [], + }); + const { result, queryClient } = renderTaskRemoval(store); + queryClient.setQueryData( + qk.workflows.snapshot("wf-1"), + makeWorkflowSnapshot([makeTask("task-A", "sess-A"), makeTask("task-next", "sess-next")]), + ); + + await result.current.removeTaskFromBoard("task-A", { + wasActiveTaskId: "task-A", + wasActiveSessionId: "sess-A", + }); + + expect("kanban" in store.getRecorded()).toBe(false); + }); + + it("does not select the next task from a removed active kanban fallback", async () => { + const store = makeStore({ + activeTaskId: "task-A", + activeSessionId: "sess-A", + remainingTasks: [], + }); + window.history.replaceState({}, "", "/t/task-A"); + const { result } = renderTaskRemoval(store); + + await result.current.removeTaskFromBoard("task-A", { + wasActiveTaskId: "task-A", + wasActiveSessionId: "sess-A", + }); + + expect(store.getRecorded().setActiveSession).not.toHaveBeenCalled(); + expect(store.getRecorded().setActiveTask).not.toHaveBeenCalled(); + expect(window.location.pathname).toBe("/"); + }); + + it("removes the task from cached workflow snapshots without a store mirror", async () => { + const store = makeStore({ + activeTaskId: "task-other", + activeSessionId: "sess-A", + remainingTasks: [], + }); + const { result, queryClient } = renderTaskRemoval(store); + queryClient.setQueryData( + qk.workflows.snapshot("wf-1"), + makeWorkflowSnapshot([makeTask("task-A", "sess-A"), makeTask("task-next", "sess-next")]), + ); + + await result.current.removeTaskFromBoard("task-A", { + wasActiveTaskId: "task-A", + wasActiveSessionId: "sess-A", + }); + + expect( + queryTaskIds(queryClient.getQueryData(qk.workflows.snapshot("wf-1"))), + ).toEqual(["task-next"]); + expect("setWorkflowSnapshot" in store.getRecorded()).toBe(false); + }); +}); diff --git a/apps/web/hooks/use-task-removal.ts b/apps/web/hooks/use-task-removal.ts index 9c1d50bbf9..8400bb5598 100644 --- a/apps/web/hooks/use-task-removal.ts +++ b/apps/web/hooks/use-task-removal.ts @@ -1,10 +1,18 @@ import { useCallback } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import type { KanbanState } from "@/lib/state/slices"; import type { TaskSession } from "@/lib/types/http"; +import { workflowSnapshotToKanbanData } from "@/lib/kanban/snapshot"; import { replaceTaskUrl } from "@/lib/links"; -import { listTaskSessions } from "@/lib/api"; +import { taskSessionsQueryOptions } from "@/lib/query/query-options"; +import { taskSessionsAreNavigationReady } from "@/lib/session/task-session-navigation"; +import { + removeTasksFromWorkflowSnapshotQueries, + workflowSnapshotQueryData, +} from "@/lib/query/workflow-snapshot-cache"; +import { useRouter } from "@/lib/routing/client-router"; import { performLayoutSwitch } from "@/lib/state/dockview-store"; import { getRecentTasks } from "@/lib/recent-tasks"; @@ -34,25 +42,26 @@ type RemoveFromBoardResult = { switchedTaskId: string | null; }; -function cachedSessionsHaveEnvIds(sessions: TaskSession[]): boolean { - return sessions.length === 0 || sessions.every((session) => !!session.task_environment_id); -} - async function loadTaskSessionsForTaskFromStore( store: StoreApi, taskId: string, + queryClient: QueryClient, ): Promise { const state = store.getState(); const cachedSessions = state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; if (state.taskSessionsByTask.loadedByTaskId[taskId]) { - if (cachedSessionsHaveEnvIds(cachedSessions)) return cachedSessions; - } - if (state.taskSessionsByTask.loadingByTaskId[taskId]) { - return cachedSessions; + if (taskSessionsAreNavigationReady(cachedSessions)) return cachedSessions; } + // Do not return partial WS-seeded rows while a Query fetch is in flight. + // fetchQuery dedupes against the active request and resolves with the + // canonical API payload, including fields needed for layout decisions such + // as is_passthrough. store.getState().setTaskSessionsLoading(taskId, true); try { - const response = await listTaskSessions(taskId, { cache: "no-store" }); + const response = await queryClient.fetchQuery({ + ...taskSessionsQueryOptions(taskId), + staleTime: 0, + }); store.getState().setTaskSessionsForTask(taskId, response.sessions ?? []); return response.sessions ?? []; } catch (error) { @@ -64,39 +73,17 @@ async function loadTaskSessionsForTaskFromStore( } } -function removeTaskFromSnapshots(store: StoreApi, taskId: string): void { - const currentSnapshots = store.getState().kanbanMulti.snapshots; - for (const [wfId, snapshot] of Object.entries(currentSnapshots)) { - const hadTask = snapshot.tasks.some((t: KanbanState["tasks"][number]) => t.id === taskId); - if (hadTask) { - store.getState().setWorkflowSnapshot(wfId, { - ...snapshot, - tasks: snapshot.tasks.filter((t: KanbanState["tasks"][number]) => t.id !== taskId), - }); - } - } - - const currentKanbanTasks = store.getState().kanban.tasks; - if (currentKanbanTasks.some((t: KanbanState["tasks"][number]) => t.id === taskId)) { - store.setState((state) => ({ - ...state, - kanban: { - ...state.kanban, - tasks: state.kanban.tasks.filter((t: KanbanState["tasks"][number]) => t.id !== taskId), - }, - })); - } +function removeTaskFromSnapshots(queryClient: QueryClient, taskId: string): void { + removeTasksFromWorkflowSnapshotQueries(queryClient, new Set([taskId])); } -function collectRemainingTasks(store: StoreApi): KanbanState["tasks"] { - const allRemainingTasks: KanbanState["tasks"] = []; - for (const snapshot of Object.values(store.getState().kanbanMulti.snapshots)) { - allRemainingTasks.push(...snapshot.tasks); - } - if (allRemainingTasks.length === 0) { - allRemainingTasks.push(...store.getState().kanban.tasks); - } - return allRemainingTasks; +function collectRemainingTasks( + store: StoreApi, + queryClient: QueryClient, +): KanbanState["tasks"] { + return workflowSnapshotQueryData(queryClient).flatMap( + (snapshot) => workflowSnapshotToKanbanData(snapshot).tasks, + ); } function selectNextTaskAfterRemoval( @@ -202,9 +189,11 @@ function shouldSwitchAfterRemoval( * Used by both TaskSessionSidebar and SessionTaskSwitcherSheet. */ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOptions) { + const queryClient = useQueryClient(); + const router = useRouter(); const loadTaskSessionsForTask = useCallback( - (taskId: string) => loadTaskSessionsForTaskFromStore(store, taskId), - [store], + (taskId: string) => loadTaskSessionsForTaskFromStore(store, taskId, queryClient), + [store, queryClient], ); /** @@ -220,8 +209,8 @@ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOp */ const removeTaskFromBoard = useCallback( async (taskId: string, opts?: RemoveFromBoardOptions): Promise => { - if (!opts?.switchOnly) removeTaskFromSnapshots(store, taskId); - const allRemainingTasks = collectRemainingTasks(store); + if (!opts?.switchOnly) removeTaskFromSnapshots(queryClient, taskId); + const allRemainingTasks = collectRemainingTasks(store, queryClient); if (!shouldSwitchAfterRemoval(store, taskId, opts)) { return { switchedTaskId: null }; @@ -240,10 +229,10 @@ export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOp return { switchedTaskId: nextTask.id }; } - window.location.href = "/"; + router.replace("/"); return { switchedTaskId: null }; }, - [store, useLayoutSwitch, loadTaskSessionsForTask], + [store, queryClient, router, useLayoutSwitch, loadTaskSessionsForTask], ); return { removeTaskFromBoard, loadTaskSessionsForTask }; diff --git a/apps/web/hooks/use-task-repositories.ts b/apps/web/hooks/use-task-repositories.ts index a64cb0738b..e824ff95c3 100644 --- a/apps/web/hooks/use-task-repositories.ts +++ b/apps/web/hooks/use-task-repositories.ts @@ -1,12 +1,10 @@ -import { useAppStore } from "@/components/state-provider"; +import { useAllCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; import { useTask } from "@/hooks/use-task"; -import type { Repository } from "@/lib/types/http"; export function useTaskRepositories(taskId: string | null) { const task = useTask(taskId); - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); + const repositories = useAllCachedRepositories(); if (!task?.repositoryId) return []; - const repositories = Object.values(repositoriesByWorkspace).flat() as Repository[]; - return repositories.filter((repo: Repository) => repo.id === task.repositoryId); + return repositories.filter((repo) => repo.id === task.repositoryId); } diff --git a/apps/web/hooks/use-task-session.ts b/apps/web/hooks/use-task-session.ts index 83f7148865..3d15532467 100644 --- a/apps/web/hooks/use-task-session.ts +++ b/apps/web/hooks/use-task-session.ts @@ -1,8 +1,9 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { getWebSocketClient } from "@/lib/ws/connection"; +import { taskSessionsQueryOptions } from "@/lib/query/query-options"; /** * Custom hook that centralizes task session fetching logic. @@ -12,69 +13,22 @@ import { getWebSocketClient } from "@/lib/ws/connection"; * @returns Object with sessionId, hasSession flag, and isLoading state */ export function useTaskSession(taskId: string | null) { + const sessionsQuery = useQuery(taskSessionsQueryOptions(taskId ?? "")); const sessionsFromStore = useAppStore((state) => taskId ? state.taskSessionsByTask.itemsByTaskId[taskId] : null, ); - const [fetchedSessionId, setFetchedSessionId] = useState(null); - const [isLoading, setIsLoading] = useState(false); - // Derive the session ID from store first, fall back to fetched value - const sessionIdFromStore = useMemo(() => { - if (!sessionsFromStore || sessionsFromStore.length === 0) return null; - const primary = sessionsFromStore.find((s) => s.is_primary); - return (primary ?? sessionsFromStore[0])?.id ?? null; - }, [sessionsFromStore]); - - const finalSessionId = sessionIdFromStore ?? fetchedSessionId; - - useEffect(() => { - // If we have session in store or no taskId, don't fetch - if (sessionIdFromStore || !taskId) { - return; - } - - // Fetch if not in store and we have a taskId - let isActive = true; - const fetchSession = async () => { - const client = getWebSocketClient(); - if (!client) { - if (isActive) { - setFetchedSessionId(null); - setIsLoading(false); - } - return; - } - - try { - setIsLoading(true); - const response = await client.request<{ sessions: Array<{ id: string }> }>( - "task.session.list", - { task_id: taskId }, - 10000, - ); - if (isActive) { - setFetchedSessionId(response.sessions[0]?.id ?? null); - setIsLoading(false); - } - } catch { - if (isActive) { - setFetchedSessionId(null); - setIsLoading(false); - } - } - }; - - fetchSession(); - - return () => { - isActive = false; - }; - }, [taskId, sessionIdFromStore]); + const finalSessionId = useMemo(() => { + const sessions = sessionsQuery.data?.sessions ?? sessionsFromStore; + if (!sessions || sessions.length === 0) return null; + const primary = sessions.find((s) => s.is_primary); + return (primary ?? sessions[0])?.id ?? null; + }, [sessionsFromStore, sessionsQuery.data?.sessions]); return { sessionId: taskId ? finalSessionId : null, hasSession: !!taskId && !!finalSessionId, - isLoading: taskId && !sessionIdFromStore ? isLoading : false, + isLoading: Boolean(taskId && sessionsQuery.isFetching && !finalSessionId), }; } diff --git a/apps/web/hooks/use-task-sessions.test.ts b/apps/web/hooks/use-task-sessions.test.ts index 1c207153ed..539b6512eb 100644 --- a/apps/web/hooks/use-task-sessions.test.ts +++ b/apps/web/hooks/use-task-sessions.test.ts @@ -1,4 +1,6 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TaskSession } from "@/lib/types/http"; import { sessionId, taskId } from "@/lib/types/ids"; @@ -24,12 +26,24 @@ vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: MockTaskSessionsState) => unknown) => selector(mockState), })); -vi.mock("@/lib/api", () => apiMock); +vi.mock("@/lib/api/domains/session-api", () => apiMock); import { useTaskSessions } from "./use-task-sessions"; const TASK_ID = taskId("task-1"); +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function wrapper(client = makeQueryClient()) { + return function TestWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + function session(id: string, state: TaskSession["state"] = "RUNNING"): TaskSession { return { id: sessionId(id), @@ -40,6 +54,12 @@ function session(id: string, state: TaskSession["state"] = "RUNNING"): TaskSessi }; } +function expectQueryOptions() { + return expect.objectContaining({ + init: expect.objectContaining({ signal: expect.any(Object) }), + }); +} + function setDocumentVisibility(value: DocumentVisibilityState) { Object.defineProperty(document, "visibilityState", { configurable: true, @@ -82,27 +102,27 @@ describe("useTaskSessions", () => { }); it("loads sessions on mount", async () => { - renderHook(() => useTaskSessions(TASK_ID)); + renderHook(() => useTaskSessions(TASK_ID), { wrapper: wrapper() }); await waitFor(() => - expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, { - cache: "no-store", - }), + expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, expectQueryOptions()), + ); + await waitFor(() => + expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [session("sess-1")]), ); - expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [session("sess-1")]); }); it("loads sessions on mount when the WebSocket is disconnected", async () => { mockState.connection.status = "disconnected"; - renderHook(() => useTaskSessions(TASK_ID)); + renderHook(() => useTaskSessions(TASK_ID), { wrapper: wrapper() }); await waitFor(() => - expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, { - cache: "no-store", - }), + expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, expectQueryOptions()), + ); + await waitFor(() => + expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [session("sess-1")]), ); - expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [session("sess-1")]); }); }); @@ -125,7 +145,7 @@ describe("useTaskSessions refreshes", () => { mockState.taskSessionsByTask.loadedByTaskId[TASK_ID] = true; apiMock.listTaskSessions.mockResolvedValueOnce({ sessions: [session("old", "COMPLETED")] }); - const { rerender } = renderHook(() => useTaskSessions(TASK_ID)); + const { rerender } = renderHook(() => useTaskSessions(TASK_ID), { wrapper: wrapper() }); await act(async () => {}); expect(apiMock.listTaskSessions).not.toHaveBeenCalled(); @@ -133,9 +153,7 @@ describe("useTaskSessions refreshes", () => { rerender(); await waitFor(() => - expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, { - cache: "no-store", - }), + expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, expectQueryOptions()), ); expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [ session("old", "COMPLETED"), @@ -150,16 +168,14 @@ describe("useTaskSessions refreshes", () => { mockState.taskSessionsByTask.loadedByTaskId[TASK_ID] = true; apiMock.listTaskSessions.mockRejectedValueOnce(error); - const { rerender } = renderHook(() => useTaskSessions(TASK_ID)); + const { rerender } = renderHook(() => useTaskSessions(TASK_ID), { wrapper: wrapper() }); await act(async () => {}); mockState.connection.status = "connected"; rerender(); await waitFor(() => - expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, { - cache: "no-store", - }), + expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, expectQueryOptions()), ); expect(mockState.setTaskSessionsForTask).not.toHaveBeenCalled(); expect(consoleError).toHaveBeenCalledWith("Failed to load task sessions:", error); @@ -171,7 +187,9 @@ describe("useTaskSessions refreshes", () => { mockState.taskSessionsByTask.loadingByTaskId[TASK_ID] = true; apiMock.listTaskSessions.mockResolvedValueOnce({ sessions: [session("old", "COMPLETED")] }); - const { result, rerender } = renderHook(() => useTaskSessions(TASK_ID)); + const { result, rerender } = renderHook(() => useTaskSessions(TASK_ID), { + wrapper: wrapper(), + }); let resolved = false; const queuedReload = result.current.loadSessions(true).then(() => { resolved = true; @@ -197,7 +215,7 @@ describe("useTaskSessions refreshes", () => { mockState.taskSessionsByTask.loadingByTaskId[TASK_ID] = true; apiMock.listTaskSessions.mockResolvedValueOnce({ sessions: [session("old", "COMPLETED")] }); - const { rerender } = renderHook(() => useTaskSessions(TASK_ID)); + const { rerender } = renderHook(() => useTaskSessions(TASK_ID), { wrapper: wrapper() }); await act(async () => {}); mockState.connection.status = "connected"; rerender(); @@ -210,9 +228,7 @@ describe("useTaskSessions refreshes", () => { }); await waitFor(() => - expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, { - cache: "no-store", - }), + expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, expectQueryOptions()), ); expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [ session("old", "COMPLETED"), @@ -244,7 +260,9 @@ describe("useTaskSessions queued refreshes", () => { .mockReturnValueOnce(firstResponse.promise) .mockResolvedValueOnce({ sessions: [session("old", "COMPLETED")] }); - const { result, rerender } = renderHook(() => useTaskSessions(TASK_ID)); + const { result, rerender } = renderHook(() => useTaskSessions(TASK_ID), { + wrapper: wrapper(), + }); const firstReload = result.current.loadSessions(true); rerender(); let queuedResolved = false; @@ -284,16 +302,14 @@ describe("useTaskSessions foreground refreshes", () => { mockState.taskSessionsByTask.loadedByTaskId[TASK_ID] = true; apiMock.listTaskSessions.mockResolvedValueOnce({ sessions: [session("old", "COMPLETED")] }); - renderHook(() => useTaskSessions(TASK_ID)); + renderHook(() => useTaskSessions(TASK_ID), { wrapper: wrapper() }); await act(async () => {}); expect(apiMock.listTaskSessions).not.toHaveBeenCalled(); document.dispatchEvent(new Event("visibilitychange")); await waitFor(() => - expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, { - cache: "no-store", - }), + expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, expectQueryOptions()), ); expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [ session("old", "COMPLETED"), @@ -306,7 +322,7 @@ describe("useTaskSessions foreground refreshes", () => { mockState.taskSessionsByTask.loadingByTaskId[TASK_ID] = true; apiMock.listTaskSessions.mockResolvedValueOnce({ sessions: [session("old", "COMPLETED")] }); - const { rerender } = renderHook(() => useTaskSessions(TASK_ID)); + const { rerender } = renderHook(() => useTaskSessions(TASK_ID), { wrapper: wrapper() }); await act(async () => {}); document.dispatchEvent(new Event("visibilitychange")); await act(async () => {}); @@ -318,9 +334,7 @@ describe("useTaskSessions foreground refreshes", () => { }); await waitFor(() => - expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, { - cache: "no-store", - }), + expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, expectQueryOptions()), ); expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [ session("old", "COMPLETED"), @@ -331,7 +345,7 @@ describe("useTaskSessions foreground refreshes", () => { mockState.taskSessionsByTask.loadingByTaskId[TASK_ID] = true; apiMock.listTaskSessions.mockResolvedValueOnce({ sessions: [session("old", "COMPLETED")] }); - const { rerender } = renderHook(() => useTaskSessions(TASK_ID)); + const { rerender } = renderHook(() => useTaskSessions(TASK_ID), { wrapper: wrapper() }); await act(async () => {}); document.dispatchEvent(new Event("visibilitychange")); await act(async () => {}); @@ -344,9 +358,7 @@ describe("useTaskSessions foreground refreshes", () => { }); await waitFor(() => - expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, { - cache: "no-store", - }), + expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, expectQueryOptions()), ); expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [ session("old", "COMPLETED"), @@ -359,14 +371,12 @@ describe("useTaskSessions foreground refreshes", () => { mockState.taskSessionsByTask.loadedByTaskId[TASK_ID] = true; apiMock.listTaskSessions.mockResolvedValueOnce({ sessions: [session("old", "COMPLETED")] }); - renderHook(() => useTaskSessions(TASK_ID)); + renderHook(() => useTaskSessions(TASK_ID), { wrapper: wrapper() }); await act(async () => {}); document.dispatchEvent(new Event("visibilitychange")); await waitFor(() => - expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, { - cache: "no-store", - }), + expect(apiMock.listTaskSessions).toHaveBeenCalledWith(TASK_ID, expectQueryOptions()), ); expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [ session("old", "COMPLETED"), diff --git a/apps/web/hooks/use-task-sessions.test.tsx b/apps/web/hooks/use-task-sessions.test.tsx new file mode 100644 index 0000000000..b47aeef9c4 --- /dev/null +++ b/apps/web/hooks/use-task-sessions.test.tsx @@ -0,0 +1,94 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { TaskSession } from "@/lib/types/http"; + +const TASK_ID = "task-1"; +const SESSION_ID = "session-1"; +const TIMESTAMP = "2026-06-24T00:00:00Z"; +const mockListTaskSessions = vi.fn(); + +type MockState = { + connection: { status: string }; + taskSessionsByTask: { + itemsByTaskId: Record; + loadingByTaskId: Record; + loadedByTaskId: Record; + }; + setTaskSessionsForTask: ReturnType; + setTaskSessionsLoading: ReturnType; +}; + +let mockState: MockState; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/lib/api/domains/session-api", () => ({ + listTaskSessions: (...args: unknown[]) => mockListTaskSessions(...args), +})); + +import { useTaskSessions } from "./use-task-sessions"; + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} + +function wrapper(client = makeQueryClient()) { + return function TestWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + +function makeSession(overrides: Partial = {}): TaskSession { + return { + id: SESSION_ID, + task_id: TASK_ID, + state: "WAITING_FOR_INPUT", + started_at: TIMESTAMP, + updated_at: TIMESTAMP, + ...overrides, + } as TaskSession; +} + +describe("useTaskSessions", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockState = { + connection: { status: "connected" }, + taskSessionsByTask: { + itemsByTaskId: {}, + loadingByTaskId: {}, + loadedByTaskId: {}, + }, + setTaskSessionsForTask: vi.fn(), + setTaskSessionsLoading: vi.fn(), + }; + }); + + it("mirrors query-owned task session metadata into the legacy session store", async () => { + const session = makeSession({ + metadata: { + last_agent_error: { + message: "peer disconnected before response", + occurred_at: TIMESTAMP, + }, + plan_mode: true, + }, + }); + mockListTaskSessions.mockResolvedValue({ sessions: [session] }); + + const { result } = renderHook(() => useTaskSessions(TASK_ID), { + wrapper: wrapper(), + }); + + await waitFor(() => + expect(mockState.setTaskSessionsForTask).toHaveBeenCalledWith(TASK_ID, [session]), + ); + expect(result.current.sessions).toEqual([session]); + }); +}); diff --git a/apps/web/hooks/use-task-sessions.ts b/apps/web/hooks/use-task-sessions.ts index e78de0bde3..bffa3dd776 100644 --- a/apps/web/hooks/use-task-sessions.ts +++ b/apps/web/hooks/use-task-sessions.ts @@ -1,25 +1,47 @@ import { useCallback, useEffect, useRef } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { listTaskSessions } from "@/lib/api"; +import { taskSessionsQueryOptions } from "@/lib/query/query-options"; import type { TaskSession } from "@/lib/types/http"; const EMPTY_SESSIONS: TaskSession[] = []; +function resolvePendingForcedReloadWaiters(waiters: Array<() => void>) { + waiters.splice(0).forEach((resolve) => resolve()); +} + export function useTaskSessions(taskId: string | null) { - const sessions = useAppStore((state) => + const queryClient = useQueryClient(); + const storeSessions = useAppStore((state) => taskId ? (state.taskSessionsByTask.itemsByTaskId[taskId] ?? EMPTY_SESSIONS) : EMPTY_SESSIONS, ); - const isLoading = useAppStore((state) => + const storeIsLoading = useAppStore((state) => taskId ? (state.taskSessionsByTask.loadingByTaskId[taskId] ?? false) : false, ); - const isLoaded = useAppStore((state) => + const storeIsLoaded = useAppStore((state) => taskId ? (state.taskSessionsByTask.loadedByTaskId[taskId] ?? false) : false, ); const setTaskSessionsForTask = useAppStore((state) => state.setTaskSessionsForTask); const setTaskSessionsLoading = useAppStore((state) => state.setTaskSessionsLoading); const connectionStatus = useAppStore((state) => state.connection.status); + const sessionsQuery = useQuery({ + ...taskSessionsQueryOptions(taskId ?? ""), + enabled: Boolean(taskId && !storeIsLoaded && !storeIsLoading), + }); const pendingForcedReloadRef = useRef(false); const pendingForcedReloadWaitersRef = useRef void>>([]); + const sessions = taskId ? (sessionsQuery.data?.sessions ?? storeSessions) : EMPTY_SESSIONS; + const isLoading = taskId ? sessionsQuery.isFetching || storeIsLoading : false; + const isLoaded = taskId ? sessionsQuery.isSuccess || storeIsLoaded : false; + + useEffect(() => { + if (taskId && sessionsQuery.data) + setTaskSessionsForTask(taskId, sessionsQuery.data.sessions ?? []); + }, [sessionsQuery.data, setTaskSessionsForTask, taskId]); + + useEffect(() => { + if (taskId) setTaskSessionsLoading(taskId, sessionsQuery.isFetching); + }, [sessionsQuery.isFetching, setTaskSessionsLoading, taskId]); const loadSessions = useCallback( async (force = false) => { @@ -34,23 +56,20 @@ export function useTaskSessions(taskId: string | null) { return; } if (!force && isLoaded) return; - setTaskSessionsLoading(taskId, true); try { - const response = await listTaskSessions(taskId, { cache: "no-store" }); - const sessions = response.sessions ?? []; - setTaskSessionsForTask(taskId, sessions); + setTaskSessionsLoading(taskId, true); + await queryClient.fetchQuery({ ...taskSessionsQueryOptions(taskId), staleTime: 0 }); } catch (error) { console.error("Failed to load task sessions:", error); if (!force) setTaskSessionsForTask(taskId, []); } finally { setTaskSessionsLoading(taskId, false); if (force && !pendingForcedReloadRef.current) { - const waiters = pendingForcedReloadWaitersRef.current.splice(0); - waiters.forEach((resolve) => resolve()); + resolvePendingForcedReloadWaiters(pendingForcedReloadWaitersRef.current); } } }, - [isLoaded, isLoading, setTaskSessionsForTask, setTaskSessionsLoading, taskId], + [isLoaded, isLoading, queryClient, setTaskSessionsForTask, setTaskSessionsLoading, taskId], ); useEffect(() => { @@ -61,8 +80,7 @@ export function useTaskSessions(taskId: string | null) { useEffect(() => { pendingForcedReloadRef.current = false; - const waiters = pendingForcedReloadWaitersRef.current.splice(0); - waiters.forEach((resolve) => resolve()); + resolvePendingForcedReloadWaiters(pendingForcedReloadWaitersRef.current); }, [taskId]); useEffect(() => { diff --git a/apps/web/hooks/use-task.test.ts b/apps/web/hooks/use-task.test.ts new file mode 100644 index 0000000000..a8b448bef8 --- /dev/null +++ b/apps/web/hooks/use-task.test.ts @@ -0,0 +1,73 @@ +import { createElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id"; +import { qk } from "@/lib/query/keys"; +import { taskId, workspaceId, workflowId } from "@/lib/types/ids"; +import type { Task } from "@/lib/types/http"; +import { useTask } from "./use-task"; + +const fetchTaskMock = vi.fn(); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: (...args: unknown[]) => fetchTaskMock(...args), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => null, +})); + +function makeTask(id: string, title: string): Task { + return { + id: taskId(id), + workspace_id: workspaceId("ws-1"), + workflow_id: workflowId("wf-1"), + workflow_step_id: "step-1", + position: 1, + title, + description: "Task description", + state: "CREATED", + priority: 0, + repositories: [], + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + }; +} + +function createQueryWrapper(tasks: Task[]) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + for (const task of tasks) { + client.setQueryData(qk.tasks.detail(task.id), task); + } + return function QueryWrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + }; +} + +describe("useTask", () => { + it("resolves a task from the Query cache without requiring the Zustand store", () => { + const task = makeTask("task-1", "Cached task"); + const { result } = renderHook(() => useTask("task-1"), { + wrapper: createQueryWrapper([task]), + }); + + expect(result.current?.id).toBe("task-1"); + expect(result.current?.title).toBe("Cached task"); + expect(result.current?.workflowStepId).toBe("step-1"); + }); +}); + +describe("useTaskById", () => { + it("resolves a task from the Query cache without requiring the Zustand store", () => { + const task = makeTask("task-2", "Sender task"); + const { result } = renderHook(() => useTaskById("task-2"), { + wrapper: createQueryWrapper([task]), + }); + + expect(result.current?.id).toBe("task-2"); + expect(result.current?.title).toBe("Sender task"); + }); +}); diff --git a/apps/web/hooks/use-task.ts b/apps/web/hooks/use-task.ts index 90df4fdcc8..cc4520fbf6 100644 --- a/apps/web/hooks/use-task.ts +++ b/apps/web/hooks/use-task.ts @@ -1,22 +1,13 @@ import { useEffect } from "react"; -import { useAppStore } from "@/components/state-provider"; +import { useQuery } from "@tanstack/react-query"; import { getWebSocketClient } from "@/lib/ws/connection"; -import { findTaskInSnapshots } from "@/lib/kanban/find-task"; -import type { KanbanState } from "@/lib/state/slices"; - -type Task = KanbanState["tasks"][number]; +import { toKanbanTask } from "@/lib/kanban/map-task"; +import { taskQueryOptions } from "@/lib/query/query-options"; export function useTask(taskId: string | null) { - // The active workflow's tasks live in `kanban.tasks`, but cross-workflow - // tasks (PR-review boards, multi-workflow swimlanes) live in - // `kanbanMulti.snapshots[*].tasks`. Mirror the lookup used by - // `KanbanWithPreview.useSelectedTask` so consumers like the chat panel - // can still resolve the task description for cross-workflow previews. - const task = useAppStore((state) => { - if (!taskId) return null; - const fromActive = state.kanban.tasks.find((item: Task) => item.id === taskId); - if (fromActive) return fromActive; - return findTaskInSnapshots(taskId, state.kanbanMulti.snapshots); + const query = useQuery({ + ...taskQueryOptions(taskId ?? ""), + enabled: Boolean(taskId), }); useEffect(() => { @@ -29,5 +20,5 @@ export function useTask(taskId: string | null) { }; }, [taskId]); - return task; + return query.data ? toKanbanTask(query.data) : null; } diff --git a/apps/web/hooks/use-tasks.test.ts b/apps/web/hooks/use-tasks.test.ts index 24987e05fe..8b403dc778 100644 --- a/apps/web/hooks/use-tasks.test.ts +++ b/apps/web/hooks/use-tasks.test.ts @@ -1,121 +1,153 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { qk } from "@/lib/query/keys"; +import { + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, + type Task, + type WorkflowSnapshot, +} from "@/lib/types/http"; +import { useTasks } from "./use-tasks"; -const mockHydrate = vi.fn(); -const mockSetState = vi.fn(); const mockFetchWorkflowSnapshot = vi.fn(); -type Task = { id: string; title: string }; -type MockState = { - connection: { status: string }; - kanban: { workflowId: string | null; tasks: Task[]; steps: unknown[]; isLoading?: boolean }; - hydrate: typeof mockHydrate; -}; - -let mockState: MockState = { - connection: { status: "connected" }, - kanban: { workflowId: null, tasks: [], steps: [] }, - hydrate: mockHydrate, -}; - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (s: MockState) => unknown) => selector(mockState), - useAppStoreApi: () => ({ - getState: () => mockState, - setState: (updater: (s: MockState) => MockState) => { - // Apply the updater so useWorkflowSnapshot's isLoading transitions are - // visible to subsequent reads in the same render cycle. Without this - // the mock would silently swallow side effects. - mockState = updater(mockState); - mockSetState(mockState); - }, - }), -})); - -vi.mock("@/lib/api", () => ({ +vi.mock("@/lib/api/domains/kanban-api", () => ({ fetchWorkflowSnapshot: (...args: unknown[]) => mockFetchWorkflowSnapshot(...args), })); -vi.mock("@/lib/ssr/mapper", () => ({ - snapshotToState: (snapshot: unknown) => ({ snapshot }), -})); +const WORKFLOW_ID = toWorkflowId("wf-1"); +const WORKSPACE_ID = toWorkspaceId("workspace-1"); +const STEP_ID = "step-1"; +const CREATED_AT = "2026-06-24T00:00:00Z"; -import { useTasks } from "./use-tasks"; +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + const tree = createElement(QueryClientProvider, { client: queryClient }, children); + return createElement(StateProvider, { children: tree }); + }; +} + +function task(id: string, title: string): Task { + return { + id: toTaskId(id), + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: STEP_ID, + position: 0, + title, + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: CREATED_AT, + updated_at: CREATED_AT, + }; +} -function setMockState(patch: Partial & { workflowId?: string | null }) { - mockState = { - ...mockState, - kanban: { ...mockState.kanban, ...patch }, +function snapshot(tasks: Task[]): WorkflowSnapshot { + return { + workflow: { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 0, + hidden: false, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + steps: [ + { + id: STEP_ID, + workflow_id: WORKFLOW_ID, + name: "Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks, }; } -describe("useTasks — loading and matching", () => { +describe("useTasks", () => { beforeEach(() => { - vi.clearAllMocks(); - // Never resolves so we observe pre-resolution loading state. - mockFetchWorkflowSnapshot.mockReturnValue(new Promise(() => {})); - mockState = { - connection: { status: "connected" }, - kanban: { workflowId: null, tasks: [], steps: [], isLoading: false }, - hydrate: mockHydrate, - }; + mockFetchWorkflowSnapshot.mockReset(); }); - it("returns empty list and not-loading when workflowId is null", () => { - const { result } = renderHook(() => useTasks(null)); - expect(result.current.tasks).toEqual([]); - expect(result.current.isLoading).toBe(false); + afterEach(() => { + cleanup(); }); - it("reports loading once useWorkflowSnapshot has flipped kanban.isLoading", () => { - setMockState({ workflowId: null, isLoading: true }); - const { result } = renderHook(() => useTasks("wf-1")); - expect(result.current.tasks).toEqual([]); - expect(result.current.isLoading).toBe(true); - }); + it("returns empty list and not-loading when workflowId is null", () => { + const { result } = renderHook(() => useTasks(null), { + wrapper: wrapperFor(createQueryClient()), + }); - it("does not report loading when fetch has settled but workflowId mismatches", () => { - // Simulates a failed snapshot fetch — `kanban.isLoading` is back to false - // and `kanban.workflowId` never advanced to the requested id. Caller - // shows the empty-state UI rather than an infinite skeleton. - setMockState({ workflowId: null, isLoading: false }); - const { result } = renderHook(() => useTasks("wf-1")); expect(result.current.tasks).toEqual([]); expect(result.current.isLoading).toBe(false); + expect(mockFetchWorkflowSnapshot).not.toHaveBeenCalled(); }); - it("returns tasks and not-loading once kanban.workflowId matches", () => { - setMockState({ - workflowId: "wf-1", - tasks: [ - { id: "t1", title: "One" }, - { id: "t2", title: "Two" }, - ], - isLoading: false, + it("returns tasks from the workflow snapshot query cache", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), snapshot([task("task-1", "One")])); + + const { result } = renderHook(() => useTasks(WORKFLOW_ID), { + wrapper: wrapperFor(queryClient), }); - const { result } = renderHook(() => useTasks("wf-1")); - expect(result.current.tasks).toHaveLength(2); + + expect(result.current.tasks).toEqual([ + expect.objectContaining({ id: "task-1", title: "One", workflowStepId: STEP_ID }), + ]); expect(result.current.isLoading).toBe(false); }); - it("filters out tasks that belong to a different workflow snapshot", () => { - setMockState({ - workflowId: "wf-other", - tasks: [{ id: "t1", title: "One" }], - isLoading: false, + it("does not surface tasks while Query loads", () => { + mockFetchWorkflowSnapshot.mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useTasks(WORKFLOW_ID), { + wrapper: wrapperFor(createQueryClient()), }); - const { result } = renderHook(() => useTasks("wf-1")); + expect(result.current.tasks).toEqual([]); + expect(result.current.isLoading).toBe(true); + }); + + it("does not show loading when Query already has tasks", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), snapshot([task("task-1", "One")])); + + const { result } = renderHook(() => useTasks(WORKFLOW_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current.tasks).toHaveLength(1); expect(result.current.isLoading).toBe(false); }); - it("respects kanban.isLoading even when workflowId already matches", () => { - setMockState({ - workflowId: "wf-1", - tasks: [{ id: "t1", title: "One" }], - isLoading: true, + it("settles to not-loading when an empty Query snapshot resolves", async () => { + mockFetchWorkflowSnapshot.mockResolvedValue(snapshot([])); + + const { result } = renderHook(() => useTasks(WORKFLOW_ID), { + wrapper: wrapperFor(createQueryClient()), }); - const { result } = renderHook(() => useTasks("wf-1")); - expect(result.current.isLoading).toBe(true); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.tasks).toEqual([]); }); }); diff --git a/apps/web/hooks/use-tasks.ts b/apps/web/hooks/use-tasks.ts index 6823ed26f7..10c537c232 100644 --- a/apps/web/hooks/use-tasks.ts +++ b/apps/web/hooks/use-tasks.ts @@ -1,19 +1,17 @@ import { useMemo } from "react"; import { useWorkflowSnapshot } from "@/hooks/use-workflow-snapshot"; -import { useAppStore } from "@/components/state-provider"; export function useTasks(workflowId: string | null) { - useWorkflowSnapshot(workflowId); + const snapshot = useWorkflowSnapshot(workflowId); - const kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId); - const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false); - const tasks = useAppStore((state) => state.kanban.tasks); - - const matchesActive = !!workflowId && kanbanWorkflowId === workflowId; - const workflowTasks = useMemo(() => (matchesActive ? tasks : []), [matchesActive, tasks]); + const matchesActive = !!workflowId && snapshot.snapshotState?.workflowId === workflowId; + const workflowTasks = useMemo( + () => (matchesActive ? (snapshot.snapshotState?.tasks ?? []) : []), + [matchesActive, snapshot.snapshotState?.tasks], + ); // Loading only while a snapshot fetch is in-flight; settles to false on success/error to avoid an infinite skeleton. - const isLoading = !!workflowId && kanbanIsLoading; + const isLoading = !!workflowId && snapshot.isFetching && workflowTasks.length === 0; return { tasks: workflowTasks, isLoading }; } diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index 11dc1b8715..29052d2ec4 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -1,11 +1,15 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; import { getWebSocketClient } from "@/lib/ws/connection"; import { updateUserSettings } from "@/lib/api"; import { useSearchParams } from "@/lib/routing/client-router"; import { mapSelectedRepositoryIds } from "@/lib/kanban/filters"; import { useAppStore } from "@/components/state-provider"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; -import { useEnsureUserSettings } from "@/hooks/use-ensure-user-settings"; +import { userSettingsQueryOptions } from "@/lib/query/query-options/settings"; +import { mapUserSettingsQueryData } from "@/hooks/domains/settings/user-settings-query-data"; +import { getLocalStorage } from "@/lib/local-storage"; +import { STORAGE_KEYS } from "@/lib/settings/constants"; import { repositoryId, type Repository } from "@/lib/types/http"; import { DEFAULT_TASKS_LIST_GROUP, DEFAULT_TASKS_LIST_SORT } from "@/lib/tasks/tasks-list-options"; import { @@ -81,14 +85,39 @@ function carryForwardSidebarSettings(current: DisplaySettings) { }; } +function emptyTaskCreateLastUsed(): DisplaySettings["taskCreateLastUsed"] { + return { + repositoryId: null, + branch: null, + agentProfileId: null, + executorProfileId: null, + }; +} + +function hasTaskCreateLastUsed(value: DisplaySettings["taskCreateLastUsed"] | undefined) { + return Boolean( + value?.repositoryId || value?.branch || value?.agentProfileId || value?.executorProfileId, + ); +} + +function readCachedTaskCreateLastUsed(): DisplaySettings["taskCreateLastUsed"] { + const cached = { + repositoryId: getLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, null), + branch: getLocalStorage(STORAGE_KEYS.LAST_BRANCH, null), + agentProfileId: getLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null), + executorProfileId: getLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, null), + }; + return hasTaskCreateLastUsed(cached) ? cached : emptyTaskCreateLastUsed(); +} + +function resolveTaskCreateLastUsed(current: DisplaySettings) { + const currentLastUsed = current.taskCreateLastUsed; + return hasTaskCreateLastUsed(currentLastUsed) ? currentLastUsed : readCachedTaskCreateLastUsed(); +} + function carryForwardSyncedLocalSettings(current: DisplaySettings) { return { - taskCreateLastUsed: current.taskCreateLastUsed ?? { - repositoryId: null, - branch: null, - agentProfileId: null, - executorProfileId: null, - }, + taskCreateLastUsed: resolveTaskCreateLastUsed(current), jiraSavedViews: current.jiraSavedViews, jiraTaskPresets: current.jiraTaskPresets, githubSavedPresets: current.githubSavedPresets, @@ -156,6 +185,22 @@ function useUserSettingsRef(userSettings: DisplaySettings) { return userSettingsRef; } +function useLoadUserSettings( + loaded: boolean, + setUserSettings: (settings: DisplaySettings) => void, +) { + const query = useQuery({ ...userSettingsQueryOptions(), enabled: !loaded }); + const mapped = useMemo(() => mapUserSettingsQueryData(query.data), [query.data]); + + useEffect(() => { + if (loaded) return; + if (!mapped) return; + setUserSettings(mapped); + }, [loaded, mapped, setUserSettings]); + + return loaded ? null : mapped; +} + function usePruneStaleRepositoryIds( userSettings: DisplaySettings, repositories: Repository[], @@ -198,10 +243,12 @@ export function useUserDisplaySettings({ const userSettings = useAppStore((state) => state.userSettings); const setUserSettings = useAppStore((state) => state.setUserSettings); const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, true); - const userSettingsRef = useUserSettingsRef(userSettings); + const loadedUserSettings = useLoadUserSettings(userSettings.loaded, setUserSettings); + const effectiveUserSettings = loadedUserSettings ?? userSettings; + const userSettingsRef = useUserSettingsRef(effectiveUserSettings); const routeWorkflowId = useSearchParams().get("workflowId"); - const settingsLoadedOnMountRef = useRef(userSettings.loaded); + const settingsLoadedOnMountRef = useRef(effectiveUserSettings.loaded); const commitSettings = useCallback( (next: CommitPayload) => { @@ -221,61 +268,67 @@ export function useUserDisplaySettings({ [setUserSettings, userSettingsRef], ); - useEnsureUserSettings(); - useEffect(() => { - if (!userSettings.loaded) return; + if (!effectiveUserSettings.loaded) return; if (routeWorkflowId) return; if (settingsLoadedOnMountRef.current) return; settingsLoadedOnMountRef.current = true; - if (userSettings.workspaceId && userSettings.workspaceId !== workspaceId) { - onWorkspaceChange?.(userSettings.workspaceId); + if (effectiveUserSettings.workspaceId && effectiveUserSettings.workspaceId !== workspaceId) { + onWorkspaceChange?.(effectiveUserSettings.workspaceId); } }, [ + effectiveUserSettings.loaded, + effectiveUserSettings.workspaceId, onWorkspaceChange, routeWorkflowId, - userSettings.loaded, - userSettings.workspaceId, workspaceId, ]); useEffect(() => { - if (!userSettings.loaded || !(!userSettings.workspaceId && workspaceId)) return; + if (!effectiveUserSettings.loaded || !(!effectiveUserSettings.workspaceId && workspaceId)) { + return; + } queueMicrotask(() => { commitSettings({ workspaceId, - workflowId: userSettings.workflowId, - repositoryIds: userSettings.repositoryIds, + workflowId: effectiveUserSettings.workflowId, + repositoryIds: effectiveUserSettings.repositoryIds, }); }); }, [ commitSettings, - userSettings.workflowId, - userSettings.loaded, - userSettings.repositoryIds, - userSettings.workspaceId, + effectiveUserSettings.workflowId, + effectiveUserSettings.loaded, + effectiveUserSettings.repositoryIds, + effectiveUserSettings.workspaceId, workspaceId, ]); useEffect(() => { - if (!userSettings.loaded) return; + if (!effectiveUserSettings.loaded) return; if (routeWorkflowId) return; if (settingsLoadedOnMountRef.current) return; - if (userSettings.workflowId && userSettings.workflowId !== workflowId) { - onWorkflowChange?.(userSettings.workflowId); + if (effectiveUserSettings.workflowId && effectiveUserSettings.workflowId !== workflowId) { + onWorkflowChange?.(effectiveUserSettings.workflowId); } - }, [workflowId, onWorkflowChange, routeWorkflowId, userSettings.workflowId, userSettings.loaded]); + }, [ + effectiveUserSettings.loaded, + effectiveUserSettings.workflowId, + workflowId, + onWorkflowChange, + routeWorkflowId, + ]); - usePruneStaleRepositoryIds(userSettings, repositories, commitSettings); + usePruneStaleRepositoryIds(effectiveUserSettings, repositories, commitSettings); - const allRepositoriesSelected = userSettings.repositoryIds.length === 0; + const allRepositoriesSelected = effectiveUserSettings.repositoryIds.length === 0; const selectedRepositoryIds = useMemo( - () => mapSelectedRepositoryIds(repositories, userSettings.repositoryIds), - [repositories, userSettings.repositoryIds], + () => mapSelectedRepositoryIds(repositories, effectiveUserSettings.repositoryIds), + [repositories, effectiveUserSettings.repositoryIds], ); return { - settings: userSettings, + settings: effectiveUserSettings, commitSettings, repositories, repositoriesLoading, diff --git a/apps/web/hooks/use-workflow-cache.ts b/apps/web/hooks/use-workflow-cache.ts new file mode 100644 index 0000000000..3ff3d36b34 --- /dev/null +++ b/apps/web/hooks/use-workflow-cache.ts @@ -0,0 +1,149 @@ +import { useCallback, useRef, useSyncExternalStore } from "react"; +import { useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query"; +import type { Workflow } from "@/lib/types/http"; +import type { WorkflowItem } from "@/lib/state/slices"; + +export type WorkflowsByWorkspace = Record; + +const EMPTY_BY_WORKSPACE: WorkflowsByWorkspace = {}; +const EMPTY_WORKFLOWS: WorkflowItem[] = []; + +type WorkflowCacheSnapshot = { + signature: string; + workflowsByWorkspace: WorkflowsByWorkspace; +}; + +export function mapWorkflowItem(workflow: Workflow | WorkflowItem): WorkflowItem { + if ("workspaceId" in workflow) { + return workflow; + } + return { + id: workflow.id, + workspaceId: workflow.workspace_id, + name: workflow.name, + description: workflow.description ?? null, + sortOrder: workflow.sort_order ?? 0, + ...(workflow.agent_profile_id ? { agent_profile_id: workflow.agent_profile_id } : {}), + ...(workflow.hidden !== undefined ? { hidden: workflow.hidden } : {}), + ...(workflow.style !== undefined ? { style: workflow.style } : {}), + }; +} + +export function useWorkflowsByWorkspace(): WorkflowsByWorkspace { + const queryClient = useQueryClient(); + const snapshotRef = useRef({ + signature: "", + workflowsByWorkspace: EMPTY_BY_WORKSPACE, + }); + const getSnapshot = useCallback(() => { + const snapshot = readWorkflowsByWorkspace(queryClient); + if (snapshot.signature === snapshotRef.current.signature) { + return snapshotRef.current.workflowsByWorkspace; + } + snapshotRef.current = snapshot; + return snapshot.workflowsByWorkspace; + }, [queryClient]); + + return useSyncExternalStore( + (onStoreChange) => queryClient.getQueryCache().subscribe(onStoreChange), + getSnapshot, + () => EMPTY_BY_WORKSPACE, + ); +} + +export function useCachedWorkflows(workspaceId: string | null | undefined): WorkflowItem[] { + const workflowsByWorkspace = useWorkflowsByWorkspace(); + if (!workspaceId) return EMPTY_WORKFLOWS; + return workflowsByWorkspace[workspaceId] ?? EMPTY_WORKFLOWS; +} + +export function useAllCachedWorkflows(): WorkflowItem[] { + return Object.values(useWorkflowsByWorkspace()).flat(); +} + +export function readWorkflowsByWorkspace(queryClient: QueryClient): WorkflowCacheSnapshot { + const queries = queryClient + .getQueryCache() + .findAll() + .filter((query) => isWorkflowListQuery(query.queryKey) && Array.isArray(query.state.data)) + .sort((a, b) => a.state.dataUpdatedAt - b.state.dataUpdatedAt); + + const grouped = new Map>(); + for (const query of queries) { + const workspaceId = query.queryKey[1] as string; + const workspaceWorkflows = grouped.get(workspaceId) ?? new Map(); + for (const workflow of query.state.data as Array) { + const item = mapWorkflowItem(workflow); + workspaceWorkflows.set(item.id, { + ...workspaceWorkflows.get(item.id), + ...item, + }); + } + grouped.set(workspaceId, workspaceWorkflows); + } + + const workflowsByWorkspace = Object.fromEntries( + [...grouped.entries()].map(([workspaceId, workflows]) => [ + workspaceId, + [...workflows.values()], + ]), + ); + + return { + signature: queries + .map( + (query) => `${query.queryHash}:${query.state.dataUpdatedAt}:${query.state.dataUpdateCount}`, + ) + .join("|"), + workflowsByWorkspace, + }; +} + +export function reorderCachedWorkflows( + queryClient: QueryClient, + workspaceId: string, + workflowIds: string[], +) { + const queries = queryClient + .getQueryCache() + .findAll() + .filter((query) => isWorkflowListQuery(query.queryKey) && query.queryKey[1] === workspaceId); + + for (const query of queries) { + queryClient.setQueryData(query.queryKey, (current: unknown) => { + if (!Array.isArray(current)) return current; + const byId = new Map( + current + .map((workflow) => [readWorkflowId(workflow), workflow] as const) + .filter((entry): entry is readonly [string, unknown] => Boolean(entry[0])), + ); + const reordered = workflowIds + .map((id) => byId.get(id)) + .filter((workflow): workflow is NonNullable => workflow != null); + for (const workflow of current) { + const id = readWorkflowId(workflow); + if (!id || workflowIds.includes(id)) continue; + reordered.push(workflow); + } + return reordered; + }); + } +} + +function readWorkflowId(workflow: unknown): string | null { + if (!workflow || typeof workflow !== "object" || !("id" in workflow)) return null; + const id = workflow.id; + return typeof id === "string" ? id : null; +} + +export function isWorkflowListQuery(key: QueryKey): boolean { + return ( + Array.isArray(key) && + key[0] === "workflows" && + typeof key[1] === "string" && + key.length === 3 && + typeof key[2] === "object" && + key[2] !== null && + "includeHidden" in key[2] + ); +} diff --git a/apps/web/hooks/use-workflow-snapshot.query.test.tsx b/apps/web/hooks/use-workflow-snapshot.query.test.tsx new file mode 100644 index 0000000000..73a8570bd8 --- /dev/null +++ b/apps/web/hooks/use-workflow-snapshot.query.test.tsx @@ -0,0 +1,103 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { qk } from "@/lib/query/keys"; +import type { WorkflowSnapshot } from "@/lib/types/http"; +import { useWorkflowSnapshot } from "./use-workflow-snapshot"; + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchWorkflowSnapshot: vi.fn(), +})); + +const WORKFLOW_ID = "workflow-1"; +const WORKSPACE_ID = "workspace-1"; + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function snapshot(): WorkflowSnapshot { + return { + workflow: { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 0, + hidden: false, + }, + steps: [ + { + id: "step-1", + workflow_id: WORKFLOW_ID, + name: "Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks: [ + { + id: "task-1", + workspace_id: WORKSPACE_ID, + workflow_id: WORKFLOW_ID, + workflow_step_id: "step-1", + position: 0, + title: "Cached task", + description: "from query", + state: "TODO", + priority: 0, + repositories: [], + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + }, + ], + } as unknown as WorkflowSnapshot; +} + +describe("useWorkflowSnapshot query ownership", () => { + afterEach(() => { + cleanup(); + }); + + it("returns converted snapshot state from Query cache without a Zustand store", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), snapshot()); + + const { result } = renderHook(() => useWorkflowSnapshot(WORKFLOW_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current.snapshotState).toEqual({ + workflowId: WORKFLOW_ID, + isLoading: false, + steps: [ + expect.objectContaining({ + id: "step-1", + title: "Todo", + }), + ], + tasks: [ + expect.objectContaining({ + id: "task-1", + title: "Cached task", + workflowStepId: "step-1", + state: "TODO", + }), + ], + }); + }); +}); diff --git a/apps/web/hooks/use-workflow-snapshot.test.ts b/apps/web/hooks/use-workflow-snapshot.test.ts index 309420dfd0..6ed705c0b0 100644 --- a/apps/web/hooks/use-workflow-snapshot.test.ts +++ b/apps/web/hooks/use-workflow-snapshot.test.ts @@ -1,146 +1,123 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import type { AppState } from "@/lib/state/store"; +import type { WorkflowSnapshot } from "@/lib/types/http"; +import { useWorkflowSnapshot } from "./use-workflow-snapshot"; -const mockHydrate = vi.fn(); const mockFetchWorkflowSnapshot = vi.fn(); -const mockSetState = vi.fn(); - -type MockKanban = { - workflowId: string | null; - tasks: unknown[]; - steps: unknown[]; - isLoading?: boolean; -}; -type MockState = { - connection: { status: string }; - kanban: MockKanban; - hydrate: typeof mockHydrate; -}; - -let mockState: MockState = { - connection: { status: "connected" }, - kanban: { workflowId: null, tasks: [], steps: [], isLoading: false }, - hydrate: mockHydrate, -}; - -vi.mock("@/components/state-provider", () => ({ - useAppStore: (selector: (s: MockState) => unknown) => selector(mockState), - useAppStoreApi: () => ({ - getState: () => mockState, - setState: (updater: (s: MockState) => MockState) => { - const next = updater(mockState); - mockSetState(next); - mockState = next; - }, - }), -})); -vi.mock("@/lib/api", () => ({ +vi.mock("@/lib/api/domains/kanban-api", () => ({ fetchWorkflowSnapshot: (...args: unknown[]) => mockFetchWorkflowSnapshot(...args), })); -vi.mock("@/lib/ssr/mapper", () => ({ - snapshotToState: (snapshot: unknown) => ({ snapshot }), -})); +const WORKFLOW_ID = "workflow-1"; +const WORKSPACE_ID = "workspace-1"; + +function makeSnapshot(workflowId: string): WorkflowSnapshot { + return { + workflow: { + id: workflowId, + workspace_id: WORKSPACE_ID, + name: workflowId, + sort_order: 0, + hidden: false, + }, + steps: [ + { + id: "step-1", + workflow_id: workflowId, + name: "Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks: [ + { + id: "task-1", + workspace_id: WORKSPACE_ID, + workflow_id: workflowId, + workflow_step_id: "step-1", + position: 0, + title: "Task", + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + }, + ], + } as unknown as WorkflowSnapshot; +} -import { useWorkflowSnapshot } from "./use-workflow-snapshot"; +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); +} -function resetState(kanban: Partial = {}) { - vi.clearAllMocks(); - mockState = { - connection: { status: "connected" }, - kanban: { - workflowId: null, - tasks: [], - steps: [], - isLoading: false, - ...kanban, - }, - hydrate: mockHydrate, +function wrapperFor(queryClient: QueryClient, initialState?: Partial) { + return function Wrapper({ children }: { children: ReactNode }) { + const tree = createElement(QueryClientProvider, { client: queryClient }, children); + if (!initialState) return tree; + return createElement(StateProvider, { initialState, children: tree }); }; } -describe("useWorkflowSnapshot — kanban.isLoading", () => { - beforeEach(() => resetState()); - - it("flips isLoading true while a fetch for an un-hydrated workflow is in flight", () => { - mockFetchWorkflowSnapshot.mockReturnValue(new Promise(() => {})); - renderHook(() => useWorkflowSnapshot("wf-1")); - expect(mockState.kanban.isLoading).toBe(true); +describe("useWorkflowSnapshot", () => { + beforeEach(() => { + mockFetchWorkflowSnapshot.mockReset(); }); - it("flips isLoading back to false after the fetch resolves", async () => { - mockFetchWorkflowSnapshot.mockResolvedValue({ steps: [], tasks: [] }); - renderHook(() => useWorkflowSnapshot("wf-1")); - await waitFor(() => expect(mockHydrate).toHaveBeenCalled()); - await waitFor(() => expect(mockState.kanban.isLoading).toBe(false)); + afterEach(() => { + cleanup(); }); - it("flips isLoading back to false after the fetch rejects", async () => { - mockFetchWorkflowSnapshot.mockRejectedValue(new Error("nope")); - renderHook(() => useWorkflowSnapshot("wf-1")); - await waitFor(() => expect(mockState.kanban.isLoading).toBe(false)); - expect(mockHydrate).not.toHaveBeenCalled(); - }); + it("cold-loads and converts a workflow snapshot through the query option", async () => { + mockFetchWorkflowSnapshot.mockResolvedValue(makeSnapshot(WORKFLOW_ID)); + const queryClient = createQueryClient(); - it("does not flip isLoading true if the requested workflow is already hydrated", () => { - resetState({ workflowId: "wf-1", isLoading: false }); - mockFetchWorkflowSnapshot.mockReturnValue(new Promise(() => {})); - renderHook(() => useWorkflowSnapshot("wf-1")); - expect(mockState.kanban.isLoading).toBe(false); - }); + const { result } = renderHook(() => useWorkflowSnapshot(WORKFLOW_ID), { + wrapper: wrapperFor(queryClient), + }); - it("does not fetch on initial mount if the requested workflow snapshot is boot-hydrated", () => { - resetState({ workflowId: "wf-1", steps: [{ id: "step-1" }], tasks: [], isLoading: false }); - renderHook(() => useWorkflowSnapshot("wf-1")); - expect(mockFetchWorkflowSnapshot).not.toHaveBeenCalled(); - expect(mockState.kanban.isLoading).toBe(false); + await waitFor(() => expect(result.current.snapshotState?.workflowId).toBe(WORKFLOW_ID)); + expect(result.current.snapshotState?.steps[0]?.title).toBe("Todo"); + expect(result.current.snapshotState?.tasks[0]?.title).toBe("Task"); + expect(mockFetchWorkflowSnapshot).toHaveBeenCalledWith(WORKFLOW_ID, expect.anything()); }); - it("does not clear isLoading on settle when it didn't raise the flag (already-hydrated re-fetch)", async () => { - // Mimic a workspace switch having set isLoading=true after the snapshot - // hydrated for this workflowId. A silent re-fetch (e.g. WS reconnect) - // must not collapse that skeleton. - resetState({ workflowId: "wf-1", isLoading: true }); - mockFetchWorkflowSnapshot.mockResolvedValue({ steps: [], tasks: [] }); - renderHook(() => useWorkflowSnapshot("wf-1")); - await waitFor(() => expect(mockHydrate).toHaveBeenCalled()); - expect(mockState.kanban.isLoading).toBe(true); + it("does not return boot-hydrated store data while Query loads", () => { + mockFetchWorkflowSnapshot.mockReturnValue(new Promise(() => {})); + const queryClient = createQueryClient(); + const initialState = { + kanban: { + workflowId: WORKFLOW_ID, + isLoading: false, + steps: [{ id: "step-1", title: "Todo", color: "bg-blue-500", position: 0 }], + tasks: [{ id: "task-1", workflowStepId: "step-1", title: "Task", position: 0 }], + }, + } as Partial; + + const { result } = renderHook(() => useWorkflowSnapshot(WORKFLOW_ID), { + wrapper: wrapperFor(queryClient, initialState), + }); + + expect(result.current.snapshotState).toBeNull(); }); it("does nothing when workflowId is null", () => { - renderHook(() => useWorkflowSnapshot(null)); - expect(mockFetchWorkflowSnapshot).not.toHaveBeenCalled(); - expect(mockState.kanban.isLoading).toBe(false); - }); + const queryClient = createQueryClient(); - it("does not clear isLoading when an old fetch settles after workflowId changes", async () => { - // First fetch never settles synchronously — we resolve it manually - // *after* re-rendering with a new workflowId, simulating the race where - // the user switches workflows mid-fetch. - let resolveFirst!: (snapshot: { steps: unknown[]; tasks: unknown[] }) => void; - const firstFetch = new Promise<{ steps: unknown[]; tasks: unknown[] }>((r) => { - resolveFirst = r; + const { result } = renderHook(() => useWorkflowSnapshot(null), { + wrapper: wrapperFor(queryClient), }); - const secondFetch = new Promise<{ steps: unknown[]; tasks: unknown[] }>(() => {}); - mockFetchWorkflowSnapshot.mockReturnValueOnce(firstFetch).mockReturnValueOnce(secondFetch); - const { rerender } = renderHook(({ id }: { id: string | null }) => useWorkflowSnapshot(id), { - initialProps: { id: "wf-1" as string | null }, - }); - expect(mockState.kanban.isLoading).toBe(true); - - // User switches to wf-2 before wf-1 finishes loading - rerender({ id: "wf-2" }); - expect(mockState.kanban.isLoading).toBe(true); - - // Old fetch lands now; flush its .then/.finally microtask chain so we - // can assert the negatives without relying on waitFor (which would - // resolve immediately for never-true assertions). - resolveFirst({ steps: [], tasks: [] }); - await Promise.resolve(); - await Promise.resolve(); - expect(mockHydrate).not.toHaveBeenCalled(); - expect(mockState.kanban.isLoading).toBe(true); + expect(mockFetchWorkflowSnapshot).not.toHaveBeenCalled(); + expect(result.current.snapshotState).toBeNull(); }); }); diff --git a/apps/web/hooks/use-workflow-snapshot.ts b/apps/web/hooks/use-workflow-snapshot.ts index d36d8b07f6..52ce31452e 100644 --- a/apps/web/hooks/use-workflow-snapshot.ts +++ b/apps/web/hooks/use-workflow-snapshot.ts @@ -1,46 +1,34 @@ -import { useEffect, useRef } from "react"; -import { fetchWorkflowSnapshot } from "@/lib/api"; -import { snapshotToState } from "@/lib/ssr/mapper"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useEffect, useMemo, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useOptionalAppStore } from "@/components/state-provider"; +import { workflowSnapshotQueryOptions } from "@/lib/query/query-options"; +import { workflowSnapshotToKanbanState } from "@/lib/kanban/snapshot"; export function useWorkflowSnapshot(workflowId: string | null) { - const store = useAppStoreApi(); - const connectionStatus = useAppStore((state) => state.connection.status); - const skippedInitialHydratedRef = useRef(false); + const connectionStatus = useOptionalAppStore((state) => state.connection.status, "connected"); + const lastConnectedRef = useRef(connectionStatus === "connected"); + + const query = useQuery({ + ...workflowSnapshotQueryOptions(workflowId ?? ""), + enabled: Boolean(workflowId), + }); + + const snapshotState = useMemo(() => { + if (query.data) return workflowSnapshotToKanbanState(query.data); + return null; + }, [query.data]); useEffect(() => { - if (!workflowId) return; - let cancelled = false; - const existing = store.getState().kanban; - if ( - !skippedInitialHydratedRef.current && - existing.workflowId === workflowId && - (existing.steps.length > 0 || existing.tasks.length > 0) - ) { - skippedInitialHydratedRef.current = true; - return; - } - const setLoading = store.getState().kanban.workflowId !== workflowId; - if (setLoading) { - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } })); - } - fetchWorkflowSnapshot(workflowId, { cache: "no-store" }) - .then((snapshot) => { - if (cancelled) return; - store.getState().hydrate(snapshotToState(snapshot)); - }) - .catch((error) => { - // Suppress superseded-fetch noise; retry happens on WS reconnect. - if (cancelled) return; - console.warn("[useWorkflowSnapshot] failed to load snapshot:", error); - }) - .finally(() => { - // Only clear the flag this effect raised; skip when cancelled or when a concurrent caller owns it. - if (cancelled || !setLoading) return; - store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } })); - }); - return () => { - cancelled = true; - }; - }, [workflowId, store, connectionStatus]); + const wasConnected = lastConnectedRef.current; + const isConnected = connectionStatus === "connected"; + lastConnectedRef.current = isConnected; + const becameConnected = !wasConnected && isConnected; + if (!workflowId || !becameConnected || !query.isFetched) return; + void query.refetch(); + // query.refetch is stable for this observer; including the whole query + // object would make the reconnect guard run on unrelated query updates. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectionStatus, workflowId]); + + return { ...query, snapshotState }; } diff --git a/apps/web/hooks/use-workflow-steps.test.ts b/apps/web/hooks/use-workflow-steps.test.ts index c50b298fed..27a59af63c 100644 --- a/apps/web/hooks/use-workflow-steps.test.ts +++ b/apps/web/hooks/use-workflow-steps.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const listWorkflowStepsMock = vi.fn(); @@ -13,9 +15,15 @@ beforeEach(() => { listWorkflowStepsMock.mockReset(); }); +function createWrapper() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); +} + describe("useWorkflowSteps", () => { it("returns empty steps and skips fetching when workflowId is empty", async () => { - const { result } = renderHook(() => useWorkflowSteps("")); + const { result } = renderHook(() => useWorkflowSteps(""), { wrapper: createWrapper() }); expect(result.current.steps).toEqual([]); expect(result.current.loading).toBe(false); // Give any (unwanted) fetch a chance to fire. @@ -30,7 +38,9 @@ describe("useWorkflowSteps", () => { { id: "s1", name: "First", position: 1 }, ], }); - const { result } = renderHook(() => useWorkflowSteps("wf-1")); + const { result } = renderHook(() => useWorkflowSteps("wf-1"), { + wrapper: createWrapper(), + }); // loading starts true on initial render with a truthy workflowId so the // dropdown can show "Loading steps…" before the fetch lands. expect(result.current.loading).toBe(true); @@ -47,6 +57,7 @@ describe("useWorkflowSteps", () => { }); const { result, rerender } = renderHook(({ id }: { id: string }) => useWorkflowSteps(id), { initialProps: { id: "wf-1" }, + wrapper: createWrapper(), }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.steps).toHaveLength(1); @@ -71,7 +82,9 @@ describe("useWorkflowSteps", () => { it("clears steps and stops loading when the fetch rejects", async () => { listWorkflowStepsMock.mockRejectedValueOnce(new Error("network down")); - const { result } = renderHook(() => useWorkflowSteps("wf-err")); + const { result } = renderHook(() => useWorkflowSteps("wf-err"), { + wrapper: createWrapper(), + }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.steps).toEqual([]); }); diff --git a/apps/web/hooks/use-workflow-steps.ts b/apps/web/hooks/use-workflow-steps.ts index 4a3d0c02fc..f460d7e185 100644 --- a/apps/web/hooks/use-workflow-steps.ts +++ b/apps/web/hooks/use-workflow-steps.ts @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useState } from "react"; -import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { workflowStepsQueryOptions } from "@/lib/query/query-options"; export type WorkflowStepOption = { id: string; name: string }; @@ -19,45 +20,13 @@ export function useWorkflowSteps(workflowId: string): { steps: WorkflowStepOption[]; loading: boolean; } { - const [steps, setSteps] = useState([]); - // Initialize loading to match the effect's behaviour on first render: if - // workflowId is truthy at mount we'll fetch immediately, so the dropdown - // should show "Loading steps…" rather than "No steps in this workflow" - // before the fetch lands. Only the setState-during-render guard below ever - // toggles loading back on for subsequent workflowId changes. - const [loading, setLoading] = useState(!!workflowId); - const [prevWorkflowId, setPrevWorkflowId] = useState(workflowId); + const query = useQuery(workflowStepsQueryOptions(workflowId)); + const steps = useMemo( + () => (query.data ?? []).map((step) => ({ id: step.id, name: step.name })), + [query.data], + ); - // setState-during-render is the React-blessed way to derive state from a - // changing input without a useEffect race. React drops the in-progress - // render and re-renders with the new state immediately. - if (prevWorkflowId !== workflowId) { - setPrevWorkflowId(workflowId); - setSteps([]); - setLoading(!!workflowId); - } - - useEffect(() => { - if (!workflowId) return; - let cancelled = false; - listWorkflowSteps(workflowId) - .then((res) => { - if (cancelled) return; - const sorted = [...res.steps].sort((a, b) => a.position - b.position); - setSteps(sorted.map((s) => ({ id: s.id, name: s.name }))); - }) - .catch(() => { - if (!cancelled) setSteps([]); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [workflowId]); - - return { steps, loading }; + return { steps, loading: Boolean(workflowId) && query.isFetching && steps.length === 0 }; } // stepPlaceholder picks the right empty-state text for the step Select based diff --git a/apps/web/hooks/use-workflows.test.ts b/apps/web/hooks/use-workflows.test.ts index a9084f6dfd..e47e77837b 100644 --- a/apps/web/hooks/use-workflows.test.ts +++ b/apps/web/hooks/use-workflows.test.ts @@ -1,157 +1,112 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; - -const mockListWorkflows = vi.fn(); -const mockSetWorkflows = vi.fn(); +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import { createElement, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { listWorkflows } from "@/lib/api/domains/kanban-api"; +import { qk } from "@/lib/query/keys"; +import { workflowId, workspaceId as toWorkspaceId, type Workflow } from "@/lib/types/http"; +import { useEnsureWorkspaceWorkflows } from "./use-workflows"; type MockState = { - workflows: { items: Array<{ id: string; workspaceId: string; name: string }> }; workspaces: { activeId: string | null }; - setWorkflows: typeof mockSetWorkflows; }; let mockState: MockState = { - workflows: { items: [] }, workspaces: { activeId: null }, - setWorkflows: mockSetWorkflows, }; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: MockState) => unknown) => selector(mockState), })); -vi.mock("@/lib/api", () => ({ - listWorkflows: (...args: unknown[]) => mockListWorkflows(...args), +vi.mock("@/lib/api/domains/kanban-api", () => ({ + listWorkflows: vi.fn(), })); -import { useWorkflows, useEnsureWorkspaceWorkflows } from "./use-workflows"; +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; +} -function makeWorkflow(id: string, workspaceId: string) { +function workflow(id: string, workspaceId: string): Workflow { return { - id, - workspace_id: workspaceId, + id: workflowId(id), + workspace_id: toWorkspaceId(workspaceId), name: id, description: null, sort_order: 0, - agent_profile_id: null, hidden: false, - style: null, + created_at: "", + updated_at: "", }; } -describe("useWorkflows — stale response guard", () => { +describe("useEnsureWorkspaceWorkflows", () => { beforeEach(() => { - vi.clearAllMocks(); - mockState = { - workflows: { items: [] }, - workspaces: { activeId: null }, - setWorkflows: mockSetWorkflows, - }; + vi.mocked(listWorkflows).mockReset(); + vi.mocked(listWorkflows).mockResolvedValue({ workflows: [], total: 0 }); + mockState = { workspaces: { activeId: "ws-A" } }; }); - it("discards a stale in-flight response when the workspace switches mid-fetch", async () => { - let resolveStale: (v: unknown) => void = () => {}; - mockListWorkflows.mockImplementationOnce( - () => - new Promise((res) => { - resolveStale = res; - }), - ); - - const { rerender } = renderHook( - ({ workspaceId }: { workspaceId: string | null }) => useWorkflows(workspaceId, true), - { initialProps: { workspaceId: "ws-A" } }, - ); - await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); - - // Switch to workspace B before A's fetch resolves; B resolves first. - mockListWorkflows.mockResolvedValueOnce({ workflows: [makeWorkflow("wf-B", "ws-B")] }); - rerender({ workspaceId: "ws-B" }); - await waitFor(() => - expect(mockSetWorkflows).toHaveBeenCalledWith([expect.objectContaining({ id: "wf-B" })]), - ); - - // Now let A resolve. It must NOT overwrite the store with A's workflows. - resolveStale({ workflows: [makeWorkflow("wf-A", "ws-A")] }); - for (let i = 0; i < 5; i++) await Promise.resolve(); - - const written = mockSetWorkflows.mock.calls.map((call) => call[0]); - const wroteA = written.some( - (list: Array<{ id: string }>) => list.length > 0 && list.some((w) => w.id === "wf-A"), - ); - expect(wroteA).toBe(false); + afterEach(() => { + cleanup(); }); - it("does not clear workflows when a stale fetch fails after the workspace switched", async () => { - let rejectStale: (e: Error) => void = () => {}; - mockListWorkflows.mockImplementationOnce( - () => - new Promise((_res, rej) => { - rejectStale = rej; - }), - ); + it("warms the active workspace workflow query on mount", async () => { + const queryClient = createQueryClient(); + const workflows = [workflow("wf-A", "ws-A")]; + vi.mocked(listWorkflows).mockResolvedValueOnce({ workflows, total: 1 }); - const { rerender } = renderHook( - ({ workspaceId }: { workspaceId: string | null }) => useWorkflows(workspaceId, true), - { initialProps: { workspaceId: "ws-A" } }, - ); - await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); + renderHook(() => useEnsureWorkspaceWorkflows(), { wrapper: wrapperFor(queryClient) }); - // Switch to workspace B; B resolves with data. - mockListWorkflows.mockResolvedValueOnce({ workflows: [makeWorkflow("wf-B", "ws-B")] }); - rerender({ workspaceId: "ws-B" }); await waitFor(() => - expect(mockSetWorkflows).toHaveBeenCalledWith([expect.objectContaining({ id: "wf-B" })]), + expect(listWorkflows).toHaveBeenCalledWith( + "ws-A", + expect.objectContaining({ includeHidden: true }), + ), ); - - // A's fetch fails after B already succeeded. Catch must NOT wipe the store. - rejectStale(new Error("network")); - for (let i = 0; i < 5; i++) await Promise.resolve(); - - const cleared = mockSetWorkflows.mock.calls.some( - (call) => Array.isArray(call[0]) && call[0].length === 0, + await waitFor(() => + expect(queryClient.getQueryData(qk.workflows.all("ws-A", { includeHidden: true }))).toEqual( + workflows, + ), ); - expect(cleared).toBe(false); }); - it("does not clear hydrated workflows when the fetch for the current workspace fails", async () => { - // The sidebar mounts on every route (task detail, settings, ...) and boot - // hydrates `state.workflows.items` before the sidebar's refresh fetch - // fires. If that fetch flakes, blowing the store away leaves the sidebar, - // board, and kanban scoping with no workflow IDs until another success. - mockListWorkflows.mockRejectedValueOnce(new Error("network")); - - renderHook(() => useWorkflows("ws-A", true)); + it("warms the next workspace when the active workspace changes", async () => { + const queryClient = createQueryClient(); + vi.mocked(listWorkflows) + .mockResolvedValueOnce({ workflows: [workflow("wf-A", "ws-A")], total: 1 }) + .mockResolvedValueOnce({ workflows: [workflow("wf-B", "ws-B")], total: 1 }); - await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); - for (let i = 0; i < 5; i++) await Promise.resolve(); + const { rerender } = renderHook(() => useEnsureWorkspaceWorkflows(), { + wrapper: wrapperFor(queryClient), + }); + await waitFor(() => expect(listWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); - expect(mockSetWorkflows).not.toHaveBeenCalled(); - }); -}); + mockState = { workspaces: { activeId: "ws-B" } }; + rerender(); -describe("useEnsureWorkspaceWorkflows", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockListWorkflows.mockResolvedValue({ workflows: [] }); - mockState = { - workflows: { items: [] }, - workspaces: { activeId: "ws-A" }, - setWorkflows: mockSetWorkflows, - }; + await waitFor(() => expect(listWorkflows).toHaveBeenCalledWith("ws-B", expect.anything())); }); - it("fetches workflows for the store's active workspace on mount", async () => { - renderHook(() => useEnsureWorkspaceWorkflows()); - await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); - }); + it("does not fetch while the active workspace is unresolved", () => { + mockState = { workspaces: { activeId: null } }; + const queryClient = createQueryClient(); - it("re-fetches when the store's active workspace changes", async () => { - const { rerender } = renderHook(() => useEnsureWorkspaceWorkflows()); - await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); + renderHook(() => useEnsureWorkspaceWorkflows(), { wrapper: wrapperFor(queryClient) }); - mockState = { ...mockState, workspaces: { activeId: "ws-B" } }; - rerender(); - await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-B", expect.anything())); + expect(listWorkflows).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/hooks/use-workflows.test.tsx b/apps/web/hooks/use-workflows.test.tsx new file mode 100644 index 0000000000..58ec81a891 --- /dev/null +++ b/apps/web/hooks/use-workflows.test.tsx @@ -0,0 +1,95 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { listWorkflows } from "@/lib/api/domains/kanban-api"; +import { qk } from "@/lib/query/keys"; +import type { Workflow } from "@/lib/types/http"; +import { useWorkflows } from "./use-workflows"; + +const WORKSPACE_ID = "workspace-1"; + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + listWorkflows: vi.fn(), +})); + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: Infinity, + }, + }, + }); +} + +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +function workflow(id: string, name: string, sortOrder = 0): Workflow { + return { + id, + workspace_id: WORKSPACE_ID, + name, + description: null, + sort_order: sortOrder, + hidden: false, + } as Workflow; +} + +describe("useWorkflows", () => { + beforeEach(() => { + vi.mocked(listWorkflows).mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("reads workspace workflows from the Query cache without a Zustand store", () => { + const queryClient = createQueryClient(); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [ + workflow("workflow-1", "Build", 10), + ]); + + const { result } = renderHook(() => useWorkflows(WORKSPACE_ID), { + wrapper: wrapperFor(queryClient), + }); + + expect(result.current).toEqual({ + workflows: [ + expect.objectContaining({ + id: "workflow-1", + workspaceId: WORKSPACE_ID, + name: "Build", + sortOrder: 10, + }), + ], + isLoading: false, + }); + }); + + it("cold-loads workspace workflows through the query option", async () => { + const workflows = [workflow("workflow-1", "Build")]; + vi.mocked(listWorkflows).mockResolvedValue({ workflows, total: 1 }); + const queryClient = createQueryClient(); + + const { result } = renderHook(() => useWorkflows(WORKSPACE_ID), { + wrapper: wrapperFor(queryClient), + }); + + await waitFor(() => expect(result.current.workflows).toHaveLength(1)); + + expect(listWorkflows).toHaveBeenCalledWith( + WORKSPACE_ID, + expect.objectContaining({ includeHidden: true }), + ); + expect(queryClient.getQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }))).toBe( + workflows, + ); + }); +}); diff --git a/apps/web/hooks/use-workflows.ts b/apps/web/hooks/use-workflows.ts index 08559e7c76..975aec07f6 100644 --- a/apps/web/hooks/use-workflows.ts +++ b/apps/web/hooks/use-workflows.ts @@ -1,66 +1,23 @@ -import { useEffect } from "react"; +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useAppStore } from "@/components/state-provider"; -import { listWorkflows } from "@/lib/api"; -import type { WorkflowsState } from "@/lib/state/slices"; +import { mapWorkflowItem } from "@/hooks/use-workflow-cache"; +import { workflowsQueryOptions } from "@/lib/query/query-options"; -type StoreWorkflow = WorkflowsState["items"][number]; -type SetWorkflows = (workflows: StoreWorkflow[]) => void; - -/** - * Fire-and-forget fetch effect. Kept internal so callers that only need to - * populate `state.workflows.items` (e.g. `useEnsureWorkspaceWorkflows`) don't - * also subscribe to the store slice they wrote to — that would re-render the - * caller on every fetch and defeats the "top-level layout" placement. - */ -function useWorkflowsFetchEffect( - workspaceId: string | null, - enabled: boolean, - setWorkflows: SetWorkflows, -) { - useEffect(() => { - if (!enabled || !workspaceId) return; - let cancelled = false; - listWorkflows(workspaceId, { cache: "no-store", includeHidden: true }) - .then((response) => { - if (cancelled) return; - const mapped = response.workflows.map((workflow) => ({ - id: workflow.id, - workspaceId: workflow.workspace_id, - name: workflow.name, - description: workflow.description, - sortOrder: workflow.sort_order ?? 0, - agent_profile_id: workflow.agent_profile_id, - hidden: workflow.hidden, - style: workflow.style, - })); - setWorkflows(mapped); - }) - // Do not clear on error — the sidebar mounts on every route, and boot - // hydrates workflows before the refresh fires. Blowing the slice away on - // a network flake would leave the sidebar and board with no workflow IDs - // until another success. The next successful fetch replaces the slice. - .catch(() => {}); - return () => { - cancelled = true; - }; - }, [enabled, setWorkflows, workspaceId]); -} - -/** - * Load workflows for the active workspace. Call from a component that stays - * mounted independently of any collapsible section, so `state.workflows.items` - * follows the active workspace even when the sidebar's Tasks section is - * collapsed and its children (which consume workflows) are unmounted. - */ export function useEnsureWorkspaceWorkflows() { const workspaceId = useAppStore((state) => state.workspaces.activeId); - const setWorkflows = useAppStore((state) => state.setWorkflows); - useWorkflowsFetchEffect(workspaceId, true, setWorkflows); + useWorkflows(workspaceId, Boolean(workspaceId)); } export function useWorkflows(workspaceId: string | null, enabled = true) { - const workflows = useAppStore((state) => state.workflows.items); - const setWorkflows = useAppStore((state) => state.setWorkflows); - useWorkflowsFetchEffect(workspaceId, enabled, setWorkflows); - return { workflows }; + const query = useQuery({ + ...workflowsQueryOptions(workspaceId ?? "", { includeHidden: true }), + enabled: enabled && Boolean(workspaceId), + }); + const workflows = useMemo( + () => (query.data ? query.data.map(mapWorkflowItem) : []), + [query.data], + ); + + return { workflows, isLoading: query.isFetching && workflows.length === 0 }; } diff --git a/apps/web/hooks/use-worktree.ts b/apps/web/hooks/use-worktree.ts deleted file mode 100644 index 60af021558..0000000000 --- a/apps/web/hooks/use-worktree.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useAppStore } from "@/components/state-provider"; - -export function useWorktree(worktreeId: string | null) { - return useAppStore((state) => (worktreeId ? (state.worktrees.items[worktreeId] ?? null) : null)); -} diff --git a/apps/web/lib/api/domains/agent-profile-normalize.ts b/apps/web/lib/api/domains/agent-profile-normalize.ts index 2a241cf5ef..4aa33f1e09 100644 --- a/apps/web/lib/api/domains/agent-profile-normalize.ts +++ b/apps/web/lib/api/domains/agent-profile-normalize.ts @@ -3,8 +3,7 @@ // `/api/v1/agent-profiles/:id` into the canonical camelCase // `AgentProfile` shape. // -// Wired via thin wrappers around the server actions / WS payloads in -// `app/actions/agents.ts` and `lib/ws/handlers/agents.ts`. +// Wired via thin wrappers around the server actions in `app/actions/agents.ts`. import type { ProfileEnvVar } from "@/lib/types/http"; import type { AgentProfile, AgentProfilePayload, CLIFlag } from "@/lib/types/agent-profile"; diff --git a/apps/web/lib/api/domains/office-api.test.ts b/apps/web/lib/api/domains/office-api.test.ts new file mode 100644 index 0000000000..0ab5dd9b1f --- /dev/null +++ b/apps/web/lib/api/domains/office-api.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/config", () => ({ + getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), +})); + +import { listAgentProfiles } from "./office-api"; + +type FetchInput = Parameters[0]; +type FetchInit = Parameters[1]; + +const fetchSpy = vi.fn<[FetchInput, FetchInit?], Promise>(); + +beforeEach(() => { + fetchSpy.mockReset(); + vi.stubGlobal("fetch", fetchSpy); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function mockAgentList(agents: unknown[]) { + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ agents }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); +} + +function rawAgent(overrides: Record = {}) { + return { + id: "agent-1", + workspace_id: "workspace-1", + name: "CEO", + role: "ceo", + status: "idle", + created_at: "2026-05-04T00:00:00Z", + updated_at: "2026-05-04T00:00:00Z", + ...overrides, + }; +} + +describe("office agent normalization", () => { + it("preserves absent skill fields for legacy default fallback", async () => { + mockAgentList([rawAgent()]); + + const response = await listAgentProfiles("workspace-1"); + + expect(response.agents[0]?.skillIds).toBeUndefined(); + expect(response.agents[0]?.desiredSkills).toBeUndefined(); + }); + + it("preserves explicitly cleared skill fields", async () => { + mockAgentList([rawAgent({ skill_ids: "[]", desired_skills: "[]" })]); + + const response = await listAgentProfiles("workspace-1"); + + expect(response.agents[0]?.skillIds).toEqual([]); + expect(response.agents[0]?.desiredSkills).toEqual([]); + }); +}); diff --git a/apps/web/lib/api/domains/office-api.ts b/apps/web/lib/api/domains/office-api.ts index 0d88245405..f083570498 100644 --- a/apps/web/lib/api/domains/office-api.ts +++ b/apps/web/lib/api/domains/office-api.ts @@ -112,6 +112,20 @@ function rawField(agent: Record, camelKey: string, snakeKey: st return agent[camelKey] ?? agent[snakeKey]; } +function hasRawField(agent: Record, camelKey: string, snakeKey: string): boolean { + return agent[camelKey] !== undefined || agent[snakeKey] !== undefined; +} + +function optionalJSONField( + agent: Record, + camelKey: string, + snakeKey: string, + fallback: T, +): T | undefined { + if (!hasRawField(agent, camelKey, snakeKey)) return undefined; + return parseJSONField(rawField(agent, camelKey, snakeKey), fallback); +} + function stringField(agent: Record, camelKey: string, snakeKey: string) { const value = rawField(agent, camelKey, snakeKey); return typeof value === "string" ? value : ""; @@ -153,7 +167,7 @@ function normalizeAgent(raw: unknown): AgentProfile { "max_concurrent_sessions", 1, ), - desiredSkills: parseJSONField(rawField(agent, "desiredSkills", "desired_skills"), []), + desiredSkills: optionalJSONField(agent, "desiredSkills", "desired_skills", []), executorPreference: parseJSONField>( rawField(agent, "executorPreference", "executor_preference"), {}, @@ -161,7 +175,7 @@ function normalizeAgent(raw: unknown): AgentProfile { pauseReason: stringField(agent, "pauseReason", "pause_reason"), billingType: rawField(agent, "billingType", "billing_type") as AgentProfile["billingType"], utilization: (agent.utilization ?? null) as AgentProfile["utilization"], - skillIds: parseJSONField(rawField(agent, "skillIds", "skill_ids"), []), + skillIds: optionalJSONField(agent, "skillIds", "skill_ids", []), // CLI subprocess fields. Office-served rows may omit these when the // office agent is not yet wired to a CLI client; default to safe // empty values so the canonical type stays satisfied. diff --git a/apps/web/lib/api/domains/session-api.ts b/apps/web/lib/api/domains/session-api.ts index ad0a92a01b..59896dfd61 100644 --- a/apps/web/lib/api/domains/session-api.ts +++ b/apps/web/lib/api/domains/session-api.ts @@ -120,12 +120,6 @@ export async function setSessionConfigOption(sessionId: string, configId: string }); } -export async function authenticateSession(sessionId: string, methodId: string) { - return fetchJson<{ ok: boolean }>(`/api/v1/task-sessions/${sessionId}/authenticate`, { - init: { method: "POST", body: JSON.stringify({ method_id: methodId }) }, - }); -} - export { launchSession, type LaunchSessionResponse } from "@/lib/services/session-launch-service"; export { buildPRPrepareRequest, diff --git a/apps/web/lib/kanban/find-task.test.ts b/apps/web/lib/kanban/find-task.test.ts index 8c44f1d732..df3fad9065 100644 --- a/apps/web/lib/kanban/find-task.test.ts +++ b/apps/web/lib/kanban/find-task.test.ts @@ -19,20 +19,13 @@ describe("findTaskInSnapshots", () => { expect(findTaskInSnapshots("t2", snaps)?.id).toBe("t2"); }); - it("falls back to fallbackTasks when no snapshot matches", () => { - expect(findTaskInSnapshots("t-new", {}, [task("t-new")])?.id).toBe("t-new"); - }); - - it("prefers a snapshot match over fallbackTasks", () => { - // If both exist, the snapshot version wins so the mobile sheet doesn't - // surface a stale active-kanban entry over a fresher multi-snapshot one. + it("returns the snapshot match when present", () => { const snap = { tasks: [{ ...task("t1"), title: "from-snapshot" }] }; - const fallback = [{ ...task("t1"), title: "from-fallback" }]; - expect(findTaskInSnapshots("t1", { wf: snap }, fallback)?.title).toBe("from-snapshot"); + expect(findTaskInSnapshots("t1", { wf: snap })?.title).toBe("from-snapshot"); }); - it("returns null when the task is missing from snapshots and fallback", () => { + it("returns null when the task is missing from snapshots", () => { expect(findTaskInSnapshots("missing", { wf: { tasks: [task("t1")] } })).toBeNull(); - expect(findTaskInSnapshots("missing", {}, [task("t1")])).toBeNull(); + expect(findTaskInSnapshots("missing", {})).toBeNull(); }); }); diff --git a/apps/web/lib/kanban/find-task.ts b/apps/web/lib/kanban/find-task.ts index 36f54b10f7..e6f37ca3c7 100644 --- a/apps/web/lib/kanban/find-task.ts +++ b/apps/web/lib/kanban/find-task.ts @@ -2,15 +2,13 @@ import type { KanbanState } from "@/lib/state/slices"; type Task = KanbanState["tasks"][number]; -// `fallbackTasks` is consulted when no snapshot matches (e.g. WS-arrived tasks before their snapshot lands). export function findTaskInSnapshots( taskId: string, snapshots: Record, - fallbackTasks?: KanbanState["tasks"], ): Task | null { for (const snapshot of Object.values(snapshots)) { const found = snapshot.tasks.find((t) => t.id === taskId); if (found) return found; } - return fallbackTasks?.find((t) => t.id === taskId) ?? null; + return null; } diff --git a/apps/web/lib/kanban/move-task-error.ts b/apps/web/lib/kanban/move-task-error.ts new file mode 100644 index 0000000000..3485504ba2 --- /dev/null +++ b/apps/web/lib/kanban/move-task-error.ts @@ -0,0 +1,5 @@ +export type MoveTaskError = { + message: string; + taskId: string; + sessionId: string | null; +}; diff --git a/apps/web/lib/kanban/resolve-workflow.test.ts b/apps/web/lib/kanban/resolve-workflow.test.ts index cfcf8f08d7..20960832dc 100644 --- a/apps/web/lib/kanban/resolve-workflow.test.ts +++ b/apps/web/lib/kanban/resolve-workflow.test.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from "vitest"; import { resolveDesiredWorkflowId } from "./resolve-workflow"; -import type { WorkflowsState } from "@/lib/state/slices"; +import type { WorkflowItem } from "@/lib/state/slices"; -type Workflow = WorkflowsState["items"][number]; +type Workflow = WorkflowItem; function workflow(id: string, overrides: Partial = {}): Workflow { return { id, workspaceId: "ws-1", name: id, ...overrides }; diff --git a/apps/web/lib/kanban/resolve-workflow.ts b/apps/web/lib/kanban/resolve-workflow.ts index 464dca7ead..455ee628a9 100644 --- a/apps/web/lib/kanban/resolve-workflow.ts +++ b/apps/web/lib/kanban/resolve-workflow.ts @@ -1,5 +1,3 @@ -import type { WorkflowsState } from "@/lib/state/slices"; - type WorkflowLike = { id: string; hidden?: boolean }; /** @@ -18,7 +16,7 @@ export function resolveDesiredWorkflowId({ }: { activeWorkflowId?: string | null; settingsWorkflowId?: string | null; - workspaceWorkflows: WorkflowsState["items"] | WorkflowLike[]; + workspaceWorkflows: WorkflowLike[]; }): string | null { const visibleWorkflows = workspaceWorkflows.filter((workflow) => !workflow.hidden); if (activeWorkflowId && visibleWorkflows.some((workflow) => workflow.id === activeWorkflowId)) { diff --git a/apps/web/lib/kanban/snapshot.test.ts b/apps/web/lib/kanban/snapshot.test.ts new file mode 100644 index 0000000000..902e89a101 --- /dev/null +++ b/apps/web/lib/kanban/snapshot.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { workflowSnapshotToKanbanData } from "./snapshot"; +import type { WorkflowSnapshot } from "@/lib/types/http"; +import { workflowId, workspaceId } from "@/lib/types/ids"; + +describe("workflowSnapshotToKanbanData", () => { + it("preserves workflow step WIP settings for the kanban UI", () => { + const data = workflowSnapshotToKanbanData({ + workflow: { + id: workflowId("workflow-1"), + workspace_id: workspaceId("workspace-1"), + name: "Review", + created_at: "2026-07-13T00:00:00Z", + updated_at: "2026-07-13T00:00:00Z", + }, + steps: [ + { + id: "step-1", + workflow_id: workflowId("workflow-1"), + name: "Review", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + wip_limit: 2, + pull_from_step_id: "step-0", + }, + ], + tasks: [], + } as WorkflowSnapshot); + + expect(data.steps[0]).toMatchObject({ + wip_limit: 2, + pull_from_step_id: "step-0", + }); + }); +}); diff --git a/apps/web/lib/kanban/snapshot.ts b/apps/web/lib/kanban/snapshot.ts new file mode 100644 index 0000000000..06cf3f9a46 --- /dev/null +++ b/apps/web/lib/kanban/snapshot.ts @@ -0,0 +1,84 @@ +import { toKanbanTask } from "@/lib/kanban/map-task"; +import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; +import type { Task, WorkflowSnapshot, WorkflowStepDTO } from "@/lib/types/http"; + +type KanbanTask = KanbanState["tasks"][number]; +type KanbanStep = KanbanState["steps"][number]; + +export function workflowStepToKanbanStep(step: WorkflowStepDTO): KanbanStep { + return { + id: step.id, + title: step.name, + color: step.color ?? "bg-neutral-400", + position: step.position, + events: step.events, + allow_manual_move: step.allow_manual_move, + prompt: step.prompt, + is_start_step: step.is_start_step, + show_in_command_panel: step.show_in_command_panel, + agent_profile_id: step.agent_profile_id, + wip_limit: step.wip_limit, + pull_from_step_id: step.pull_from_step_id ?? null, + stage_type: step.stage_type, + }; +} + +function mergeRuntimeTaskFields(task: KanbanTask, existing: KanbanTask | undefined): KanbanTask { + if (!existing) return task; + return { + ...task, + primarySessionId: task.primarySessionId || existing.primarySessionId, + primarySessionState: task.primarySessionState || existing.primarySessionState, + }; +} + +export function workflowSnapshotToKanbanData( + snapshot: WorkflowSnapshot, + existing?: WorkflowSnapshotData | null, +): WorkflowSnapshotData { + const steps = snapshot.steps.map(workflowStepToKanbanStep); + const stepIds = new Set(steps.map((step) => step.id)); + const existingById = new Map((existing?.tasks ?? []).map((task) => [task.id, task])); + const tasks = snapshot.tasks + .filter((task) => !task.is_ephemeral) + .map((task) => taskToSnapshotTask(task, stepIds, existingById.get(task.id))) + .filter((task): task is KanbanTask => task !== null); + + return { + workflowId: snapshot.workflow.id, + workflowName: snapshot.workflow.name, + steps, + tasks, + }; +} + +export function workflowSnapshotToKanbanState( + snapshot: WorkflowSnapshot, + existing?: KanbanState | null, +): KanbanState { + const existingData = + existing && existing.workflowId === snapshot.workflow.id + ? { + workflowId: existing.workflowId, + workflowName: snapshot.workflow.name, + steps: existing.steps, + tasks: existing.tasks, + } + : null; + const data = workflowSnapshotToKanbanData(snapshot, existingData); + return { + workflowId: data.workflowId, + steps: data.steps, + tasks: data.tasks, + isLoading: false, + }; +} + +function taskToSnapshotTask( + task: Task, + stepIds: Set, + existing: KanbanTask | undefined, +): KanbanTask | null { + if (!task.workflow_step_id || !stepIds.has(task.workflow_step_id)) return null; + return mergeRuntimeTaskFields(toKanbanTask(task), existing); +} diff --git a/apps/web/lib/kanban/view-registry.ts b/apps/web/lib/kanban/view-registry.ts index 702ce78786..73a8ae96f0 100644 --- a/apps/web/lib/kanban/view-registry.ts +++ b/apps/web/lib/kanban/view-registry.ts @@ -4,7 +4,7 @@ import { SwimlaneKanbanContent } from "@/components/kanban/swimlane-kanban-conte import { SwimlaneGraph2Content } from "@/components/kanban/swimlane-graph2-content"; import type { Task } from "@/components/kanban-card"; import type { WorkflowStep } from "@/components/kanban-column"; -import type { MoveTaskError } from "@/hooks/use-drag-and-drop"; +import type { MoveTaskError } from "@/lib/kanban/move-task-error"; export type ViewContentProps = { workflowId: string; diff --git a/apps/web/lib/query/bridge/audit.ts b/apps/web/lib/query/bridge/audit.ts new file mode 100644 index 0000000000..e93b66e011 --- /dev/null +++ b/apps/web/lib/query/bridge/audit.ts @@ -0,0 +1,264 @@ +import type { QueryClient } from "@tanstack/react-query"; + +const AUDIT_BUFFER_SIZE = 5000; + +export type BridgeAuditStatus = "handled" | "allowlisted"; + +export interface BridgeAuditEntry { + action: string; + cacheChanged: boolean; + mutationCount: number; + reason?: string; + sessionId: string | null; + status: BridgeAuditStatus; + taskId: string | null; + timestamp: number; + type: string | null; +} + +type BridgeAuditWindow = Window & { + __KANDEV_E2E_EXPOSE_STORE__?: boolean; + __kandev_bridge_audit__?: () => BridgeAuditEntry[]; + __kandev_bridge_audit_clear__?: () => void; +}; + +type BridgeEnvelope = { + action?: string; + payload?: unknown; + type?: string; +}; + +type CacheMutationMethod = + | "clear" + | "invalidateQueries" + | "removeQueries" + | "setQueriesData" + | "setQueryData"; + +const SPIED_CACHE_METHODS: readonly CacheMutationMethod[] = [ + "setQueryData", + "setQueriesData", + "invalidateQueries", + "removeQueries", + "clear", +]; + +// Control-plane responses are resolved through WebSocketClient.pendingRequests, +// not query cache state. Keeping them explicit here lets the E2E audit explain +// why a parsed envelope intentionally has no bridge cache mutation. +const CONTROL_PLANE_REASON = "control-plane request/response handled outside the query cache"; + +// Client-only notifications produce toasts, dialogs, browser notifications, or +// imperative refreshes rather than durable server-state cache entries. +const CLIENT_EFFECT_REASON = "client-only effect with no durable query cache entry"; + +const COMPONENT_LOCAL_REASON = + "component-local live state with no durable shared query cache entry"; + +// High-volume streams stay outside QueryClient to avoid per-chunk observer churn. +const STREAM_REASON = "high-volume stream tracked outside TanStack Query"; + +export const BRIDGE_SKIPPED_ACTIONS = { + "agent.cancel": CONTROL_PLANE_REASON, + "agent.logs": CONTROL_PLANE_REASON, + "agent.prompt": CONTROL_PLANE_REASON, + "agent.resize": CONTROL_PLANE_REASON, + "agent.status": CONTROL_PLANE_REASON, + "agent.stdin": CONTROL_PLANE_REASON, + "agent.stop": CONTROL_PLANE_REASON, + "message.queue.add": CONTROL_PLANE_REASON, + "message.queue.append": CONTROL_PLANE_REASON, + "message.queue.cancel": CONTROL_PLANE_REASON, + "message.queue.get": CONTROL_PLANE_REASON, + "message.queue.remove": CONTROL_PLANE_REASON, + "message.queue.update": CONTROL_PLANE_REASON, + "permission.respond": CONTROL_PLANE_REASON, + "run.subscribe": CONTROL_PLANE_REASON, + "run.unsubscribe": CONTROL_PLANE_REASON, + "session.commit_diff": CONTROL_PLANE_REASON, + "session.cumulative_diff": CONTROL_PLANE_REASON, + "session.delete": CONTROL_PLANE_REASON, + "session.ensure": CONTROL_PLANE_REASON, + "session.file_review.get": CONTROL_PLANE_REASON, + "session.file_review.reset": CONTROL_PLANE_REASON, + "session.file_review.update": CONTROL_PLANE_REASON, + "session.focus": CONTROL_PLANE_REASON, + "session.git.commits": CONTROL_PLANE_REASON, + "session.git.snapshots": CONTROL_PLANE_REASON, + "session.launch": CONTROL_PLANE_REASON, + "session.recover": CONTROL_PLANE_REASON, + "session.reset_context": CONTROL_PLANE_REASON, + "session.set_mode": CONTROL_PLANE_REASON, + "session.set_plan_mode": CONTROL_PLANE_REASON, + "session.set_primary": CONTROL_PLANE_REASON, + "session.shell.status": CONTROL_PLANE_REASON, + "session.stop": CONTROL_PLANE_REASON, + "session.subscribe": CONTROL_PLANE_REASON, + "session.unfocus": CONTROL_PLANE_REASON, + "session.unsubscribe": CONTROL_PLANE_REASON, + "shell.input": CONTROL_PLANE_REASON, + "shell.subscribe": CONTROL_PLANE_REASON, + "system.metrics.subscribe": CONTROL_PLANE_REASON, + "system.metrics.unsubscribe": CONTROL_PLANE_REASON, + "task.plan.create": CONTROL_PLANE_REASON, + "task.plan.delete": CONTROL_PLANE_REASON, + "task.plan.get": CONTROL_PLANE_REASON, + "task.plan.revision.get": CONTROL_PLANE_REASON, + "task.plan.revisions.list": CONTROL_PLANE_REASON, + "task.plan.revert": CONTROL_PLANE_REASON, + "task.plan.update": CONTROL_PLANE_REASON, + "task.session": CONTROL_PLANE_REASON, + "task.session.list": CONTROL_PLANE_REASON, + "task.session.status": CONTROL_PLANE_REASON, + "task.subscribe": CONTROL_PLANE_REASON, + "task.unsubscribe": CONTROL_PLANE_REASON, + "user.subscribe": CONTROL_PLANE_REASON, + "user.unsubscribe": CONTROL_PLANE_REASON, + "vscode.openFile": CONTROL_PLANE_REASON, + "vscode.start": CONTROL_PLANE_REASON, + "vscode.status": CONTROL_PLANE_REASON, + "vscode.stop": CONTROL_PLANE_REASON, + + "run.event.appended": COMPONENT_LOCAL_REASON, + + "input.requested": CLIENT_EFFECT_REASON, + "permission.requested": CLIENT_EFFECT_REASON, + "session.waiting_for_input": CLIENT_EFFECT_REASON, + "session.workspace.file.changes": CLIENT_EFFECT_REASON, + "system.error": CLIENT_EFFECT_REASON, + + "session.process.output": STREAM_REASON, + "session.shell.output": STREAM_REASON, + "terminal.output": STREAM_REASON, +} as const satisfies Readonly>; + +// Prefix skips cover request/response families that are not emitted as typed +// BackendMessageType notifications. They are still parsed WS envelopes, so the +// audit needs a rationale instead of reporting a missing bridge. +export const BRIDGE_SKIPPED_PREFIXES = ["agentctl_", "user_shell."] as const; + +const BRIDGE_SKIPPED_PREFIX_REASONS: Readonly> = { + agentctl_: "agentctl container channels are consumed through agentctl HTTP surfaces", + "user_shell.": "user shell operations are request/response control-plane envelopes", +}; + +const auditBuffer = new Map(); +let auditSeq = 0; + +export function isBridgeSkippedAction(action: string): boolean { + return getBridgeSkipReason(action) !== null; +} + +export function getBridgeSkipReason(action: string): string | null { + if (Object.prototype.hasOwnProperty.call(BRIDGE_SKIPPED_ACTIONS, action)) { + return BRIDGE_SKIPPED_ACTIONS[action as keyof typeof BRIDGE_SKIPPED_ACTIONS]; + } + const prefix = BRIDGE_SKIPPED_PREFIXES.find((item) => action.startsWith(item)); + return prefix ? BRIDGE_SKIPPED_PREFIX_REASONS[prefix] : null; +} + +export function clearBridgeAuditRows(): void { + auditBuffer.clear(); +} + +export function getBridgeAuditRows(): BridgeAuditEntry[] { + return Array.from(auditBuffer.values()); +} + +export function installBridgeAuditAccessors(): void { + const win = getAuditWindow(); + if (!win || !isBridgeAuditEnabled()) return; + win.__kandev_bridge_audit__ = getBridgeAuditRows; + win.__kandev_bridge_audit_clear__ = clearBridgeAuditRows; +} + +export function isBridgeAuditEnabled(): boolean { + return getAuditWindow()?.__KANDEV_E2E_EXPOSE_STORE__ === true; +} + +export function recordBridgeAllowlistedEvent(message: BridgeEnvelope): void { + const action = message.action; + if (!action || !isBridgeAuditEnabled()) return; + const reason = getBridgeSkipReason(action); + if (!reason) return; + pushAuditEntry({ + action, + cacheChanged: false, + mutationCount: 0, + reason, + sessionId: readStringField(message.payload, "session_id"), + status: "allowlisted", + taskId: readStringField(message.payload, "task_id"), + timestamp: Date.now(), + type: message.type ?? null, + }); +} + +export function wrapBridgeHandler( + queryClient: QueryClient, + action: string, + handler: (message: T) => void, +): (message: T) => void { + if (!isBridgeAuditEnabled()) return handler; + installBridgeAuditAccessors(); + return (message: T) => { + // Bridge handlers must stay synchronous: these temporary spies are restored + // as soon as the handler returns, so deferred cache writes are intentionally + // outside the audit window. + let mutationCount = 0; + const queryClientMethods = queryClient as unknown as Record< + CacheMutationMethod, + (...args: unknown[]) => unknown + >; + const originals = new Map unknown>(); + + for (const method of SPIED_CACHE_METHODS) { + const original = queryClientMethods[method]; + originals.set(method, original); + queryClientMethods[method] = (...args: unknown[]) => { + mutationCount++; + return original.apply(queryClient, args); + }; + } + + try { + handler(message); + } finally { + for (const [method, original] of originals) { + queryClientMethods[method] = original; + } + pushAuditEntry({ + action, + cacheChanged: mutationCount > 0, + mutationCount, + sessionId: readStringField(message.payload, "session_id"), + status: "handled", + taskId: readStringField(message.payload, "task_id"), + timestamp: Date.now(), + type: message.type ?? null, + }); + } + }; +} + +function pushAuditEntry(entry: BridgeAuditEntry): void { + if (!isBridgeAuditEnabled()) return; + installBridgeAuditAccessors(); + auditBuffer.set(auditSeq++, entry); + while (auditBuffer.size > AUDIT_BUFFER_SIZE) { + const oldest = auditBuffer.keys().next().value; + if (oldest === undefined) break; + auditBuffer.delete(oldest); + } +} + +function readStringField(payload: unknown, key: string): string | null { + if (!payload || typeof payload !== "object") return null; + const value = (payload as Record)[key]; + return typeof value === "string" && value !== "" ? value : null; +} + +function getAuditWindow(): BridgeAuditWindow | null { + if (typeof window === "undefined") return null; + return window as BridgeAuditWindow; +} diff --git a/apps/web/lib/query/bridge/index.test.ts b/apps/web/lib/query/bridge/index.test.ts new file mode 100644 index 0000000000..96c5be70f1 --- /dev/null +++ b/apps/web/lib/query/bridge/index.test.ts @@ -0,0 +1,1673 @@ +/* eslint-disable max-lines, max-lines-per-function, sonarjs/no-duplicate-string */ +import { QueryClient } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; +import type { BackendMessage } from "@/lib/types/backend-message"; +import type { TaskPR } from "@/lib/types/github"; +import { + sessionId as toSessionId, + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, + type Task, + type WorkflowSnapshot, +} from "@/lib/types/http"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { makeQueryClient } from "../client"; +import { qk } from "../keys"; +import { + BRIDGE_SKIPPED_ACTIONS, + clearBridgeAuditRows, + getBridgeAuditRows, + isBridgeSkippedAction, + registerQueryBridge, +} from "./index"; + +type BridgeWindow = Window & { + __KANDEV_E2E_EXPOSE_STORE__?: boolean; + __kandev_bridge_audit__?: () => unknown[]; + __kandev_bridge_audit_clear__?: () => void; +}; + +type AnyBackendMessage = BackendMessage>; +type Handler = (message: AnyBackendMessage) => void; + +class FakeWebSocketClient { + private envelopeHandlers = new Set(); + private handlers = new Map>(); + + on(type: T, handler: (message: BackendMessageMap[T]) => void) { + const bucket = this.handlers.get(type) ?? new Set(); + bucket.add(handler as Handler); + this.handlers.set(type, bucket); + return () => { + bucket.delete(handler as Handler); + }; + } + + onEnvelope(handler: (message: BackendMessageMap[BackendMessageType]) => void) { + this.envelopeHandlers.add(handler as Handler); + return () => { + this.envelopeHandlers.delete(handler as Handler); + }; + } + + emit(message: AnyBackendMessage) { + this.envelopeHandlers.forEach((handler) => handler(message)); + this.handlers.get(message.action)?.forEach((handler) => handler(message)); + } +} + +function taskUpdated( + overrides: Partial = {}, +): BackendMessageMap["task.updated"] { + return { + type: "notification", + action: "task.updated", + payload: { + task_id: "task-1", + workflow_id: "workflow-1", + workflow_step_id: "step-2", + title: "Updated task", + is_ephemeral: false, + ...overrides, + }, + }; +} + +function workflowSnapshotTask(overrides: Partial = {}): Task { + return { + id: toTaskId("task-1"), + workspace_id: toWorkspaceId("workspace-1"), + workflow_id: toWorkflowId("workflow-1"), + workflow_step_id: "step-1", + position: 0, + title: "Task", + description: "", + state: "TODO", + priority: 0, + primary_session_id: toSessionId("session-1"), + primary_session_state: "WAITING_FOR_INPUT", + repositories: [], + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + ...overrides, + } as Task; +} + +function workflowSnapshot(tasks: Task[]): WorkflowSnapshot { + return { + workflow: { + id: toWorkflowId("workflow-1"), + workspace_id: toWorkspaceId("workspace-1"), + name: "Workflow", + sort_order: 0, + hidden: false, + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + }, + steps: [ + { + id: "step-1", + workflow_id: toWorkflowId("workflow-1"), + name: "Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks, + }; +} + +function taskPr(overrides: Partial = {}): TaskPR { + return { + id: "task-pr-1", + task_id: "task-1", + repository_id: "repo-1", + owner: "kdlbs", + repo: "kandev", + pr_number: 1512, + pr_url: "https://github.com/kdlbs/kandev/pull/1512", + pr_title: "Old title", + head_branch: "feature/tanstack-migration-801", + base_branch: "main", + author_login: "octocat", + state: "open", + review_state: "pending", + checks_state: "pending", + mergeable_state: "unknown", + review_count: 0, + pending_review_count: 0, + required_reviews: null, + comment_count: 0, + unresolved_review_threads: 0, + checks_total: 0, + checks_passing: 0, + additions: 0, + deletions: 0, + created_at: "2026-06-24T00:00:00Z", + merged_at: null, + closed_at: null, + last_synced_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + ...overrides, + }; +} + +function registerBridge(ws: FakeWebSocketClient, client: QueryClient) { + return registerQueryBridge(ws as unknown as WebSocketClient, client); +} + +describe("query bridge audit", () => { + beforeEach(() => { + (window as BridgeWindow).__KANDEV_E2E_EXPOSE_STORE__ = true; + clearBridgeAuditRows(); + delete (window as BridgeWindow).__kandev_bridge_audit__; + delete (window as BridgeWindow).__kandev_bridge_audit_clear__; + }); + + afterEach(() => { + clearBridgeAuditRows(); + delete (window as BridgeWindow).__KANDEV_E2E_EXPOSE_STORE__; + delete (window as BridgeWindow).__kandev_bridge_audit__; + delete (window as BridgeWindow).__kandev_bridge_audit_clear__; + }); + + it("patches registered query keys and records handled audit rows", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.tasks.detail("task-1"), { + id: "task-1", + title: "Old title", + workflow_step_id: "step-1", + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit(taskUpdated()); + + expect(queryClient.getQueryData(qk.tasks.detail("task-1"))).toMatchObject({ + id: "task-1", + title: "Updated task", + workflow_step_id: "step-2", + }); + expect(getBridgeAuditRows()).toEqual([ + expect.objectContaining({ + action: "task.updated", + cacheChanged: true, + mutationCount: expect.any(Number), + status: "handled", + taskId: "task-1", + }), + ]); + expect((window as BridgeWindow).__kandev_bridge_audit__?.()).toHaveLength(1); + + cleanup(); + }); + + it("records allowlisted envelopes without registering cache handlers", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + id: "response-1", + type: "response", + action: "session.subscribe", + payload: { session_id: "session-1" }, + }); + + expect(getBridgeAuditRows()).toEqual([ + expect.objectContaining({ + action: "session.subscribe", + cacheChanged: false, + mutationCount: 0, + reason: BRIDGE_SKIPPED_ACTIONS["session.subscribe"], + sessionId: "session-1", + status: "allowlisted", + }), + ]); + expect(isBridgeSkippedAction("session.subscribe")).toBe(true); + + cleanup(); + }); + + it("handles repository events by invalidating workspace repository caches", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workspaces.repositories("workspace-1"), [{ id: "repo-1" }]); + queryClient.setQueryData(qk.workspaces.repositories("workspace-1", { includeScripts: true }), [ + { id: "repo-1", scripts: [] }, + ]); + queryClient.setQueryData(qk.workspaces.repositoryScripts("repo-1"), []); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "repository.updated", + payload: { + id: "repo-1", + workspace_id: "workspace-1", + name: "Updated repository", + }, + }); + + expect( + queryClient.getQueryState(qk.workspaces.repositories("workspace-1"))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(qk.workspaces.repositories("workspace-1", { includeScripts: true })) + ?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(qk.workspaces.repositoryScripts("repo-1"))?.isInvalidated, + ).toBe(true); + expect(getBridgeAuditRows()).toContainEqual( + expect.objectContaining({ + action: "repository.updated", + status: "handled", + }), + ); + + cleanup(); + }); + + it("patches cached workflow lists when a workflow is created", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workflows.all("workspace-1"), [ + { + id: "workflow-1", + workspace_id: "workspace-1", + name: "Existing", + hidden: false, + }, + ]); + queryClient.setQueryData(qk.workflows.all("workspace-2"), [ + { + id: "workflow-2", + workspace_id: "workspace-2", + name: "Other workspace", + hidden: false, + }, + ]); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "workflow.created", + payload: { + id: "workflow-new", + workspace_id: "workspace-1", + name: "New workflow", + hidden: false, + }, + }); + + expect(queryClient.getQueryData(qk.workflows.all("workspace-1"))).toEqual([ + expect.objectContaining({ id: "workflow-1" }), + expect.objectContaining({ id: "workflow-new", name: "New workflow" }), + ]); + expect(queryClient.getQueryData(qk.workflows.all("workspace-2"))).toEqual([ + expect.objectContaining({ id: "workflow-2" }), + ]); + expect(queryClient.getQueryState(qk.workflows.all("workspace-1"))?.isInvalidated).toBe(true); + + cleanup(); + }); + + it("handles repository script events by invalidating script and repository-list caches", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workspaces.repositories("workspace-1"), [ + { id: "repo-1", scripts: [] }, + ]); + queryClient.setQueryData(qk.workspaces.repositoryScripts("repo-1"), [ + { id: "script-1", repository_id: "repo-1" }, + ]); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "repository.script.updated", + payload: { + id: "script-1", + repository_id: "repo-1", + name: "Setup", + }, + }); + + expect( + queryClient.getQueryState(qk.workspaces.repositories("workspace-1"))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(qk.workspaces.repositoryScripts("repo-1"))?.isInvalidated, + ).toBe(true); + expect(getBridgeAuditRows()).toContainEqual( + expect.objectContaining({ + action: "repository.script.updated", + status: "handled", + }), + ); + + cleanup(); + }); + + it("patches office task query pages and records handled office audit rows", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.tasks("workspace-1", { limit: 200 }), { + pages: [ + { + tasks: [ + { + id: "office-task-1", + title: "Old office title", + status: "todo", + }, + ], + }, + ], + pageParams: [undefined], + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "office.task.updated", + payload: { + workspace_id: "workspace-1", + task_id: "office-task-1", + title: "Updated office title", + }, + }); + + expect(queryClient.getQueryData(qk.office.tasks("workspace-1", { limit: 200 }))).toMatchObject({ + pages: [{ tasks: [{ id: "office-task-1", title: "Updated office title" }] }], + }); + expect(getBridgeAuditRows()).toContainEqual( + expect.objectContaining({ + action: "office.task.updated", + status: "handled", + taskId: "office-task-1", + }), + ); + + cleanup(); + }); + + it("patches task PR rows and invalidates workspace PR aggregates without cross-workspace writes", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + const oldPr = taskPr(); + const updatedPr = taskPr({ pr_title: "Updated title", checks_state: "success" }); + const workspacePrsKey = qk.integrations.github.prs("workspace-1"); + const otherWorkspacePrsKey = qk.integrations.github.prs("workspace-2"); + const otherWorkspacePr = taskPr({ id: "task-pr-2", task_id: "task-2", pr_number: 99 }); + queryClient.setQueryData(qk.integrations.github.taskPr("task-1"), [oldPr]); + queryClient.setQueryData(workspacePrsKey, { task_prs: { "task-1": [oldPr] } }); + queryClient.setQueryData(otherWorkspacePrsKey, { task_prs: { "task-2": [otherWorkspacePr] } }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "github.task_pr.updated", + payload: updatedPr, + }); + + expect(queryClient.getQueryData(qk.integrations.github.taskPr("task-1"))).toEqual([ + expect.objectContaining({ + checks_state: "success", + pr_title: "Updated title", + }), + ]); + expect(queryClient.getQueryData(workspacePrsKey)).toEqual({ + task_prs: { + "task-1": [oldPr], + }, + }); + expect(queryClient.getQueryData(otherWorkspacePrsKey)).toEqual({ + task_prs: { + "task-2": [otherWorkspacePr], + }, + }); + expect( + queryClient.getQueryData<{ task_prs: Record }>(otherWorkspacePrsKey) + ?.task_prs["task-1"], + ).toBeUndefined(); + expect(queryClient.getQueryState(workspacePrsKey)?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(otherWorkspacePrsKey)?.isInvalidated).toBe(true); + + cleanup(); + }); + + it("upserts provider health rows from office bridge events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.providerHealth("workspace-1"), { health: [] }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "office.provider.health_changed", + payload: { + workspace_id: "workspace-1", + provider_id: "claude-acp", + scope: "provider", + scope_value: "", + state: "degraded", + backoff_step: 1, + }, + }); + + expect(queryClient.getQueryData(qk.office.providerHealth("workspace-1"))).toEqual({ + health: [ + expect.objectContaining({ + provider_id: "claude-acp", + state: "degraded", + }), + ], + }); + + cleanup(); + }); + + it("does not materialize partial provider health lists from office bridge events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "office.provider.health_changed", + payload: { + workspace_id: "workspace-1", + provider_id: "claude-acp", + scope: "provider", + scope_value: "", + state: "degraded", + backoff_step: 1, + }, + }); + + expect(queryClient.getQueryData(qk.office.providerHealth("workspace-1"))).toBeUndefined(); + expect(queryClient.getQueryState(qk.office.providerHealth("workspace-1"))).toBeUndefined(); + + cleanup(); + }); + + it("appends route attempts from office bridge events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.runAttempts("run-1"), { attempts: [] }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "office.route_attempt.appended", + payload: { + run_id: "run-1", + attempt: { + seq: 1, + provider_id: "codex", + tier: "balanced", + outcome: "launched", + started_at: "2026-06-23T00:00:00Z", + }, + }, + }); + + expect(queryClient.getQueryData(qk.office.runAttempts("run-1"))).toEqual({ + attempts: [ + expect.objectContaining({ + provider_id: "codex", + seq: 1, + }), + ], + }); + + cleanup(); + }); + + it("does not seed full run-attempt caches from live route attempt events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "office.route_attempt.appended", + payload: { + run_id: "run-1", + attempt: { + seq: 1, + provider_id: "codex", + tier: "balanced", + outcome: "launched", + started_at: "2026-06-23T00:00:00Z", + }, + }, + }); + + expect(queryClient.getQueryData(qk.office.runAttempts("run-1"))).toBeUndefined(); + + cleanup(); + }); + + it("invalidates office task comments when comment events arrive", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.taskComments("office-task-1"), { comments: [] }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "office.comment.created", + payload: { + task_id: "office-task-1", + }, + }); + + expect(queryClient.getQueryState(qk.office.taskComments("office-task-1"))?.isInvalidated).toBe( + true, + ); + + cleanup(); + }); + + it("invalidates office task comments when run lifecycle events arrive", () => { + const events: Array<"office.run.queued" | "office.run.processed"> = [ + "office.run.queued", + "office.run.processed", + ]; + + for (const action of events) { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.taskComments("office-task-1"), { comments: [] }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action, + payload: { + workspace_id: "workspace-1", + task_id: "office-task-1", + run_id: "run-1", + }, + }); + + expect( + queryClient.getQueryState(qk.office.taskComments("office-task-1"))?.isInvalidated, + ).toBe(true); + + cleanup(); + } + }); + + it("invalidates linked office task surfaces when task events arrive", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.projects("workspace-1"), { projects: [] }); + queryClient.setQueryData(qk.office.project("project-1"), { project: { id: "project-1" } }); + queryClient.setQueryData(qk.office.agentSummary("agent-1", 14), { summary: {} }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "office.task.status_changed", + payload: { + workspace_id: "workspace-1", + task_id: "office-task-1", + project_id: "project-1", + assignee_agent_profile_id: "agent-1", + new_status: "done", + }, + }); + + expect(queryClient.getQueryState(qk.office.projects("workspace-1"))?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(qk.office.project("project-1"))?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(qk.office.agentSummary("agent-1", 14))?.isInvalidated).toBe( + true, + ); + + cleanup(); + }); + + it("invalidates office agent and task run surfaces when run lifecycle events arrive", () => { + const events: Array<"office.run.queued" | "office.run.processed"> = [ + "office.run.queued", + "office.run.processed", + ]; + + for (const action of events) { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.dashboard("workspace-1"), {}); + queryClient.setQueryData(qk.office.agentSummary("agent-1", 14), { summary: {} }); + queryClient.setQueryData(qk.office.agentRuns("agent-1", { limit: 25 }), { runs: [] }); + queryClient.setQueryData(qk.office.runDetail("agent-1", "run-1"), { run: { id: "run-1" } }); + queryClient.setQueryData(qk.office.taskActivity("workspace-1", "office-task-1"), { + activity: [], + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action, + payload: { + workspace_id: "workspace-1", + task_id: "office-task-1", + agent_profile_id: "agent-1", + run_id: "run-1", + }, + }); + + expect(queryClient.getQueryState(qk.office.dashboard("workspace-1"))?.isInvalidated).toBe( + true, + ); + expect(queryClient.getQueryState(qk.office.agentSummary("agent-1", 14))?.isInvalidated).toBe( + true, + ); + expect( + queryClient.getQueryState(qk.office.agentRuns("agent-1", { limit: 25 }))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(qk.office.runDetail("agent-1", "run-1"))?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(qk.office.taskActivity("workspace-1", "office-task-1")) + ?.isInvalidated, + ).toBe(true); + + cleanup(); + } + }); + + it("invalidates exact task activity for comment and review events", () => { + const events: Array< + "office.comment.created" | "office.task.decision_recorded" | "office.task.review_requested" + > = ["office.comment.created", "office.task.decision_recorded", "office.task.review_requested"]; + + for (const action of events) { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.taskActivity("workspace-1", "office-task-1"), { + activity: [], + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action, + payload: { + workspace_id: "workspace-1", + task_id: "office-task-1", + }, + }); + + expect( + queryClient.getQueryState(qk.office.taskActivity("workspace-1", "office-task-1")) + ?.isInvalidated, + ).toBe(true); + + cleanup(); + } + }); + + it("invalidates agent route queries for routing events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.agentRoute("agent-1"), { route: null }); + queryClient.setQueryData(qk.office.agentRoute("agent-2"), { route: null }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "office.provider.health_changed", + payload: { + workspace_id: "workspace-1", + provider_id: "claude-acp", + state: "degraded", + }, + }); + + expect(queryClient.getQueryState(qk.office.agentRoute("agent-1"))?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(qk.office.agentRoute("agent-2"))?.isInvalidated).toBe(true); + + cleanup(); + }); + + it("invalidates targeted agent route when route attempts carry an agent id", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.agentRoute("agent-1"), { route: null }); + queryClient.setQueryData(qk.office.agentRoute("agent-2"), { route: null }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "office.route_attempt.appended", + payload: { + run_id: "run-1", + agent_profile_id: "agent-1", + attempt: { + seq: 1, + provider_id: "codex", + tier: "balanced", + outcome: "launched", + started_at: "2026-06-23T00:00:00Z", + }, + }, + }); + + expect(queryClient.getQueryState(qk.office.agentRoute("agent-1"))?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(qk.office.agentRoute("agent-2"))?.isInvalidated).toBe(false); + + cleanup(); + }); + + it("invalidates agent summaries and runs when session state changes", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.office.agentSummary("agent-1", 14), { summary: {} }); + queryClient.setQueryData(qk.office.agentRuns("agent-1", { limit: 25 }), { runs: [] }); + queryClient.setQueryData(qk.office.runs("workspace-1"), { runs: [] }); + queryClient.setQueryData(qk.office.dashboard("workspace-1"), {}); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.state_changed", + payload: { + session_id: "session-1", + task_id: "task-1", + old_state: "WAITING_FOR_INPUT", + new_state: "RUNNING", + }, + }); + + expect(queryClient.getQueryState(qk.office.agentSummary("agent-1", 14))?.isInvalidated).toBe( + true, + ); + expect( + queryClient.getQueryState(qk.office.agentRuns("agent-1", { limit: 25 }))?.isInvalidated, + ).toBe(true); + expect(queryClient.getQueryState(qk.office.runs("workspace-1"))?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(qk.office.dashboard("workspace-1"))?.isInvalidated).toBe(true); + + cleanup(); + }); + + it("upserts session messages into the stable session message cache", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.session.messages("session-1"), { + messages: [ + { + id: "message-1", + session_id: "session-1", + task_id: "task-1", + author_type: "agent", + content: "Old", + type: "message", + created_at: "2026-06-23T00:00:00Z", + }, + ], + hasMore: false, + oldestCursor: "message-1", + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.message.updated", + payload: { + task_id: "task-1", + session_id: "session-1", + message_id: "message-1", + author_type: "agent", + content: "Updated", + type: "message", + created_at: "2026-06-23T00:00:00Z", + updated_at: "2026-06-23T00:00:01Z", + }, + }); + + expect(queryClient.getQueryData(qk.session.messages("session-1"))).toMatchObject({ + messages: [{ id: "message-1", content: "Updated" }], + }); + expect(getBridgeAuditRows()).toContainEqual( + expect.objectContaining({ + action: "session.message.updated", + sessionId: "session-1", + status: "handled", + }), + ); + + cleanup(); + }); + + it("does not seed latest session messages from a single event when the cache is missing", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.message.added", + payload: { + task_id: "task-1", + session_id: "session-1", + message_id: "message-1", + author_type: "agent", + content: "Needs input", + type: "message", + created_at: "2026-06-23T00:00:00Z", + }, + }); + + expect(queryClient.getQueryData(qk.session.messages("session-1"))).toBeUndefined(); + expect(getBridgeAuditRows()).toContainEqual( + expect.objectContaining({ + action: "session.message.added", + sessionId: "session-1", + status: "handled", + }), + ); + + cleanup(); + }); + + it("removes deleted session messages from query caches", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + const keptMessage = { + id: "message-keep", + session_id: "session-1", + task_id: "task-1", + author_type: "user", + content: "Keep", + type: "message", + created_at: "2026-06-23T00:00:00Z", + }; + const deletedMessage = { + id: "message-delete", + session_id: "session-1", + task_id: "task-1", + author_type: "agent", + content: "Delete", + type: "message", + created_at: "2026-06-23T00:00:01Z", + }; + queryClient.setQueryData(qk.session.messages("session-1"), { + messages: [keptMessage, deletedMessage], + hasMore: false, + oldestCursor: "message-keep", + }); + queryClient.setQueryData(qk.session.messagesPage("session-1"), { + messages: [keptMessage, deletedMessage], + }); + queryClient.setQueryData(qk.session.messagesInfinite("session-1"), { + pages: [{ messages: [keptMessage, deletedMessage] }], + pageParams: [undefined], + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.message.deleted", + payload: { + task_id: "task-1", + session_id: "session-1", + message_id: "message-delete", + author_type: "agent", + content: "", + type: "message", + created_at: "2026-06-23T00:00:01Z", + }, + }); + + expect(queryClient.getQueryData(qk.session.messages("session-1"))).toMatchObject({ + messages: [expect.objectContaining({ id: "message-keep" })], + }); + expect(queryClient.getQueryData(qk.session.messagesPage("session-1"))).toMatchObject({ + messages: [expect.objectContaining({ id: "message-keep" })], + }); + expect(queryClient.getQueryData(qk.session.messagesInfinite("session-1"))).toMatchObject({ + pages: [{ messages: [expect.objectContaining({ id: "message-keep" })] }], + }); + expect(getBridgeAuditRows()).toContainEqual( + expect.objectContaining({ + action: "session.message.deleted", + sessionId: "session-1", + status: "handled", + }), + ); + + cleanup(); + }); + + it("patches session-by-id and invalidates task session lists on state changes", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.taskSession.byId("session-1"), { + id: "session-1", + task_id: "task-1", + state: "RUNNING", + }); + queryClient.setQueryData(qk.taskSession.byTask("task-1"), { + sessions: [{ id: "session-1", state: "RUNNING" }], + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.state_changed", + payload: { + task_id: "task-1", + session_id: "session-1", + old_state: "RUNNING", + new_state: "WAITING_FOR_INPUT", + updated_at: "2026-06-23T00:00:01Z", + }, + }); + + expect(queryClient.getQueryData(qk.taskSession.byId("session-1"))).toMatchObject({ + state: "WAITING_FOR_INPUT", + updated_at: "2026-06-23T00:00:01Z", + }); + expect(queryClient.getQueryData(qk.taskSession.byTask("task-1"))).toMatchObject({ + sessions: [{ id: "session-1", state: "WAITING_FOR_INPUT" }], + }); + expect(queryClient.getQueryState(qk.taskSession.byTask("task-1"))?.isInvalidated).toBe(true); + + cleanup(); + }); + + it("patches primary task card session state in workflow snapshot and task detail caches", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData( + qk.workflows.snapshot("workflow-1"), + workflowSnapshot([ + workflowSnapshotTask(), + workflowSnapshotTask({ + id: toTaskId("task-2"), + primary_session_id: toSessionId("session-other"), + primary_session_state: "WAITING_FOR_INPUT", + }), + ]), + ); + queryClient.setQueryData(qk.tasks.detail("task-1"), workflowSnapshotTask()); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.state_changed", + payload: { + task_id: "task-1", + session_id: "session-1", + old_state: "WAITING_FOR_INPUT", + new_state: "RUNNING", + updated_at: "2026-06-24T00:01:00Z", + }, + }); + + expect(queryClient.getQueryData(qk.workflows.snapshot("workflow-1"))).toEqual( + expect.objectContaining({ + tasks: [ + expect.objectContaining({ + id: "task-1", + primary_session_id: "session-1", + primary_session_state: "RUNNING", + }), + expect.objectContaining({ + id: "task-2", + primary_session_state: "WAITING_FOR_INPUT", + }), + ], + }), + ); + expect(queryClient.getQueryData(qk.tasks.detail("task-1"))).toMatchObject({ + primary_session_state: "RUNNING", + }); + + cleanup(); + }); + + it("does not create a full install-job list from a single install event", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "agent.install.started", + payload: { + job_id: "job-1", + agent_name: "codex", + status: "running", + started_at: "2026-06-24T00:00:00Z", + }, + }); + + expect(queryClient.getQueryData(qk.settings.installJob("job-1"))).toMatchObject({ + job_id: "job-1", + status: "running", + }); + expect(queryClient.getQueryData(qk.settings.installJobs())).toBeUndefined(); + + cleanup(); + }); + + it("patches and invalidates an existing install-job list", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.settings.installJobs(), { + jobs: [ + { + job_id: "job-1", + agent_name: "codex", + status: "running", + started_at: "2026-06-24T00:00:00Z", + }, + { + job_id: "job-2", + agent_name: "claude", + status: "running", + started_at: "2026-06-24T00:00:00Z", + }, + ], + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "agent.install.finished", + payload: { + job_id: "job-1", + agent_name: "codex", + status: "succeeded", + started_at: "2026-06-24T00:00:00Z", + finished_at: "2026-06-24T00:01:00Z", + }, + }); + + expect(queryClient.getQueryData(qk.settings.installJobs())).toMatchObject({ + jobs: [ + { job_id: "job-1", status: "succeeded" }, + { job_id: "job-2", status: "running" }, + ], + }); + expect(queryClient.getQueryState(qk.settings.installJobs())?.isInvalidated).toBe(true); + + cleanup(); + }); + + it("preserves available-agent tools when availability events omit tools", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.settings.availableAgents(), { + agents: [{ name: "codex", available: true }], + tools: [{ name: "codex", installed: true }], + total: 1, + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "agent.available.updated", + payload: { + agents: [{ name: "codex", available: false }], + }, + }); + + expect(queryClient.getQueryData(qk.settings.availableAgents())).toEqual({ + agents: [{ name: "codex", available: false }], + tools: [{ name: "codex", installed: true }], + total: 1, + }); + + cleanup(); + }); + + it("does not seed available-agent snapshots from partial availability events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "agent.available.updated", + payload: { + agents: [{ name: "codex", available: false }], + }, + }); + + expect(queryClient.getQueryData(qk.settings.availableAgents())).toBeUndefined(); + + cleanup(); + }); + + it("keeps session turns active id in the query cache", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.session.turns("session-1"), { turns: [], activeTurnId: null }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.turn.started", + payload: { + id: "turn-1", + task_id: "task-1", + session_id: "session-1", + started_at: "2026-06-23T00:00:00Z", + created_at: "2026-06-23T00:00:00Z", + updated_at: "2026-06-23T00:00:00Z", + }, + }); + ws.emit({ + type: "notification", + action: "session.turn.completed", + payload: { + id: "turn-1", + task_id: "task-1", + session_id: "session-1", + started_at: "2026-06-23T00:00:00Z", + completed_at: "2026-06-23T00:00:03Z", + created_at: "2026-06-23T00:00:00Z", + updated_at: "2026-06-23T00:00:03Z", + }, + }); + + expect(queryClient.getQueryData(qk.session.turns("session-1"))).toMatchObject({ + activeTurnId: null, + turns: [{ id: "turn-1", completed_at: "2026-06-23T00:00:03Z" }], + }); + + cleanup(); + }); + + it("does not create a full turns list from a single live turn event", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.turn.started", + payload: { + id: "turn-1", + task_id: "task-1", + session_id: "session-1", + started_at: "2026-06-23T00:00:00Z", + created_at: "2026-06-23T00:00:00Z", + updated_at: "2026-06-23T00:00:00Z", + }, + }); + + expect(queryClient.getQueryData(qk.session.turns("session-1"))).toBeUndefined(); + + cleanup(); + }); + + it("patches queue status and task-plan query caches from session events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.session.queue("session-1"), { entries: [], count: 0, max: 10 }); + queryClient.setQueryData(qk.taskPlan.detail("task-1"), null); + queryClient.setQueryData(qk.taskPlan.revisions("task-1"), []); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "message.queue.status_changed", + payload: { + session_id: "session-1", + entries: [ + { + id: "queue-1", + session_id: "session-1", + task_id: "task-1", + content: "Queued", + plan_mode: false, + queued_at: "2026-06-23T00:00:00Z", + }, + ], + count: 1, + max: 10, + }, + }); + ws.emit({ + type: "notification", + action: "task.plan.updated", + payload: { + id: "plan-1", + task_id: "task-1", + title: "Plan", + content: "Updated plan", + created_by: "agent", + created_at: "2026-06-23T00:00:00Z", + updated_at: "2026-06-23T00:00:01Z", + }, + }); + queryClient.setQueryData(qk.taskPlan.revision("task-1", "revision-1"), { + id: "revision-1", + task_id: "task-1", + content: "stale content", + }); + ws.emit({ + type: "notification", + action: "task.plan.revision.created", + payload: { + id: "revision-1", + task_id: "task-1", + revision_number: 1, + title: "Plan", + author_kind: "agent", + author_name: "Agent", + created_at: "2026-06-23T00:00:01Z", + updated_at: "2026-06-23T00:00:01Z", + }, + }); + + expect(queryClient.getQueryData(qk.session.queue("session-1"))).toMatchObject({ + count: 1, + entries: [{ id: "queue-1", content: "Queued" }], + }); + expect(queryClient.getQueryData(qk.taskPlan.detail("task-1"))).toMatchObject({ + id: "plan-1", + content: "Updated plan", + }); + expect(queryClient.getQueryData(qk.taskPlan.revisions("task-1"))).toMatchObject([ + { id: "revision-1", revision_number: 1 }, + ]); + expect(queryClient.getQueryState(qk.taskPlan.revisions("task-1"))).toMatchObject({ + isInvalidated: true, + }); + expect(queryClient.getQueryState(qk.taskPlan.revision("task-1", "revision-1"))).toMatchObject({ + isInvalidated: true, + }); + + cleanup(); + }); + + it("does not create a full revision list from a single task-plan revision event", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "task.plan.revision.created", + payload: { + id: "revision-1", + task_id: "task-1", + revision_number: 1, + title: "Plan", + author_kind: "agent", + author_name: "Agent", + created_at: "2026-06-23T00:00:01Z", + updated_at: "2026-06-23T00:00:01Z", + }, + }); + + expect(queryClient.getQueryData(qk.taskPlan.revisions("task-1"))).toBeUndefined(); + + cleanup(); + }); + + it("patches session runtime query caches from runtime events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.taskSession.byId("session-1"), { + id: "session-1", + task_id: "task-1", + task_environment_id: "env-1", + repository_id: "repo-1", + }); + queryClient.setQueryData(qk.sessionRuntime.gitStatus("env-1"), { byRepo: {} }); + queryClient.setQueryData(qk.sessionRuntime.commits("env-1"), []); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.git.event", + payload: { + type: "status_update", + session_id: "session-1", + timestamp: "2026-06-23T00:00:00Z", + status: { + branch: "feature", + remote_branch: "origin/feature", + modified: ["a.ts"], + added: [], + deleted: [], + untracked: [], + renamed: [], + ahead: 1, + behind: 0, + files: { + "a.ts": { path: "a.ts", status: "modified", staged: false }, + }, + repository_name: "repo", + }, + }, + }); + ws.emit({ + type: "notification", + action: "session.git.event", + payload: { + type: "commit_created", + session_id: "session-1", + timestamp: "2026-06-23T00:00:01Z", + commit: { + id: "commit-1", + commit_sha: "abc", + parent_sha: "base", + commit_message: "commit", + author_name: "A", + author_email: "a@example.com", + files_changed: 1, + insertions: 2, + deletions: 1, + committed_at: "2026-06-23T00:00:01Z", + }, + }, + }); + ws.emit({ + type: "notification", + action: "executor.prepare.progress", + payload: { + session_id: "session-1", + step_index: 0, + step_name: "Clone", + status: "running", + }, + }); + ws.emit({ + type: "notification", + action: "session.models_updated", + payload: { + task_id: "task-1", + session_id: "session-1", + agent_id: "agent-1", + current_model_id: "gpt-5", + models: [{ model_id: "gpt-5", name: "GPT-5", usage_multiplier: "1" }], + config_options: [], + timestamp: "2026-06-23T00:00:02Z", + }, + }); + ws.emit({ + type: "notification", + action: "session.agentctl_ready", + timestamp: "2026-06-23T00:00:03Z", + payload: { + task_id: "task-1", + session_id: "session-1", + task_environment_id: "env-1", + agent_execution_id: "exec-1", + worktree_id: "worktree-1", + worktree_path: "/tmp/kandev/worktrees/worktree-1", + worktree_branch: "feature/session", + }, + }); + ws.emit({ + type: "notification", + action: "session.process.status", + payload: { + session_id: "session-1", + process_id: "process-1", + kind: "dev", + status: "running", + timestamp: "2026-06-23T00:00:04Z", + }, + }); + + expect(queryClient.getQueryData(qk.sessionRuntime.gitStatus("env-1"))).toMatchObject({ + latest: { branch: "feature" }, + byRepo: { repo: { branch: "feature" } }, + }); + expect(queryClient.getQueryData(qk.sessionRuntime.commits("env-1"))).toMatchObject([ + { id: "commit-1", commit_sha: "abc" }, + ]); + expect(queryClient.getQueryData(qk.sessionRuntime.prepare("session-1"))).toMatchObject({ + status: "preparing", + steps: [{ name: "Clone", status: "running" }], + }); + expect(queryClient.getQueryData(qk.sessionRuntime.models("session-1"))).toMatchObject({ + currentModelId: "gpt-5", + models: [{ modelId: "gpt-5", name: "GPT-5" }], + }); + expect(queryClient.getQueryData(qk.sessionRuntime.agentctl("session-1"))).toMatchObject({ + status: "ready", + agentExecutionId: "exec-1", + }); + expect(queryClient.getQueryData(qk.sessionRuntime.worktrees("session-1"))).toEqual([ + { + id: "worktree-1", + sessionId: "session-1", + repositoryId: "repo-1", + path: "/tmp/kandev/worktrees/worktree-1", + branch: "feature/session", + }, + ]); + expect(queryClient.getQueryData(qk.sessionRuntime.processes("session-1"))).toMatchObject({ + devProcessId: "process-1", + processesById: { "process-1": { status: "running" } }, + }); + expect(getBridgeAuditRows()).toContainEqual( + expect.objectContaining({ + action: "session.models_updated", + sessionId: "session-1", + status: "handled", + }), + ); + + cleanup(); + }); + + it("does not create partial task session detail rows from agentctl events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.agentctl_ready", + timestamp: "2026-06-23T00:00:03Z", + payload: { + task_id: "task-1", + session_id: "session-1", + task_environment_id: "env-1", + agent_execution_id: "exec-1", + worktree_id: "worktree-1", + worktree_path: "/tmp/kandev/worktrees/worktree-1", + worktree_branch: "feature/session", + }, + }); + + expect(queryClient.getQueryData(qk.taskSession.byId("session-1"))).toBeUndefined(); + expect(queryClient.getQueryData(qk.sessionRuntime.agentctl("session-1"))).toMatchObject({ + status: "ready", + agentExecutionId: "exec-1", + }); + expect(queryClient.getQueryData(qk.sessionRuntime.worktrees("session-1"))).toEqual([ + { + id: "worktree-1", + sessionId: "session-1", + repositoryId: undefined, + path: "/tmp/kandev/worktrees/worktree-1", + branch: "feature/session", + }, + ]); + + cleanup(); + }); + + it("preserves existing worktree metadata when agentctl ready events omit optional fields", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.sessionRuntime.worktrees("session-1"), [ + { + id: "worktree-1", + sessionId: "session-1", + repositoryId: "repo-1", + path: "/tmp/kandev/worktrees/worktree-1", + branch: "feature/session", + }, + ]); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.agentctl_ready", + timestamp: "2026-06-23T00:00:03Z", + payload: { + task_id: "task-1", + session_id: "session-1", + task_environment_id: "env-1", + agent_execution_id: "exec-1", + worktree_id: "worktree-1", + }, + }); + + expect(queryClient.getQueryData(qk.sessionRuntime.worktrees("session-1"))).toEqual([ + { + id: "worktree-1", + sessionId: "session-1", + repositoryId: "repo-1", + path: "/tmp/kandev/worktrees/worktree-1", + branch: "feature/session", + }, + ]); + + cleanup(); + }); + + it("seeds the primary worktree when an uncached sibling worktree becomes ready", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.taskSession.byId("session-1"), { + id: "session-1", + task_id: "task-1", + task_environment_id: "env-1", + repository_id: "repo-1", + worktree_id: "primary-worktree", + worktree_path: "/tmp/kandev/worktrees/primary-worktree", + worktree_branch: "main", + }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.agentctl_ready", + timestamp: "2026-06-23T00:00:03Z", + payload: { + task_id: "task-1", + session_id: "session-1", + task_environment_id: "env-1", + agent_execution_id: "exec-1", + worktree_id: "sibling-worktree", + worktree_path: "/tmp/kandev/worktrees/sibling-worktree", + worktree_branch: "feature/sibling", + }, + }); + + expect(queryClient.getQueryData(qk.taskSession.byId("session-1"))).toMatchObject({ + worktree_id: "primary-worktree", + worktree_path: "/tmp/kandev/worktrees/primary-worktree", + worktree_branch: "main", + }); + expect(queryClient.getQueryData(qk.sessionRuntime.worktrees("session-1"))).toEqual([ + { + id: "primary-worktree", + sessionId: "session-1", + repositoryId: "repo-1", + path: "/tmp/kandev/worktrees/primary-worktree", + branch: "main", + }, + { + id: "sibling-worktree", + sessionId: "session-1", + repositoryId: "repo-1", + path: "/tmp/kandev/worktrees/sibling-worktree", + branch: "feature/sibling", + }, + ]); + + cleanup(); + }); + + it("stales session worktree queries when an agentctl event arrives before session detail", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + + const cleanup = registerBridge(ws, queryClient); + ws.emit({ + type: "notification", + action: "session.agentctl_ready", + timestamp: "2026-06-23T00:00:03Z", + payload: { + task_id: "task-1", + session_id: "session-1", + task_environment_id: "env-1", + agent_execution_id: "exec-1", + worktree_id: "sibling-worktree", + worktree_path: "/tmp/kandev/worktrees/sibling-worktree", + worktree_branch: "feature/sibling", + }, + }); + + expect(queryClient.getQueryData(qk.sessionRuntime.worktrees("session-1"))).toEqual([ + expect.objectContaining({ id: "sibling-worktree" }), + ]); + expect(queryClient.getQueryState(qk.sessionRuntime.worktrees("session-1"))?.isInvalidated).toBe( + true, + ); + + cleanup(); + }); + + it("does not record audit rows when E2E exposure is disabled", () => { + delete (window as BridgeWindow).__KANDEV_E2E_EXPOSE_STORE__; + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.tasks.detail("task-1"), { id: "task-1", title: "Old title" }); + + const cleanup = registerBridge(ws, queryClient); + ws.emit(taskUpdated({ title: "Still patched" })); + + expect(queryClient.getQueryData(qk.tasks.detail("task-1"))).toMatchObject({ + title: "Still patched", + }); + expect(getBridgeAuditRows()).toEqual([]); + expect((window as BridgeWindow).__kandev_bridge_audit__).toBeUndefined(); + + cleanup(); + }); + + it("unregisters bridge handlers and envelope audit listeners", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.tasks.detail("task-1"), { id: "task-1", title: "Old title" }); + + const cleanup = registerBridge(ws, queryClient); + cleanup(); + + ws.emit(taskUpdated({ title: "Ignored" })); + ws.emit({ + id: "response-1", + type: "response", + action: "session.subscribe", + payload: { session_id: "session-1" }, + }); + + expect(queryClient.getQueryData(qk.tasks.detail("task-1"))).toMatchObject({ + title: "Old title", + }); + expect(getBridgeAuditRows()).toEqual([]); + }); +}); diff --git a/apps/web/lib/query/bridge/index.ts b/apps/web/lib/query/bridge/index.ts new file mode 100644 index 0000000000..fa033abd86 --- /dev/null +++ b/apps/web/lib/query/bridge/index.ts @@ -0,0 +1,76 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { installBridgeAuditAccessors, recordBridgeAllowlistedEvent } from "./audit"; +import { registerOfficeBridge } from "./office"; +import { registerSessionBridge } from "./session"; +import { registerSessionRuntimeBridge } from "./session-runtime"; +import { registerSettingsSystemBridge } from "./settings-system"; +import { registerTaskBridge } from "./tasks"; +import { registerWorkspaceBridge } from "./workspace"; +import type { QueryBridgeRegistrar } from "./registrar"; + +export { + BRIDGE_SKIPPED_ACTIONS, + BRIDGE_SKIPPED_PREFIXES, + clearBridgeAuditRows, + getBridgeAuditRows, + getBridgeSkipReason, + isBridgeAuditEnabled, + isBridgeSkippedAction, + wrapBridgeHandler, + type BridgeAuditEntry, + type BridgeAuditStatus, +} from "./audit"; +export type { + QueryBridgeCleanup, + QueryBridgeRegistrar, + QueryBridgeRegistration, +} from "./registrar"; + +export interface QueryBridgeOptions { + registrars?: readonly QueryBridgeRegistrar[]; +} + +const DEFAULT_REGISTRARS: readonly QueryBridgeRegistrar[] = [ + registerTaskBridge, + registerWorkspaceBridge, + registerOfficeBridge, + registerSessionBridge, + registerSessionRuntimeBridge, + registerSettingsSystemBridge, +]; + +export function registerQueryBridge( + ws: WebSocketClient, + queryClient: QueryClient, + options: QueryBridgeOptions = {}, +): () => void { + installBridgeAuditAccessors(); + + const registrations = (options.registrars ?? DEFAULT_REGISTRARS).map((registrar) => + registrar(ws, queryClient), + ); + const handledActions = new Set(registrations.flatMap((registration) => registration.actions)); + const cleanups = registrations.map((registration) => registration.cleanup); + + cleanups.push(registerAllowlistAudit(ws, handledActions)); + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; +} + +function registerAllowlistAudit(ws: WebSocketClient, handledActions: ReadonlySet) { + return ws.onEnvelope((message: BackendMessageMap[BackendMessageType]) => { + const auditMessage = message as BridgeAuditMessage; + if (!auditMessage.action || handledActions.has(auditMessage.action)) return; + recordBridgeAllowlistedEvent(auditMessage); + }); +} + +type BridgeAuditMessage = { + action?: string; + payload?: unknown; + type?: string; +}; diff --git a/apps/web/lib/query/bridge/office.ts b/apps/web/lib/query/bridge/office.ts new file mode 100644 index 0000000000..3870e39586 --- /dev/null +++ b/apps/web/lib/query/bridge/office.ts @@ -0,0 +1,492 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { BackendMessageMap } from "@/lib/types/backend"; +import type { OfficeEventPayload } from "@/lib/types/office-events"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { qk } from "../keys"; +import { registerBridgeHandlers, type QueryBridgeRegistration } from "./registrar"; + +type SessionStateChangedMessage = BackendMessageMap["session.state_changed"]; +type OfficeBridgeHandlers = Parameters[2]; + +export function registerOfficeBridge( + ws: WebSocketClient, + queryClient: QueryClient, +): QueryBridgeRegistration { + return registerBridgeHandlers(ws, queryClient, officeBridgeHandlers(queryClient)); +} + +function officeBridgeHandlers(queryClient: QueryClient): OfficeBridgeHandlers { + return { + ...taskBridgeHandlers(queryClient), + ...agentBridgeHandlers(queryClient), + ...miscBridgeHandlers(queryClient), + ...routingBridgeHandlers(queryClient), + "session.state_changed": (message) => { + invalidateSessionDrivenOfficeSurfaces(queryClient, message); + }, + }; +} + +function taskBridgeHandlers(queryClient: QueryClient): OfficeBridgeHandlers { + return { + "office.task.updated": (message) => { + patchOfficeTask(queryClient, message.payload); + invalidateTaskSurfaces(queryClient, message.payload); + invalidateTaskLinkedSurfaces(queryClient, message.payload); + invalidateDashboard(queryClient, message.payload.workspace_id); + }, + "office.task.created": (message) => { + invalidateTaskSurfaces(queryClient, message.payload); + invalidateTaskLinkedSurfaces(queryClient, message.payload); + invalidateDashboard(queryClient, message.payload.workspace_id); + }, + "office.task.moved": (message) => { + patchOfficeTask(queryClient, message.payload); + invalidateTaskSurfaces(queryClient, message.payload); + invalidateTaskLinkedSurfaces(queryClient, message.payload); + invalidateActivity(queryClient, message.payload.workspace_id); + invalidateDashboard(queryClient, message.payload.workspace_id); + }, + "office.task.status_changed": (message) => { + patchOfficeTask(queryClient, message.payload); + invalidateTaskSurfaces(queryClient, message.payload); + invalidateTaskLinkedSurfaces(queryClient, message.payload); + invalidateDashboard(queryClient, message.payload.workspace_id); + }, + "office.comment.created": (message) => { + invalidateTaskDetail(queryClient, message.payload); + invalidateTaskComments(queryClient, message.payload); + invalidateTaskActivity(queryClient, message.payload); + invalidateActivity(queryClient, message.payload.workspace_id); + }, + "office.task.decision_recorded": (message) => { + invalidateTaskDetail(queryClient, message.payload); + invalidateTaskActivity(queryClient, message.payload); + invalidateInbox(queryClient, message.payload.workspace_id); + }, + "office.task.review_requested": (message) => { + invalidateTaskDetail(queryClient, message.payload); + invalidateTaskActivity(queryClient, message.payload); + invalidateInbox(queryClient, message.payload.workspace_id); + }, + }; +} + +function agentBridgeHandlers(queryClient: QueryClient): OfficeBridgeHandlers { + return { + "office.agent.completed": (message) => { + patchAgentStatus(queryClient, message.payload, "idle"); + invalidateAgentsDashboardRuns(queryClient, message.payload.workspace_id); + invalidateAgentSummaries(queryClient, message.payload); + invalidateAgentRunSurfaces(queryClient, message.payload); + invalidateActivity(queryClient, message.payload.workspace_id); + }, + "office.agent.failed": (message) => { + patchAgentStatus(queryClient, message.payload, "idle"); + invalidateAgentsDashboardRuns(queryClient, message.payload.workspace_id); + invalidateAgentSummaries(queryClient, message.payload); + invalidateAgentRunSurfaces(queryClient, message.payload); + invalidateInbox(queryClient, message.payload.workspace_id); + }, + "office.agent.updated": (message) => { + invalidateAgents(queryClient, message.payload.workspace_id); + invalidateAgentSummaries(queryClient, message.payload); + invalidateDashboard(queryClient, message.payload.workspace_id); + }, + }; +} + +function miscBridgeHandlers(queryClient: QueryClient): OfficeBridgeHandlers { + return { + "office.approval.created": (message) => { + invalidateInbox(queryClient, message.payload.workspace_id); + invalidateDashboard(queryClient, message.payload.workspace_id); + }, + "office.approval.resolved": (message) => { + invalidateInbox(queryClient, message.payload.workspace_id); + invalidateDashboard(queryClient, message.payload.workspace_id); + invalidateAgents(queryClient, message.payload.workspace_id); + invalidateAgentSummaries(queryClient, message.payload); + }, + "office.cost.recorded": (message) => { + invalidateCosts(queryClient, message.payload.workspace_id); + invalidateAgentSummaries(queryClient, message.payload); + invalidateDashboard(queryClient, message.payload.workspace_id); + }, + "office.run.queued": (message) => { + invalidateRunsAndTask(queryClient, message.payload); + }, + "office.run.processed": (message) => { + invalidateRunsAndTask(queryClient, message.payload); + }, + "office.routine.triggered": (message) => { + invalidateRoutines(queryClient, message.payload.workspace_id); + invalidateActivity(queryClient, message.payload.workspace_id); + invalidateDashboard(queryClient, message.payload.workspace_id); + }, + }; +} + +function routingBridgeHandlers(queryClient: QueryClient): OfficeBridgeHandlers { + return { + "office.provider.health_changed": (message) => { + patchProviderHealth(queryClient, message.payload); + invalidateRoutingSurfaces(queryClient, message.payload.workspace_id); + }, + "office.route_attempt.appended": (message) => { + patchRouteAttempt(queryClient, message.payload); + invalidateAgentRoutes(queryClient, message.payload); + }, + "office.routing.settings_updated": (message) => { + invalidateRoutingConfig(queryClient, message.payload.workspace_id); + invalidateAgentRoutes(queryClient); + }, + }; +} + +function patchOfficeTask(queryClient: QueryClient, payload: OfficeEventPayload): void { + const taskId = readId(payload.task_id ?? payload.id); + if (!taskId) return; + const patch = normalizeTaskPatch(payload); + + if (payload.workspace_id) { + queryClient.setQueryData(qk.office.task(payload.workspace_id, taskId), (current: unknown) => + isRecord(current) ? { ...current, task: patchNestedTask(current.task, patch) } : current, + ); + queryClient.setQueriesData( + { queryKey: ["office", "workspaces", payload.workspace_id, "tasks"] }, + (current: unknown) => patchTaskPages(current, taskId, patch), + ); + } +} + +function patchAgentStatus( + queryClient: QueryClient, + payload: OfficeEventPayload, + status: string, +): void { + const agentId = readId(payload.agent_profile_id ?? payload.agent_id); + if (!agentId || !payload.workspace_id) return; + queryClient.setQueryData(qk.office.agents(payload.workspace_id), (current: unknown) => { + if (!isRecord(current) || !Array.isArray(current.agents)) return current; + return { + ...current, + agents: current.agents.map((agent) => + isRecord(agent) && agent.id === agentId ? { ...agent, status } : agent, + ), + }; + }); +} + +function patchProviderHealth(queryClient: QueryClient, payload: OfficeEventPayload): void { + if (!payload.workspace_id || typeof payload.provider_id !== "string") return; + const row = { + provider_id: payload.provider_id, + scope: typeof payload.scope === "string" ? payload.scope : "provider", + scope_value: typeof payload.scope_value === "string" ? payload.scope_value : "", + state: payload.state ?? "healthy", + error_code: payload.error_code, + retry_at: payload.retry_at, + backoff_step: typeof payload.backoff_step === "number" ? payload.backoff_step : 0, + last_failure: payload.last_failure, + last_success: payload.last_success, + raw_excerpt: payload.raw_excerpt, + workspace_id: payload.workspace_id, + }; + queryClient.setQueryData(qk.office.providerHealth(payload.workspace_id), (current: unknown) => { + if (!isRecord(current) || !Array.isArray(current.health)) return current; + const health = current.health; + const next = [...health]; + const index = next.findIndex( + (item) => + isRecord(item) && + item.provider_id === row.provider_id && + item.scope === row.scope && + item.scope_value === row.scope_value, + ); + if (index >= 0) next[index] = row; + else next.push(row); + return { health: next }; + }); +} + +function patchRouteAttempt(queryClient: QueryClient, payload: OfficeEventPayload): void { + const runId = readId(payload.run_id); + const attempt = isRecord(payload.attempt) ? payload.attempt : null; + if (!runId || !attempt) return; + queryClient.setQueryData(qk.office.runAttempts(runId), (current: unknown) => { + if (!isRecord(current)) return current; + const attempts = Array.isArray(current.attempts) ? current.attempts : []; + const next = [...attempts]; + const seq = attempt.seq; + const index = next.findIndex((item) => isRecord(item) && item.seq === seq); + if (index >= 0) next[index] = attempt; + else next.push(attempt); + return { attempts: next }; + }); +} + +function invalidateTaskSurfaces(queryClient: QueryClient, payload: OfficeEventPayload): void { + invalidateWorkspaceFamily(queryClient, payload.workspace_id, "tasks"); + invalidateTaskDetail(queryClient, payload); +} + +function invalidateTaskDetail(queryClient: QueryClient, payload: OfficeEventPayload): void { + const taskId = readId(payload.task_id ?? payload.id); + if (!taskId) return; + if (payload.workspace_id) { + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.office.task(payload.workspace_id, taskId), + }); + } else { + queryClient.invalidateQueries({ queryKey: ["office", "workspaces"] }); + } +} + +function invalidateTaskComments(queryClient: QueryClient, payload: OfficeEventPayload): void { + const taskId = readId(payload.task_id ?? payload.id); + if (!taskId) return; + queryClient.invalidateQueries({ exact: true, queryKey: qk.office.taskComments(taskId) }); +} + +function invalidateTaskActivity(queryClient: QueryClient, payload: OfficeEventPayload): void { + const taskId = readId(payload.task_id ?? payload.id); + if (!taskId) return; + if (payload.workspace_id) { + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.office.taskActivity(payload.workspace_id, taskId), + }); + return; + } + queryClient.invalidateQueries({ + predicate: (query) => + query.queryKey[0] === "office" && + query.queryKey[1] === "workspaces" && + query.queryKey[3] === "tasks" && + query.queryKey[4] === taskId && + query.queryKey[5] === "activity", + }); +} + +function invalidateTaskLinkedSurfaces(queryClient: QueryClient, payload: OfficeEventPayload): void { + invalidateProjects(queryClient, payload.workspace_id); + invalidateProjectDetail(queryClient, payload); + invalidateAgentSummaries(queryClient, payload); +} + +function invalidateProjectDetail(queryClient: QueryClient, payload: OfficeEventPayload): void { + const projectId = readId(payload.project_id ?? payload.projectId); + if (!projectId) return; + queryClient.invalidateQueries({ exact: true, queryKey: qk.office.project(projectId) }); +} + +function invalidateAgentsDashboardRuns(queryClient: QueryClient, workspaceId?: string): void { + invalidateAgents(queryClient, workspaceId); + invalidateDashboard(queryClient, workspaceId); + invalidateWorkspaceFamily(queryClient, workspaceId, "runs"); +} + +function invalidateRunsAndTask(queryClient: QueryClient, payload: OfficeEventPayload): void { + invalidateWorkspaceFamily(queryClient, payload.workspace_id, "runs"); + invalidateAgents(queryClient, payload.workspace_id); + invalidateDashboard(queryClient, payload.workspace_id); + invalidateAgentSummaries(queryClient, payload); + invalidateAgentRunSurfaces(queryClient, payload); + invalidateTaskDetail(queryClient, payload); + invalidateTaskComments(queryClient, payload); + invalidateTaskActivity(queryClient, payload); +} + +function invalidateAgentSummaries(queryClient: QueryClient, payload?: OfficeEventPayload): void { + const agentId = payload ? readAgentId(payload) : null; + if (agentId) { + queryClient.invalidateQueries({ queryKey: ["office", "agents", agentId, "summary"] }); + return; + } + queryClient.invalidateQueries({ predicate: isAgentSummaryQuery }); +} + +function invalidateAgentRunSurfaces(queryClient: QueryClient, payload?: OfficeEventPayload): void { + const agentId = payload ? readAgentId(payload) : null; + if (agentId) { + queryClient.invalidateQueries({ queryKey: ["office", "agents", agentId, "runs"] }); + return; + } + queryClient.invalidateQueries({ predicate: isAgentRunsQuery }); +} + +function invalidateAgentRoutes(queryClient: QueryClient, payload?: OfficeEventPayload): void { + const agentId = payload ? readAgentId(payload) : null; + if (agentId) { + queryClient.invalidateQueries({ exact: true, queryKey: qk.office.agentRoute(agentId) }); + return; + } + queryClient.invalidateQueries({ predicate: isAgentRouteQuery }); +} + +function invalidateSessionDrivenOfficeSurfaces( + queryClient: QueryClient, + message: SessionStateChangedMessage, +): void { + const payload = message.payload as Record; + if (payload.new_state === payload.old_state) return; + queryClient.invalidateQueries({ + queryKey: ["office", "workspaces"], + predicate: isDashboardOrAgentsQuery, + }); + invalidateWorkspaceFamily(queryClient, undefined, "runs"); + invalidateAgentSummaries(queryClient); + invalidateAgentRunSurfaces(queryClient); +} + +function invalidateDashboard(queryClient: QueryClient, workspaceId?: string): void { + invalidateWorkspaceFamily(queryClient, workspaceId, "dashboard"); +} + +function invalidateAgents(queryClient: QueryClient, workspaceId?: string): void { + invalidateWorkspaceFamily(queryClient, workspaceId, "agents"); +} + +function invalidateProjects(queryClient: QueryClient, workspaceId?: string): void { + invalidateWorkspaceFamily(queryClient, workspaceId, "projects"); +} + +function invalidateInbox(queryClient: QueryClient, workspaceId?: string): void { + invalidateWorkspaceFamily(queryClient, workspaceId, "inbox"); +} + +function invalidateActivity(queryClient: QueryClient, workspaceId?: string): void { + invalidateWorkspaceFamily(queryClient, workspaceId, "activity"); +} + +function invalidateCosts(queryClient: QueryClient, workspaceId?: string): void { + invalidateWorkspaceFamily(queryClient, workspaceId, "costs"); + invalidateWorkspaceFamily(queryClient, workspaceId, "costBreakdown"); + invalidateWorkspaceFamily(queryClient, workspaceId, "budgets"); +} + +function invalidateRoutines(queryClient: QueryClient, workspaceId?: string): void { + invalidateWorkspaceFamily(queryClient, workspaceId, "routines"); + invalidateWorkspaceFamily(queryClient, workspaceId, "routineRuns"); +} + +function invalidateRoutingSurfaces(queryClient: QueryClient, workspaceId?: string): void { + invalidateWorkspaceFamily(queryClient, workspaceId, "providerHealth"); + invalidateWorkspaceFamily(queryClient, workspaceId, "routingPreview"); + invalidateAgentRoutes(queryClient); + invalidateDashboard(queryClient, workspaceId); +} + +function invalidateRoutingConfig(queryClient: QueryClient, workspaceId?: string): void { + if (workspaceId) { + queryClient.removeQueries({ exact: true, queryKey: qk.office.routing(workspaceId) }); + queryClient.invalidateQueries({ exact: true, queryKey: qk.office.routingPreview(workspaceId) }); + } else { + queryClient.invalidateQueries({ queryKey: ["office", "workspaces"] }); + } +} + +function invalidateWorkspaceFamily( + queryClient: QueryClient, + workspaceId: string | undefined, + family: string, +): void { + if (workspaceId) { + queryClient.invalidateQueries({ queryKey: ["office", "workspaces", workspaceId, family] }); + return; + } + queryClient.invalidateQueries({ + queryKey: ["office", "workspaces"], + predicate: (query) => query.queryKey[3] === family, + }); +} + +function isDashboardOrAgentsQuery(query: { queryKey: readonly unknown[] }): boolean { + return ( + query.queryKey[0] === "office" && + (query.queryKey[3] === "dashboard" || query.queryKey[3] === "agents") + ); +} + +function isAgentSummaryQuery(query: { queryKey: readonly unknown[] }): boolean { + return ( + query.queryKey[0] === "office" && + query.queryKey[1] === "agents" && + query.queryKey[3] === "summary" + ); +} + +function isAgentRunsQuery(query: { queryKey: readonly unknown[] }): boolean { + return ( + query.queryKey[0] === "office" && query.queryKey[1] === "agents" && query.queryKey[3] === "runs" + ); +} + +function isAgentRouteQuery(query: { queryKey: readonly unknown[] }): boolean { + return ( + query.queryKey[0] === "office" && + query.queryKey[1] === "agents" && + query.queryKey[3] === "route" + ); +} + +function readAgentId(payload: OfficeEventPayload): string | null { + return readId( + payload.agent_profile_id ?? + payload.agent_id ?? + payload.assignee_agent_profile_id ?? + payload.assigneeAgentProfileId, + ); +} + +function patchNestedTask(currentTask: unknown, patch: Record) { + return isRecord(currentTask) ? { ...currentTask, ...patch } : currentTask; +} + +function patchTaskPages(current: unknown, taskId: string, patch: Record) { + if (!isRecord(current) || !Array.isArray(current.pages)) return current; + return { + ...current, + pages: current.pages.map((page) => + isRecord(page) && Array.isArray(page.tasks) + ? { + ...page, + tasks: page.tasks.map((task) => + isRecord(task) && task.id === taskId ? { ...task, ...patch } : task, + ), + } + : page, + ), + }; +} + +function normalizeTaskPatch(payload: OfficeEventPayload): Record { + const patch: Record = {}; + copyField(payload, patch, "title", "title"); + copyField(payload, patch, "description", "description"); + copyField(payload, patch, "status", "status"); + copyField(payload, patch, "new_status", "status"); + copyField(payload, patch, "priority", "priority"); + copyField(payload, patch, "updated_at", "updatedAt"); + copyField(payload, patch, "assignee_agent_profile_id", "assigneeAgentProfileId"); + return patch; +} + +function copyField( + source: Record, + target: Record, + sourceKey: string, + targetKey: string, +): void { + if (source[sourceKey] !== undefined) target[targetKey] = source[sourceKey]; +} + +function readId(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/web/lib/query/bridge/registrar.ts b/apps/web/lib/query/bridge/registrar.ts new file mode 100644 index 0000000000..a5ef10f0d8 --- /dev/null +++ b/apps/web/lib/query/bridge/registrar.ts @@ -0,0 +1,44 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { wrapBridgeHandler } from "./audit"; + +export type QueryBridgeCleanup = () => void; + +export interface QueryBridgeRegistration { + actions: readonly string[]; + cleanup: QueryBridgeCleanup; +} + +export type QueryBridgeRegistrar = ( + ws: WebSocketClient, + queryClient: QueryClient, +) => QueryBridgeRegistration; + +type BridgeHandlerMap = { + [Action in BackendMessageType]?: (message: BackendMessageMap[Action]) => void; +}; + +export function registerBridgeHandlers( + ws: WebSocketClient, + queryClient: QueryClient, + handlers: BridgeHandlerMap, +): QueryBridgeRegistration { + const cleanups = Object.entries(handlers).map(([action, handler]) => + ws.on( + action as BackendMessageType, + wrapBridgeHandler( + queryClient, + action, + handler as (message: BackendMessageMap[BackendMessageType]) => void, + ), + ), + ); + + return { + actions: Object.keys(handlers), + cleanup: () => { + cleanups.forEach((cleanup) => cleanup()); + }, + }; +} diff --git a/apps/web/lib/query/bridge/session-runtime.test.ts b/apps/web/lib/query/bridge/session-runtime.test.ts new file mode 100644 index 0000000000..921a080eca --- /dev/null +++ b/apps/web/lib/query/bridge/session-runtime.test.ts @@ -0,0 +1,156 @@ +import { expect, it } from "vitest"; +import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; +import type { BackendMessage } from "@/lib/types/backend-message"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { makeQueryClient } from "../client"; +import { qk } from "../keys"; +import { registerSessionRuntimeBridge } from "./session-runtime"; + +type AnyBackendMessage = BackendMessage>; +type Handler = (message: AnyBackendMessage) => void; +type BridgeHarness = { + ws: FakeWebSocketClient; + queryClient: ReturnType; + cleanup: () => void; +}; + +class FakeWebSocketClient { + private handlers = new Map>(); + + on(type: T, handler: (message: BackendMessageMap[T]) => void) { + const bucket = this.handlers.get(type) ?? new Set(); + bucket.add(handler as Handler); + this.handlers.set(type, bucket); + return () => { + bucket.delete(handler as Handler); + }; + } + + emit(message: AnyBackendMessage) { + this.handlers.get(message.action)?.forEach((handler) => handler(message)); + } +} + +function setupBridge(): BridgeHarness { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + const registration = registerSessionRuntimeBridge(ws as unknown as WebSocketClient, queryClient); + return { ws, queryClient, cleanup: registration.cleanup }; +} + +it("patches prompt usage and todo caches from runtime events", () => { + const { ws, queryClient, cleanup } = setupBridge(); + + ws.emit({ + type: "notification", + action: "session.prompt_usage", + payload: { + task_id: "task-1", + session_id: "session-1", + agent_id: "agent-1", + usage: { + input_tokens: 100, + output_tokens: 25, + cached_read_tokens: 10, + total_tokens: 125, + }, + timestamp: "2026-06-23T00:00:05Z", + }, + }); + ws.emit({ + type: "notification", + action: "session.todos_updated", + payload: { + task_id: "task-1", + session_id: "session-1", + agent_id: "agent-1", + entries: [ + { + description: "Run verification", + status: "completed", + priority: "high", + }, + ], + timestamp: "2026-06-23T00:00:07Z", + }, + }); + + expect(queryClient.getQueryData(qk.sessionRuntime.promptUsage("session-1"))).toMatchObject({ + inputTokens: 100, + outputTokens: 25, + cachedReadTokens: 10, + totalTokens: 125, + }); + expect(queryClient.getQueryData(qk.sessionRuntime.todos("session-1"))).toEqual([ + { + description: "Run verification", + status: "completed", + priority: "high", + }, + ]); + + cleanup(); +}); + +it("patches agent capabilities and poll mode caches from runtime events", () => { + const { ws, queryClient, cleanup } = setupBridge(); + + ws.emit({ + type: "notification", + action: "session.agent_capabilities", + payload: { + task_id: "task-1", + session_id: "session-1", + agent_id: "agent-1", + supports_image: true, + supports_audio: false, + supports_embedded_context: true, + auth_methods: [ + { + id: "login", + name: "Login", + description: "Authenticate in terminal", + terminal_auth: { + command: "agent", + args: ["login"], + label: "Run login", + }, + }, + ], + timestamp: "2026-06-23T00:00:06Z", + }, + }); + ws.emit({ + type: "notification", + action: "session.poll_mode_changed", + payload: { + task_id: "task-1", + session_id: "session-1", + poll_mode: "slow", + reason: "subscribed", + timestamp: "2026-06-23T00:00:08Z", + }, + }); + + expect(queryClient.getQueryData(qk.sessionRuntime.agentCapabilities("session-1"))).toEqual({ + supportsImage: true, + supportsAudio: false, + supportsEmbeddedContext: true, + authMethods: [ + { + id: "login", + name: "Login", + description: "Authenticate in terminal", + terminalAuth: { + command: "agent", + args: ["login"], + label: "Run login", + }, + meta: undefined, + }, + ], + }); + expect(queryClient.getQueryData(qk.sessionRuntime.pollMode("session-1"))).toBe("slow"); + + cleanup(); +}); diff --git a/apps/web/lib/query/bridge/session-runtime.ts b/apps/web/lib/query/bridge/session-runtime.ts new file mode 100644 index 0000000000..207ca6fb2a --- /dev/null +++ b/apps/web/lib/query/bridge/session-runtime.ts @@ -0,0 +1,567 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { invalidateCumulativeDiffCache } from "@/hooks/domains/session/use-cumulative-diff"; +import { hasGitStatusChanged } from "@/lib/state/slices/session-runtime/session-runtime-slice"; +import type { + GitStatusEntry, + ProcessStatusEntry, + SessionCommit, + SessionPollMode, + SessionPrepareState, +} from "@/lib/state/slices/session-runtime/types"; +import type { Worktree } from "@/lib/state/slices/session/types"; +import type { BackendMessageMap } from "@/lib/types/backend"; +import type { + GitBranchSwitchedEvent, + GitCommitCreatedEvent, + GitCommitsResetEvent, + GitEventPayload, + GitStatusUpdateEvent, +} from "@/lib/types/git-events"; +import { qk } from "../keys"; +import type { + GitStatusQueryData, + SessionModelsQueryData, + SessionProcessesQueryData, +} from "../query-options/session-runtime"; +import { registerBridgeHandlers, type QueryBridgeRegistration } from "./registrar"; + +const VALID_POLL_MODES = new Set(["fast", "slow", "paused"]); + +export function registerSessionRuntimeBridge( + ws: Parameters[0], + queryClient: QueryClient, +): QueryBridgeRegistration { + return registerBridgeHandlers(ws, queryClient, { + "session.git.event": (message) => patchGitEvent(queryClient, message.payload), + "executor.prepare.progress": (message) => patchPrepareProgress(queryClient, message.payload), + "executor.prepare.completed": (message) => patchPrepareCompleted(queryClient, message.payload), + "session.state_changed": (message) => patchContextWindowFromState(queryClient, message), + "session.agentctl_starting": (message) => + patchAgentctl(queryClient, message.payload, "starting", message.timestamp), + "session.agentctl_ready": (message) => + patchAgentctl(queryClient, message.payload, "ready", message.timestamp), + "session.agentctl_error": (message) => + patchAgentctl(queryClient, message.payload, "error", message.timestamp), + "session.available_commands": (message) => + queryClient.setQueryData( + qk.sessionRuntime.availableCommands(message.payload.session_id), + message.payload.available_commands ?? [], + ), + "session.mode_changed": (message) => patchSessionMode(queryClient, message), + "session.agent_capabilities": (message) => patchAgentCapabilities(queryClient, message), + "session.models_updated": (message) => patchSessionModels(queryClient, message), + "session.info_updated": (message) => patchSessionInfo(queryClient, message), + "session.prompt_usage": (message) => patchPromptUsage(queryClient, message), + "session.todos_updated": (message) => patchTodos(queryClient, message), + "session.poll_mode_changed": (message) => patchPollMode(queryClient, message), + "session.process.status": (message) => patchProcessStatus(queryClient, message), + }); +} + +function patchGitEvent(queryClient: QueryClient, payload: GitEventPayload): void { + if (!payload.session_id || !payload.type) return; + switch (payload.type) { + case "status_update": + patchGitStatus(queryClient, payload); + return; + case "commit_created": + patchCommitCreated(queryClient, payload); + return; + case "commits_reset": + case "branch_switched": + invalidateCommits(queryClient, payload); + return; + } +} + +function patchGitStatus(queryClient: QueryClient, event: GitStatusUpdateEvent): void { + const envKey = resolveEnvKey(queryClient, event.session_id); + const row: GitStatusEntry = { + branch: event.status.branch, + remote_branch: event.status.remote_branch, + modified: event.status.modified, + added: event.status.added, + deleted: event.status.deleted, + untracked: event.status.untracked, + renamed: event.status.renamed, + ahead: event.status.ahead, + behind: event.status.behind, + files: event.status.files, + timestamp: event.timestamp, + branch_additions: event.status.branch_additions, + branch_deletions: event.status.branch_deletions, + repository_name: event.status.repository_name, + }; + const repoName = row.repository_name ?? ""; + let changed = false; + queryClient.setQueryData(qk.sessionRuntime.gitStatus(envKey), (current: unknown) => { + const currentData = readGitStatusData(current); + const existing = currentData.byRepo[repoName]; + changed = !existing || hasGitStatusChanged(existing, row); + if (!changed) return currentData; + return { + latest: row, + byRepo: { ...currentData.byRepo, [repoName]: row }, + }; + }); + if (changed) invalidateCumulativeDiffCache(envKey); +} + +function patchCommitCreated(queryClient: QueryClient, event: GitCommitCreatedEvent): void { + const envKey = resolveEnvKey(queryClient, event.session_id); + const commit: SessionCommit = { + id: event.commit.id, + session_id: event.session_id, + commit_sha: event.commit.commit_sha, + parent_sha: event.commit.parent_sha, + commit_message: event.commit.commit_message, + author_name: event.commit.author_name, + author_email: event.commit.author_email, + files_changed: event.commit.files_changed, + insertions: event.commit.insertions, + deletions: event.commit.deletions, + committed_at: event.commit.committed_at, + created_at: event.commit.created_at ?? event.timestamp, + repository_name: event.commit.repository_name, + }; + queryClient.setQueryData(qk.sessionRuntime.commits(envKey), (current: unknown) => { + const existing = Array.isArray(current) ? (current as SessionCommit[]) : []; + if (existing.length > 0 && existing[0]?.parent_sha === commit.parent_sha) { + return [commit, ...existing.slice(1)]; + } + return [commit, ...existing]; + }); + invalidateCumulativeDiffCache(envKey); +} + +function invalidateCommits( + queryClient: QueryClient, + event: GitCommitsResetEvent | GitBranchSwitchedEvent, +): void { + const envKey = resolveEnvKey(queryClient, event.session_id); + queryClient.invalidateQueries({ exact: true, queryKey: qk.sessionRuntime.commits(envKey) }); + invalidateCumulativeDiffCache(envKey); +} + +function patchPrepareProgress( + queryClient: QueryClient, + payload: BackendMessageMap["executor.prepare.progress"]["payload"], +): void { + if (!payload.session_id) return; + queryClient.setQueryData(qk.sessionRuntime.prepare(payload.session_id), (current: unknown) => { + const existing = isRecord(current) ? (current as Partial) : {}; + const steps = [...(Array.isArray(existing.steps) ? existing.steps : [])]; + while (steps.length <= payload.step_index) steps.push({ name: "", status: "pending" }); + steps[payload.step_index] = { + name: payload.step_name, + command: payload.step_command, + status: payload.status, + output: payload.output, + error: payload.error, + warning: payload.warning, + warningDetail: payload.warning_detail, + startedAt: payload.started_at, + endedAt: payload.ended_at, + }; + return { ...existing, sessionId: payload.session_id, status: "preparing", steps }; + }); +} + +function patchPrepareCompleted( + queryClient: QueryClient, + payload: BackendMessageMap["executor.prepare.completed"]["payload"], +): void { + if (!payload.session_id) return; + queryClient.setQueryData(qk.sessionRuntime.prepare(payload.session_id), (current: unknown) => { + const existing = isRecord(current) ? (current as Partial) : {}; + const steps = payload.steps?.length + ? payload.steps.map((step) => ({ + name: step.name, + command: step.command, + status: step.status, + output: step.output, + error: step.error, + warning: step.warning, + warningDetail: step.warning_detail, + startedAt: step.started_at, + endedAt: step.ended_at, + })) + : existing.steps; + return { + ...existing, + sessionId: payload.session_id, + status: payload.success ? "completed" : "failed", + steps: steps ?? [], + errorMessage: payload.error_message, + durationMs: payload.duration_ms, + }; + }); +} + +function patchContextWindowFromState( + queryClient: QueryClient, + message: BackendMessageMap["session.state_changed"], +): void { + const { session_id: sessionId } = message.payload; + if (!sessionId) return; + const metadata = message.payload.metadata ?? message.payload.session_metadata; + const contextWindow = isRecord(metadata) ? metadata.context_window : null; + if (!isRecord(contextWindow)) return; + queryClient.setQueryData(qk.sessionRuntime.contextWindow(sessionId), { + size: Number(contextWindow.size ?? 0), + used: Number(contextWindow.used ?? 0), + remaining: Number(contextWindow.remaining ?? 0), + efficiency: Number(contextWindow.efficiency ?? 0), + timestamp: + typeof contextWindow.timestamp === "string" + ? contextWindow.timestamp + : new Date().toISOString(), + }); +} + +function patchAgentctl( + queryClient: QueryClient, + payload: BackendMessageMap["session.agentctl_ready"]["payload"], + status: "starting" | "ready" | "error", + timestamp: string | undefined, +): void { + if (!payload.session_id) return; + queryClient.setQueryData(qk.sessionRuntime.agentctl(payload.session_id), { + status, + agentExecutionId: payload.agent_execution_id, + errorMessage: payload.error_message, + updatedAt: timestamp, + }); + patchSessionEnvironment(queryClient, payload); + patchSessionWorktrees(queryClient, payload); +} + +function patchSessionEnvironment( + queryClient: QueryClient, + payload: BackendMessageMap["session.agentctl_ready"]["payload"], +): void { + if (!payload.session_id) return; + queryClient.setQueryData(qk.taskSession.byId(payload.session_id), (current: unknown) => { + if (!isRecord(current)) return current; + const existing = current; + const existingWorktreeId = stringField(existing.worktree_id); + const isSiblingWorktree = + !!payload.worktree_id && !!existingWorktreeId && payload.worktree_id !== existingWorktreeId; + return { + ...existing, + id: payload.session_id, + task_id: payload.task_id ?? existing.task_id, + task_environment_id: payload.task_environment_id ?? existing.task_environment_id, + worktree_id: isSiblingWorktree + ? existing.worktree_id + : (payload.worktree_id ?? existing.worktree_id), + worktree_path: isSiblingWorktree + ? (payload.task_workspace_path ?? existing.worktree_path) + : (payload.task_workspace_path ?? payload.worktree_path ?? existing.worktree_path), + worktree_branch: isSiblingWorktree + ? existing.worktree_branch + : (payload.worktree_branch ?? existing.worktree_branch), + }; + }); +} + +function patchSessionWorktrees( + queryClient: QueryClient, + payload: BackendMessageMap["session.agentctl_ready"]["payload"], +): void { + if (!payload.session_id || !payload.worktree_id) return; + const sessionId = payload.session_id; + const worktreeId = payload.worktree_id; + const existingSession = queryClient.getQueryData>( + qk.taskSession.byId(sessionId), + ); + const queryKey = qk.sessionRuntime.worktrees(sessionId); + const currentWorktrees = queryClient.getQueryData(queryKey); + const doubleAbsent = currentWorktrees === undefined && existingSession === undefined; + queryClient.setQueryData(queryKey, (current) => { + const existing = seedWorktreesFromSession(sessionId, existingSession, current); + const existingWorktree = existing.find((worktree) => worktree.id === worktreeId); + const nextWorktree: Worktree = { + id: worktreeId, + sessionId, + repositoryId: stringField(existingSession?.repository_id) ?? existingWorktree?.repositoryId, + path: + payload.worktree_path ?? + stringField(existingSession?.worktree_path) ?? + existingWorktree?.path, + branch: + payload.worktree_branch ?? + stringField(existingSession?.worktree_branch) ?? + existingWorktree?.branch, + }; + return existing.some((worktree) => worktree.id === nextWorktree.id) + ? existing.map((worktree) => (worktree.id === nextWorktree.id ? nextWorktree : worktree)) + : [...existing, nextWorktree]; + }); + if (doubleAbsent) { + queryClient.invalidateQueries({ exact: true, queryKey: qk.taskSession.byId(sessionId) }); + queryClient.invalidateQueries({ exact: true, queryKey }); + } +} + +function seedWorktreesFromSession( + sessionId: string, + session: Record | undefined, + current: Worktree[] | undefined, +): Worktree[] { + const cachedWorktrees = current ?? []; + if (current !== undefined) return cachedWorktrees; + const primaryWorktree = worktreeFromSession(sessionId, session); + if (!primaryWorktree || cachedWorktrees.some((worktree) => worktree.id === primaryWorktree.id)) { + return cachedWorktrees; + } + return [primaryWorktree, ...cachedWorktrees]; +} + +function worktreeFromSession( + sessionId: string, + session: Record | undefined, +): Worktree | null { + const id = stringField(session?.worktree_id); + if (!id) return null; + return { + id, + sessionId, + repositoryId: stringField(session?.repository_id), + path: stringField(session?.worktree_path), + branch: stringField(session?.worktree_branch), + }; +} + +function stringField(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function patchSessionMode( + queryClient: QueryClient, + message: BackendMessageMap["session.mode_changed"], +): void { + const payload = message.payload; + if (!payload.session_id) return; + queryClient.setQueryData(qk.sessionRuntime.mode(payload.session_id), { + currentModeId: payload.current_mode_id || "", + availableModes: (payload.available_modes ?? []).map((mode) => ({ + id: mode.id, + name: mode.name, + description: mode.description, + })), + }); +} + +function patchAgentCapabilities( + queryClient: QueryClient, + message: BackendMessageMap["session.agent_capabilities"], +): void { + const payload = message.payload; + if (!payload.session_id) return; + queryClient.setQueryData(qk.sessionRuntime.agentCapabilities(payload.session_id), { + supportsImage: payload.supports_image, + supportsAudio: payload.supports_audio, + supportsEmbeddedContext: payload.supports_embedded_context, + authMethods: (payload.auth_methods ?? []).map((method) => ({ + id: method.id, + name: method.name, + description: method.description, + terminalAuth: method.terminal_auth + ? { + command: method.terminal_auth.command, + args: method.terminal_auth.args, + label: method.terminal_auth.label, + } + : undefined, + meta: method.meta, + })), + }); +} + +function patchSessionModels( + queryClient: QueryClient, + message: BackendMessageMap["session.models_updated"], +): void { + const payload = message.payload; + if (!payload.session_id) return; + const currentModelId = resolveCurrentModelId(payload); + let previousModelId = ""; + const next: SessionModelsQueryData = { + currentModelId, + models: (payload.models ?? []).map((model) => ({ + modelId: model.model_id, + name: model.name, + description: model.description, + usageMultiplier: model.usage_multiplier, + meta: model.meta, + })), + configOptions: (payload.config_options ?? []).map((option) => ({ + type: option.type, + id: option.id, + name: option.name, + currentValue: option.current_value, + category: option.category, + options: option.options, + })), + }; + queryClient.setQueryData(qk.sessionRuntime.models(payload.session_id), (current: unknown) => { + previousModelId = isRecord(current) ? String(current.currentModelId ?? "") : ""; + return next; + }); + if (previousModelId && currentModelId && previousModelId !== currentModelId) { + queryClient.setQueryData(qk.sessionRuntime.contextWindow(payload.session_id), null); + } +} + +function patchSessionInfo( + queryClient: QueryClient, + message: BackendMessageMap["session.info_updated"], +): void { + const payload = message.payload; + if (!payload.session_id) return; + queryClient.setQueryData(qk.taskSession.byId(payload.session_id), (current: unknown) => { + if (!isRecord(current)) return current; + const currentMetadata = isRecord(current.metadata) ? current.metadata : {}; + const existingAcp = readExistingAcp(currentMetadata.acp); + if (isStale(payload.session_updated_at, existingAcp.updated_at)) return current; + return { + ...current, + metadata: { + ...currentMetadata, + acp: { + session_id: payload.acp_session_id || existingAcp.session_id, + title: payload.session_title || existingAcp.title, + updated_at: payload.session_updated_at || existingAcp.updated_at, + meta: payload.session_meta ?? existingAcp.meta, + }, + }, + }; + }); +} + +function patchPromptUsage( + queryClient: QueryClient, + message: BackendMessageMap["session.prompt_usage"], +): void { + const payload = message.payload; + if (!payload.session_id || !payload.usage) return; + queryClient.setQueryData(qk.sessionRuntime.promptUsage(payload.session_id), { + inputTokens: payload.usage.input_tokens, + outputTokens: payload.usage.output_tokens, + cachedReadTokens: payload.usage.cached_read_tokens, + cachedWriteTokens: payload.usage.cached_write_tokens, + totalTokens: payload.usage.total_tokens, + }); +} + +function patchTodos(queryClient: QueryClient, message: BackendMessageMap["session.todos_updated"]) { + const payload = message.payload; + if (!payload.session_id) return; + queryClient.setQueryData( + qk.sessionRuntime.todos(payload.session_id), + (payload.entries ?? []).map((entry) => ({ + description: entry.description, + status: entry.status, + priority: entry.priority, + })), + ); +} + +function patchPollMode( + queryClient: QueryClient, + message: BackendMessageMap["session.poll_mode_changed"], +): void { + const { session_id: sessionId, poll_mode: pollMode } = message.payload; + if (!sessionId || !VALID_POLL_MODES.has(pollMode as SessionPollMode)) return; + queryClient.setQueryData(qk.sessionRuntime.pollMode(sessionId), pollMode as SessionPollMode); +} + +function patchProcessStatus( + queryClient: QueryClient, + message: BackendMessageMap["session.process.status"], +): void { + const payload = message.payload; + if (!payload.session_id || !payload.process_id || !payload.status) return; + const row: ProcessStatusEntry = { + processId: payload.process_id, + sessionId: payload.session_id, + kind: payload.kind, + scriptName: payload.script_name, + status: payload.status, + command: payload.command, + workingDir: payload.working_dir, + exitCode: payload.exit_code, + updatedAt: payload.timestamp, + }; + queryClient.setQueryData(qk.sessionRuntime.processes(payload.session_id), (current: unknown) => { + const existing = readProcessesData(current); + return { + ...existing, + processesById: { ...existing.processesById, [row.processId]: row }, + processIds: existing.processIds.includes(row.processId) + ? existing.processIds + : [...existing.processIds, row.processId], + devProcessId: row.kind === "dev" ? row.processId : existing.devProcessId, + }; + }); +} + +function resolveCurrentModelId(payload: BackendMessageMap["session.models_updated"]["payload"]) { + if (payload.current_model_id) return payload.current_model_id; + const modelOption = (payload.config_options ?? []).find( + (option) => option.id === "model" || option.category === "model", + ); + return modelOption?.current_value ?? ""; +} + +function resolveEnvKey(queryClient: QueryClient, sessionId: string): string { + const session = queryClient.getQueryData(qk.taskSession.byId(sessionId)); + if (isRecord(session) && typeof session.task_environment_id === "string") { + return session.task_environment_id; + } + return sessionId; +} + +function readGitStatusData(current: unknown): GitStatusQueryData { + if (!isRecord(current)) return { byRepo: {} }; + return { + latest: isRecord(current.latest) ? (current.latest as GitStatusEntry) : undefined, + byRepo: isRecord(current.byRepo) ? (current.byRepo as Record) : {}, + }; +} + +function readProcessesData(current: unknown): SessionProcessesQueryData { + if (!isRecord(current)) return { processesById: {}, processIds: [] }; + return { + processesById: isRecord(current.processesById) + ? (current.processesById as Record) + : {}, + processIds: Array.isArray(current.processIds) ? (current.processIds as string[]) : [], + activeProcessId: + typeof current.activeProcessId === "string" ? current.activeProcessId : undefined, + devProcessId: typeof current.devProcessId === "string" ? current.devProcessId : undefined, + }; +} + +function isStale(incomingUpdatedAt: string | undefined, existingUpdatedAt: string): boolean { + if (!incomingUpdatedAt || !existingUpdatedAt) return false; + const incoming = Date.parse(incomingUpdatedAt); + const existing = Date.parse(existingUpdatedAt); + if (Number.isNaN(incoming) || Number.isNaN(existing)) return false; + return incoming < existing; +} + +function readExistingAcp(value: unknown) { + if (!isRecord(value)) return { session_id: "", title: "", updated_at: "", meta: {} }; + return { + session_id: typeof value.session_id === "string" ? value.session_id : "", + title: typeof value.title === "string" ? value.title : "", + updated_at: typeof value.updated_at === "string" ? value.updated_at : "", + meta: isRecord(value.meta) ? value.meta : {}, + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/web/lib/query/bridge/session.test.ts b/apps/web/lib/query/bridge/session.test.ts new file mode 100644 index 0000000000..60ac22d685 --- /dev/null +++ b/apps/web/lib/query/bridge/session.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from "vitest"; +import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; +import type { BackendMessage } from "@/lib/types/backend-message"; +import type { TaskPlan, TaskSession } from "@/lib/types/http"; +import { agentProfileId } from "@/lib/types/ids"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { makeQueryClient } from "../client"; +import { qk } from "../keys"; +import { registerSessionBridge } from "./session"; + +const TEST_SESSION_ID = "session-1"; +const TEST_TASK_ID = "task-1"; +const TEST_PROFILE_ID = agentProfileId("profile-1"); +const TEST_STARTED_AT = "2026-06-24T00:00:00Z"; +const TEST_UPDATED_AT = "2026-06-24T00:00:05Z"; +const TEST_AGENT_NAME = "Codex"; +const TEST_AGENT_ERROR = "peer disconnected before response"; +const TEST_PLAN_ID = "plan-1"; +const SESSION_STATE_CHANGED_ACTION = "session.state_changed"; + +type AnyBackendMessage = BackendMessage>; +type Handler = (message: AnyBackendMessage) => void; + +class FakeWebSocketClient { + private handlers = new Map>(); + + on(type: T, handler: (message: BackendMessageMap[T]) => void) { + const bucket = this.handlers.get(type) ?? new Set(); + bucket.add(handler as Handler); + this.handlers.set(type, bucket); + return () => { + bucket.delete(handler as Handler); + }; + } + + emit(message: AnyBackendMessage) { + this.handlers.get(message.action)?.forEach((handler) => handler(message)); + } +} + +function setupBridge() { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + const registration = registerSessionBridge(ws as unknown as WebSocketClient, queryClient); + return { ws, queryClient, cleanup: registration.cleanup }; +} + +function makeSession(overrides: Partial = {}): TaskSession { + return { + id: TEST_SESSION_ID, + task_id: TEST_TASK_ID, + state: "STARTING", + started_at: TEST_STARTED_AT, + updated_at: TEST_STARTED_AT, + ...overrides, + } as TaskSession; +} + +function makeTaskPlan(overrides: Partial = {}): TaskPlan { + return { + id: TEST_PLAN_ID, + task_id: TEST_TASK_ID, + title: "Plan", + content: "# Plan", + created_by: "agent", + created_at: TEST_STARTED_AT, + updated_at: TEST_UPDATED_AT, + ...overrides, + }; +} + +describe("session query bridge state events — identity", () => { + it("preserves session identity fields when a partial state event patches the cache", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData( + qk.taskSession.byId(TEST_SESSION_ID), + makeSession({ + agent_profile_id: TEST_PROFILE_ID, + task_environment_id: "env-1", + agent_profile_snapshot: { name: TEST_AGENT_NAME }, + }), + ); + queryClient.setQueryData(qk.taskSession.byTask(TEST_TASK_ID), { + sessions: [ + makeSession({ + agent_profile_id: TEST_PROFILE_ID, + task_environment_id: "env-1", + agent_profile_snapshot: { name: TEST_AGENT_NAME }, + }), + ], + }); + + ws.emit({ + type: "notification", + action: SESSION_STATE_CHANGED_ACTION, + payload: { + task_id: TEST_TASK_ID, + session_id: TEST_SESSION_ID, + new_state: "WAITING_FOR_INPUT", + updated_at: TEST_UPDATED_AT, + }, + }); + + expect(queryClient.getQueryData(qk.taskSession.byId(TEST_SESSION_ID))).toMatchObject({ + state: "WAITING_FOR_INPUT", + agent_profile_id: TEST_PROFILE_ID, + task_environment_id: "env-1", + agent_profile_snapshot: { name: TEST_AGENT_NAME }, + }); + expect( + queryClient.getQueryData<{ sessions: TaskSession[] }>(qk.taskSession.byTask(TEST_TASK_ID)), + ).toMatchObject({ + sessions: [ + { + state: "WAITING_FOR_INPUT", + agent_profile_id: TEST_PROFILE_ID, + task_environment_id: "env-1", + agent_profile_snapshot: { name: TEST_AGENT_NAME }, + }, + ], + }); + + cleanup(); + }); + + it("upserts agent profile fields from state events before the HTTP session row loads", () => { + const { ws, queryClient, cleanup } = setupBridge(); + + ws.emit({ + type: "notification", + action: SESSION_STATE_CHANGED_ACTION, + payload: { + task_id: TEST_TASK_ID, + session_id: TEST_SESSION_ID, + new_state: "RUNNING", + agent_profile_id: TEST_PROFILE_ID, + task_environment_id: "env-1", + agent_profile_snapshot: { name: TEST_AGENT_NAME }, + updated_at: TEST_UPDATED_AT, + }, + }); + + expect(queryClient.getQueryData(qk.taskSession.byId(TEST_SESSION_ID))).toMatchObject({ + id: TEST_SESSION_ID, + task_id: TEST_TASK_ID, + state: "RUNNING", + agent_profile_id: TEST_PROFILE_ID, + task_environment_id: "env-1", + agent_profile_snapshot: { name: TEST_AGENT_NAME }, + }); + expect(queryClient.getQueryState(qk.taskSession.byId(TEST_SESSION_ID))?.isInvalidated).toBe( + true, + ); + + cleanup(); + }); +}); + +describe("session query bridge state events — task list upserts", () => { + it("inserts missing sessions into an already-cached by-task list", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData(qk.taskSession.byTask(TEST_TASK_ID), { + sessions: [makeSession({ id: "session-existing" as TaskSession["id"] })], + }); + + ws.emit({ + type: "notification", + action: SESSION_STATE_CHANGED_ACTION, + payload: { + task_id: TEST_TASK_ID, + session_id: TEST_SESSION_ID, + new_state: "RUNNING", + agent_profile_id: TEST_PROFILE_ID, + updated_at: TEST_UPDATED_AT, + }, + }); + + expect( + queryClient.getQueryData<{ sessions: TaskSession[] }>(qk.taskSession.byTask(TEST_TASK_ID)), + ).toMatchObject({ + sessions: [ + { id: "session-existing" }, + { + id: TEST_SESSION_ID, + task_id: TEST_TASK_ID, + state: "RUNNING", + agent_profile_id: TEST_PROFILE_ID, + started_at: TEST_UPDATED_AT, + updated_at: TEST_UPDATED_AT, + }, + ], + }); + expect(queryClient.getQueryState(qk.taskSession.byTask(TEST_TASK_ID))?.isInvalidated).toBe( + true, + ); + + cleanup(); + }); +}); + +describe("session query bridge state events — metadata", () => { + it("merges partial metadata updates without dropping existing session metadata", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData( + qk.taskSession.byId(TEST_SESSION_ID), + makeSession({ + metadata: { + last_agent_error: { + message: TEST_AGENT_ERROR, + occurred_at: TEST_STARTED_AT, + }, + }, + }), + ); + queryClient.setQueryData(qk.taskSession.byTask(TEST_TASK_ID), { + sessions: [ + makeSession({ + metadata: { + last_agent_error: { + message: TEST_AGENT_ERROR, + occurred_at: TEST_STARTED_AT, + }, + }, + }), + ], + }); + + ws.emit({ + type: "notification", + action: SESSION_STATE_CHANGED_ACTION, + payload: { + task_id: TEST_TASK_ID, + session_id: TEST_SESSION_ID, + metadata: { + context_window: { size: 256000, used: 1024, remaining: 254976, efficiency: 0.004 }, + }, + updated_at: TEST_UPDATED_AT, + }, + }); + + expect(queryClient.getQueryData(qk.taskSession.byId(TEST_SESSION_ID))).toMatchObject({ + metadata: { + last_agent_error: { + message: TEST_AGENT_ERROR, + occurred_at: TEST_STARTED_AT, + }, + context_window: { size: 256000 }, + }, + }); + expect( + queryClient.getQueryData<{ sessions: TaskSession[] }>(qk.taskSession.byTask(TEST_TASK_ID)), + ).toMatchObject({ + sessions: [ + { + metadata: { + last_agent_error: { + message: TEST_AGENT_ERROR, + occurred_at: TEST_STARTED_AT, + }, + context_window: { size: 256000 }, + }, + }, + ], + }); + + cleanup(); + }); +}); + +describe("session query bridge task-plan events", () => { + it("upserts task plans into the query cache", () => { + const { ws, queryClient, cleanup } = setupBridge(); + + ws.emit({ + type: "notification", + action: "task.plan.created", + payload: makeTaskPlan({ content: "# Created" }) as unknown as Record, + }); + ws.emit({ + type: "notification", + action: "task.plan.updated", + payload: makeTaskPlan({ content: "# Updated" }) as unknown as Record, + }); + + expect(queryClient.getQueryData(qk.taskPlan.detail(TEST_TASK_ID))).toMatchObject({ + id: TEST_PLAN_ID, + task_id: TEST_TASK_ID, + content: "# Updated", + }); + + cleanup(); + }); + + it("preserves implementation markers from task plan updates", () => { + const { ws, queryClient, cleanup } = setupBridge(); + + ws.emit({ + type: "notification", + action: "task.plan.updated", + payload: makeTaskPlan({ + implementation_started_at: TEST_UPDATED_AT, + implementation_started_session_id: TEST_SESSION_ID, + implementation_started_by: "user", + }) as unknown as Record, + }); + + expect(queryClient.getQueryData(qk.taskPlan.detail(TEST_TASK_ID))).toMatchObject({ + implementation_started_at: TEST_UPDATED_AT, + implementation_started_session_id: TEST_SESSION_ID, + implementation_started_by: "user", + }); + + cleanup(); + }); + + it("stores deleted task plans as null", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData(qk.taskPlan.detail(TEST_TASK_ID), makeTaskPlan()); + + ws.emit({ + type: "notification", + action: "task.plan.deleted", + payload: { task_id: TEST_TASK_ID }, + }); + + expect(queryClient.getQueryData(qk.taskPlan.detail(TEST_TASK_ID))).toBeNull(); + + cleanup(); + }); +}); diff --git a/apps/web/lib/query/bridge/session.ts b/apps/web/lib/query/bridge/session.ts new file mode 100644 index 0000000000..8afc33d7f2 --- /dev/null +++ b/apps/web/lib/query/bridge/session.ts @@ -0,0 +1,539 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { QueueStatus, QueuedMessage } from "@/lib/state/slices/session/types"; +import type { BackendMessageMap } from "@/lib/types/backend"; +import { + sessionId as toSessionId, + taskId as toTaskId, + type Message, + type MessageType, + type Task, + type TaskPlan, + type TaskPlanRevision, + type TaskWalkthrough, + type Turn, + type WorkflowSnapshot, +} from "@/lib/types/http"; +import { qk } from "../keys"; +import type { SessionMessagesLatestData } from "../query-options/session"; +import { updateWorkflowSnapshotQueries } from "../workflow-snapshot-cache"; +import { registerBridgeHandlers, type QueryBridgeRegistration } from "./registrar"; + +type MessageEvent = + | BackendMessageMap["session.message.added"] + | BackendMessageMap["session.message.updated"]; +type MessageDeletedEvent = BackendMessageMap["session.message.deleted"]; +type TurnEvent = + | BackendMessageMap["session.turn.started"] + | BackendMessageMap["session.turn.completed"]; +type SessionStateEvent = BackendMessageMap["session.state_changed"]; +type QueueEvent = BackendMessageMap["message.queue.status_changed"]; +type PlanEvent = BackendMessageMap["task.plan.created"] | BackendMessageMap["task.plan.updated"]; +type PlanRevisionEvent = + | BackendMessageMap["task.plan.revision.created"] + | BackendMessageMap["task.plan.reverted"]; +type WalkthroughEvent = + | BackendMessageMap["task.walkthrough.created"] + | BackendMessageMap["task.walkthrough.updated"]; + +export function registerSessionBridge( + ws: Parameters[0], + queryClient: QueryClient, +): QueryBridgeRegistration { + return registerBridgeHandlers(ws, queryClient, { + "session.message.added": (message) => { + const row = messageFromPayload(message.payload); + if (row) upsertSessionMessageCaches(queryClient, row); + }, + "session.message.updated": (message) => { + const row = messageFromPayload(message.payload); + if (row) upsertSessionMessageCaches(queryClient, row); + }, + "session.message.deleted": (message) => { + removeMessageCaches(queryClient, message); + }, + "session.turn.started": (message) => { + const row = turnFromPayload(message.payload); + if (row) upsertTurn(queryClient, row, "started"); + }, + "session.turn.completed": (message) => { + const row = turnFromPayload(message.payload); + if (row) upsertTurn(queryClient, row, "completed"); + if (message.payload.session_id) { + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.session.messages(message.payload.session_id), + }); + } + }, + "session.state_changed": (message) => { + patchTaskSession(queryClient, message); + patchPrimaryTaskSessionState(queryClient, message); + }, + "message.queue.status_changed": (message) => { + patchQueueStatus(queryClient, message); + }, + "task.plan.created": (message) => { + upsertTaskPlan(queryClient, message); + }, + "task.plan.updated": (message) => { + upsertTaskPlan(queryClient, message); + }, + "task.plan.deleted": (message) => { + deleteTaskPlan(queryClient, message); + }, + "task.plan.revision.created": (message) => { + upsertTaskPlanRevision(queryClient, message); + }, + "task.plan.reverted": (message) => { + upsertTaskPlanRevision(queryClient, message); + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.taskPlan.detail(message.payload.task_id), + }); + }, + "task.walkthrough.created": (message) => { + upsertTaskWalkthrough(queryClient, message); + }, + "task.walkthrough.updated": (message) => { + upsertTaskWalkthrough(queryClient, message); + }, + "task.walkthrough.deleted": (message) => { + queryClient.setQueryData(qk.taskWalkthrough.detail(message.payload.task_id), null); + }, + }); +} + +function removeMessageCaches(queryClient: QueryClient, message: MessageDeletedEvent): void { + const payload = message.payload; + if (!payload.session_id || !payload.message_id) return; + const sid = payload.session_id; + const messageId = payload.message_id; + queryClient.setQueryData(qk.session.messages(sid), (current: unknown) => + removeMessageFromLatest(current, messageId), + ); + queryClient.setQueriesData({ queryKey: ["session", sid, "messagesPage"] }, (current: unknown) => + removeMessageFromPage(current, messageId), + ); + queryClient.setQueriesData( + { queryKey: ["session", sid, "messagesInfinite"] }, + (current: unknown) => removeMessageFromPages(current, messageId), + ); +} + +function removeMessageFromLatest(current: unknown, messageId: string): unknown { + if (!isRecord(current) || !Array.isArray(current.messages)) return current; + return { ...current, messages: removeMessage(current.messages, messageId) }; +} + +function removeMessageFromPage(current: unknown, messageId: string): unknown { + if (!isRecord(current) || !Array.isArray(current.messages)) return current; + return { ...current, messages: removeMessage(current.messages, messageId) }; +} + +function removeMessageFromPages(current: unknown, messageId: string): unknown { + if (!isRecord(current) || !Array.isArray(current.pages)) return current; + return { + ...current, + pages: current.pages.map((page) => + isRecord(page) && Array.isArray(page.messages) + ? { ...page, messages: removeMessage(page.messages, messageId) } + : page, + ), + }; +} + +function removeMessage(messages: unknown[], messageId: string): Message[] { + return messages.filter( + (message) => !(isRecord(message) && message.id === messageId), + ) as Message[]; +} + +function messageFromPayload(payload: MessageEvent["payload"]): Message | null { + if (!payload.session_id || !payload.message_id) return null; + return { + id: payload.message_id, + session_id: toSessionId(payload.session_id), + task_id: toTaskId(payload.task_id), + turn_id: payload.turn_id, + author_type: payload.author_type, + author_id: payload.author_id, + content: payload.content, + raw_content: payload.raw_content, + type: (payload.type as MessageType | undefined) ?? "message", + metadata: payload.metadata, + requests_input: payload.requests_input, + created_at: payload.created_at, + updated_at: payload.updated_at, + }; +} + +function turnFromPayload(payload: TurnEvent["payload"]): Turn | null { + if (!payload.session_id || !payload.id) return null; + return { + id: payload.id, + session_id: toSessionId(payload.session_id), + task_id: toTaskId(payload.task_id), + started_at: payload.started_at, + completed_at: payload.completed_at, + metadata: payload.metadata, + created_at: payload.created_at, + updated_at: payload.updated_at, + }; +} + +export function upsertSessionMessageCaches(queryClient: QueryClient, row: Message): void { + const sid = row.session_id; + const latestMessagesKey = qk.session.messages(sid); + if (queryClient.getQueryData(latestMessagesKey) === undefined) { + queryClient.invalidateQueries({ exact: true, queryKey: latestMessagesKey }); + } else { + queryClient.setQueryData(latestMessagesKey, (current: unknown) => + patchLatestMessages(current, row), + ); + } + queryClient.setQueriesData({ queryKey: ["session", sid, "messagesPage"] }, (current: unknown) => + patchMessagePage(current, row), + ); + queryClient.setQueriesData( + { queryKey: ["session", sid, "messagesInfinite"] }, + (current: unknown) => patchMessagePages(current, row), + ); +} + +function patchLatestMessages(current: unknown, row: Message): SessionMessagesLatestData { + const currentRecord = isRecord(current) ? current : {}; + const messages = Array.isArray(currentRecord.messages) ? currentRecord.messages : []; + const next = upsertMessage(messages, row); + return { + messages: next, + hasMore: Boolean(currentRecord.hasMore), + oldestCursor: + typeof currentRecord.oldestCursor === "string" + ? currentRecord.oldestCursor + : (next[0]?.id ?? null), + }; +} + +function patchMessagePage(current: unknown, row: Message): unknown { + if (!isRecord(current) || !Array.isArray(current.messages)) return current; + return { ...current, messages: upsertMessage(current.messages, row) }; +} + +function patchMessagePages(current: unknown, row: Message): unknown { + if (!isRecord(current) || !Array.isArray(current.pages)) return current; + return { + ...current, + pages: current.pages.map((page) => + isRecord(page) && Array.isArray(page.messages) + ? { ...page, messages: upsertMessage(page.messages, row) } + : page, + ), + }; +} + +function upsertMessage(messages: unknown[], row: Message): Message[] { + const next = messages.map((message) => + isRecord(message) && message.id === row.id + ? ({ ...message, ...definedFields(row) } as Message) + : (message as Message), + ); + if (!next.some((message) => message.id === row.id)) next.push(row); + return next.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); +} + +function upsertTurn(queryClient: QueryClient, row: Turn, phase: "started" | "completed"): void { + const queryKey = qk.session.turns(row.session_id); + let patchedExistingList = false; + queryClient.setQueryData(queryKey, (current: unknown) => { + if (!isRecord(current) || !Array.isArray(current.turns)) return current; + patchedExistingList = true; + const currentRecord = current; + const turns = currentRecord.turns as unknown[]; + const next = turns.map((turn) => + isRecord(turn) && turn.id === row.id ? { ...turn, ...definedFields(row) } : turn, + ); + if (!next.some((turn) => isRecord(turn) && turn.id === row.id)) next.push(row); + const currentActiveTurnId = + typeof currentRecord.activeTurnId === "string" ? currentRecord.activeTurnId : null; + return { + ...currentRecord, + turns: next.sort( + (a, b) => + new Date(String((a as Turn).started_at)).getTime() - + new Date(String((b as Turn).started_at)).getTime(), + ), + total: typeof currentRecord.total === "number" ? currentRecord.total : next.length, + activeTurnId: nextActiveTurnId(phase, row.id, currentActiveTurnId), + }; + }); + if (!patchedExistingList) { + queryClient.invalidateQueries({ exact: true, queryKey }); + } +} + +function nextActiveTurnId( + phase: string | undefined, + rowId: string, + currentActiveTurnId: string | null, +): string | null { + if (phase === "started") return rowId; + if (currentActiveTurnId === rowId) return null; + return currentActiveTurnId; +} + +function patchTaskSession(queryClient: QueryClient, message: SessionStateEvent): void { + const payload = message.payload; + if (!payload.session_id) return; + const byIdKey = qk.taskSession.byId(payload.session_id); + const existing = queryClient.getQueryData(byIdKey); + if (isStaleSessionStateEvent(existing, payload.updated_at)) return; + queryClient.setQueryData(byIdKey, (current: unknown) => { + const existing = isRecord(current) ? current : {}; + return applySessionStatePayload( + { + ...existing, + id: payload.session_id, + task_id: payload.task_id ?? existing.task_id, + }, + payload, + ); + }); + if (!hasFullSessionDetail(existing)) { + queryClient.invalidateQueries({ exact: true, queryKey: byIdKey }); + } + if (payload.task_id) { + patchTaskSessionList(queryClient, payload.task_id, payload.session_id, payload); + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.taskSession.byTask(payload.task_id), + }); + } +} + +function hasFullSessionDetail(session: unknown): boolean { + return isRecord(session) && typeof session.started_at === "string"; +} + +function patchPrimaryTaskSessionState(queryClient: QueryClient, message: SessionStateEvent): void { + const payload = message.payload; + if (!payload.task_id || !payload.session_id || !payload.new_state) return; + const existing = queryClient.getQueryData(qk.taskSession.byId(payload.session_id)); + if (isStaleSessionStateEvent(existing, payload.updated_at)) return; + queryClient.setQueryData(qk.tasks.detail(payload.task_id), (current: unknown) => + patchPrimaryTask(current, payload), + ); + updateWorkflowSnapshotQueries(queryClient, (snapshot) => + patchSnapshotPrimaryTask(snapshot, payload), + ); +} + +function patchSnapshotPrimaryTask( + snapshot: WorkflowSnapshot, + payload: SessionStateEvent["payload"], +): WorkflowSnapshot { + let changed = false; + const tasks = snapshot.tasks.map((task) => { + const nextTask = patchPrimaryTask(task, payload) as Task; + if (nextTask !== task) changed = true; + return nextTask; + }); + return changed ? { ...snapshot, tasks } : snapshot; +} + +function patchPrimaryTask(current: unknown, payload: SessionStateEvent["payload"]): unknown { + if (!isRecord(current) || current.id !== payload.task_id) return current; + if (current.primary_session_id !== payload.session_id) return current; + if (current.primary_session_state === payload.new_state) return current; + return { + ...current, + primary_session_state: payload.new_state as Task["primary_session_state"], + updated_at: payload.updated_at ?? current.updated_at, + }; +} + +function isStaleSessionStateEvent( + existing: unknown, + payloadUpdatedAt: string | undefined, +): boolean { + if (!payloadUpdatedAt || !isRecord(existing) || typeof existing.updated_at !== "string") { + return false; + } + const payloadTime = Date.parse(payloadUpdatedAt); + const existingTime = Date.parse(existing.updated_at); + if (Number.isNaN(payloadTime) || Number.isNaN(existingTime)) return false; + return payloadTime < existingTime; +} + +function patchTaskSessionList( + queryClient: QueryClient, + taskId: string, + sessionId: string, + payload: SessionStateEvent["payload"], +): void { + queryClient.setQueryData(qk.taskSession.byTask(taskId), (current: unknown) => { + if (!isRecord(current) || !Array.isArray(current.sessions)) return current; + let found = false; + const sessions = current.sessions.map((session) => { + if (isRecord(session) && session.id === sessionId) { + found = true; + return applySessionStatePayload(session, payload); + } + return session; + }); + if (!found) sessions.push(sessionRowFromStatePayload(queryClient, taskId, sessionId, payload)); + return { ...current, sessions }; + }); +} + +function sessionRowFromStatePayload( + queryClient: QueryClient, + taskId: string, + sessionId: string, + payload: SessionStateEvent["payload"], +): Record { + const byId = queryClient.getQueryData(qk.taskSession.byId(sessionId)); + const existing = isRecord(byId) ? byId : {}; + const timestamp = + payload.updated_at ?? + (typeof existing.updated_at === "string" ? existing.updated_at : new Date(0).toISOString()); + return applySessionStatePayload( + { + ...existing, + id: sessionId, + task_id: taskId, + state: payload.new_state ?? existing.state ?? "STARTING", + started_at: typeof existing.started_at === "string" ? existing.started_at : timestamp, + updated_at: typeof existing.updated_at === "string" ? existing.updated_at : timestamp, + }, + payload, + ); +} + +function applySessionStatePayload( + existing: Record, + payload: SessionStateEvent["payload"], +): Record { + return { + ...existing, + state: payload.new_state ?? existing.state, + updated_at: payload.updated_at ?? existing.updated_at, + error_message: payload.error_message ?? existing.error_message, + metadata: mergeSessionMetadata(existing.metadata, payload), + agent_profile_id: payload.agent_profile_id ?? existing.agent_profile_id, + agent_profile_snapshot: payload.agent_profile_snapshot ?? existing.agent_profile_snapshot, + is_passthrough: payload.is_passthrough ?? existing.is_passthrough, + review_status: payload.review_status ?? existing.review_status, + task_environment_id: payload.task_environment_id ?? existing.task_environment_id, + }; +} + +function mergeSessionMetadata( + existingMetadata: unknown, + payload: SessionStateEvent["payload"], +): unknown { + const incoming = payload.session_metadata ?? payload.metadata; + if (incoming === undefined) return existingMetadata; + if (!isRecord(incoming)) return incoming; + const existing = isRecord(existingMetadata) ? existingMetadata : {}; + return { ...existing, ...incoming }; +} + +function patchQueueStatus(queryClient: QueryClient, message: QueueEvent): void { + const payload = message.payload; + if (!payload.session_id) return; + const entries = Array.isArray(payload.entries) ? (payload.entries as QueuedMessage[]) : []; + const status: QueueStatus = { + entries, + count: typeof payload.count === "number" ? payload.count : entries.length, + max: typeof payload.max === "number" ? payload.max : 0, + }; + queryClient.setQueryData(qk.session.queue(payload.session_id), status); +} + +function upsertTaskPlan(queryClient: QueryClient, message: PlanEvent): void { + const payload = message.payload; + const plan: TaskPlan = { + id: payload.id, + task_id: payload.task_id, + title: payload.title, + content: payload.content, + created_by: payload.created_by, + created_at: payload.created_at, + updated_at: payload.updated_at, + implementation_started_at: payload.implementation_started_at, + implementation_started_session_id: payload.implementation_started_session_id, + implementation_started_by: payload.implementation_started_by, + }; + queryClient.setQueryData(qk.taskPlan.detail(payload.task_id), plan); +} + +function deleteTaskPlan( + queryClient: QueryClient, + message: BackendMessageMap["task.plan.deleted"], +): void { + queryClient.setQueryData(qk.taskPlan.detail(message.payload.task_id), null); + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.taskPlan.revisions(message.payload.task_id), + }); +} + +function upsertTaskPlanRevision(queryClient: QueryClient, message: PlanRevisionEvent): void { + const payload = message.payload; + const revisionsKey = qk.taskPlan.revisions(payload.task_id); + const revision: TaskPlanRevision = { + id: payload.id, + task_id: payload.task_id, + revision_number: payload.revision_number, + title: payload.title, + author_kind: payload.author_kind, + author_name: payload.author_name, + revert_of_revision_id: payload.revert_of_revision_id ?? null, + created_at: payload.created_at, + updated_at: payload.updated_at, + }; + queryClient.setQueryData(revisionsKey, (current: unknown) => { + if (!Array.isArray(current)) return current; + const next = current.map((item) => + isRecord(item) && item.id === revision.id ? { ...item, ...revision } : item, + ); + if (!next.some((item) => isRecord(item) && item.id === revision.id)) next.unshift(revision); + return next.sort( + (a, b) => + Number((b as TaskPlanRevision).revision_number) - + Number((a as TaskPlanRevision).revision_number), + ); + }); + queryClient.invalidateQueries({ + exact: true, + queryKey: revisionsKey, + }); + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.taskPlan.revision(payload.task_id, payload.id), + }); +} + +function upsertTaskWalkthrough(queryClient: QueryClient, message: WalkthroughEvent): void { + const payload = message.payload; + const walkthrough: TaskWalkthrough = { + id: payload.id, + task_id: payload.task_id, + title: payload.title, + steps: payload.steps, + created_by: payload.created_by, + created_at: payload.created_at, + updated_at: payload.updated_at, + }; + queryClient.setQueryData(qk.taskWalkthrough.detail(payload.task_id), walkthrough); +} + +function definedFields>(value: T): Partial { + return Object.fromEntries( + Object.entries(value).filter(([, field]) => field !== undefined), + ) as Partial; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/web/lib/query/bridge/settings-system.test.ts b/apps/web/lib/query/bridge/settings-system.test.ts new file mode 100644 index 0000000000..730548b118 --- /dev/null +++ b/apps/web/lib/query/bridge/settings-system.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; +import type { BackendMessage } from "@/lib/types/backend-message"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { makeQueryClient } from "../client"; +import { qk } from "../keys"; +import { registerSettingsSystemBridge } from "./settings-system"; + +type AnyBackendMessage = BackendMessage>; +type Handler = (message: AnyBackendMessage) => void; + +class FakeWebSocketClient { + private handlers = new Map>(); + + on(type: T, handler: (message: BackendMessageMap[T]) => void) { + const bucket = this.handlers.get(type) ?? new Set(); + bucket.add(handler as Handler); + this.handlers.set(type, bucket); + return () => { + bucket.delete(handler as Handler); + }; + } + + emit(message: AnyBackendMessage) { + this.handlers.get(message.action)?.forEach((handler) => handler(message)); + } +} + +const CREATED_AT = "2026-06-23T00:00:00Z"; + +describe("settings/system query bridge", () => { + it("patches existing secrets cache without materializing a missing list", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + const registration = registerSettingsSystemBridge( + ws as unknown as WebSocketClient, + queryClient, + ); + + ws.emit({ + type: "notification", + action: "secrets.created", + payload: { + id: "secret-1", + name: "GITHUB_TOKEN", + has_value: true, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + }); + + expect(queryClient.getQueryData(qk.settings.secrets())).toBeUndefined(); + + queryClient.setQueryData(qk.settings.secrets(), [ + { + id: "secret-1", + name: "OLD_TOKEN", + has_value: true, + created_at: CREATED_AT, + updated_at: CREATED_AT, + }, + ]); + ws.emit({ + type: "notification", + action: "secrets.updated", + payload: { + id: "secret-1", + name: "GITHUB_TOKEN", + has_value: true, + created_at: CREATED_AT, + updated_at: "2026-06-23T00:01:00Z", + }, + }); + + expect(queryClient.getQueryData(qk.settings.secrets())).toEqual([ + expect.objectContaining({ id: "secret-1", name: "GITHUB_TOKEN" }), + ]); + + registration.cleanup(); + }); + + it("does not materialize an empty secrets cache from delete events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + const registration = registerSettingsSystemBridge( + ws as unknown as WebSocketClient, + queryClient, + ); + + ws.emit({ + type: "notification", + action: "secrets.deleted", + payload: { id: "secret-1" }, + }); + + expect(queryClient.getQueryData(qk.settings.secrets())).toBeUndefined(); + + registration.cleanup(); + }); +}); diff --git a/apps/web/lib/query/bridge/settings-system.ts b/apps/web/lib/query/bridge/settings-system.ts new file mode 100644 index 0000000000..d8c6137fde --- /dev/null +++ b/apps/web/lib/query/bridge/settings-system.ts @@ -0,0 +1,212 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { + AgentInstallJobPayload, + BackendMessageMap, + ExecutorProfilePayload, +} from "@/lib/types/backend"; +import type { InstallJob } from "@/lib/api/domains/settings-api"; +import type { + GitHubRateLimitInfo, + GitHubRateLimitUpdate, + TaskCIAutomationOptions, + TaskPR, +} from "@/lib/types/github"; +import type { ListAvailableAgentsResponse } from "@/lib/types/http"; +import type { SecretListItem } from "@/lib/types/http-secrets"; +import type { SystemJob } from "@/lib/types/system"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { qk } from "../keys"; +import { registerBridgeHandlers, type QueryBridgeRegistration } from "./registrar"; + +export function registerSettingsSystemBridge( + ws: WebSocketClient, + queryClient: QueryClient, +): QueryBridgeRegistration { + return registerBridgeHandlers(ws, queryClient, { + "agent.available.updated": (message) => { + patchAvailableAgents(queryClient, message.payload); + }, + "agent.install.started": (message) => patchInstallJob(queryClient, message.payload), + "agent.install.output": (message) => patchInstallOutput(queryClient, message.payload), + "agent.install.finished": (message) => patchInstallJob(queryClient, message.payload), + "agent.updated": () => invalidateAgentSettings(queryClient), + "agent.profile.created": () => invalidateAgentSettings(queryClient), + "agent.profile.updated": () => invalidateAgentSettings(queryClient), + "agent.profile.deleted": () => invalidateAgentSettings(queryClient), + "executor.created": () => invalidateExecutorSettings(queryClient), + "executor.updated": () => invalidateExecutorSettings(queryClient), + "executor.deleted": () => invalidateExecutorSettings(queryClient), + "executor.profile.created": (message) => patchExecutorProfile(queryClient, message.payload), + "executor.profile.updated": (message) => patchExecutorProfile(queryClient, message.payload), + "executor.profile.deleted": (message) => { + removeExecutorProfile(queryClient, message.payload.id); + invalidateExecutorSettings(queryClient); + }, + "environment.created": () => invalidateExecutorSettings(queryClient), + "environment.updated": () => invalidateExecutorSettings(queryClient), + "environment.deleted": () => invalidateExecutorSettings(queryClient), + "github.task_pr.updated": (message) => patchTaskPr(queryClient, message.payload), + "github.task_ci_options.updated": (message) => patchTaskCiOptions(queryClient, message.payload), + "github.rate_limit.updated": (message) => { + queryClient.setQueryData(qk.integrations.github.rateLimit(), message.payload); + patchGitHubStatusRateLimit(queryClient, message.payload); + }, + "secrets.created": (message) => upsertSecret(queryClient, message.payload), + "secrets.updated": (message) => upsertSecret(queryClient, message.payload), + "secrets.deleted": (message) => removeSecret(queryClient, message.payload.id), + "system.job.update": (message) => patchSystemJob(queryClient, message.payload), + "system.metrics.updated": (message) => { + queryClient.setQueryData(qk.system.metrics(), message.payload); + }, + "user.settings.updated": () => { + queryClient.invalidateQueries({ queryKey: qk.settings.user() }); + }, + }); +} + +function patchAvailableAgents( + queryClient: QueryClient, + payload: BackendMessageMap["agent.available.updated"]["payload"], +) { + const queryKey = qk.settings.availableAgents(); + const existing = queryClient.getQueryData(queryKey); + if (existing === undefined && payload.tools === undefined) { + queryClient.invalidateQueries({ exact: true, queryKey }); + return; + } + queryClient.setQueryData(queryKey, (prev) => { + const agents = payload.agents ?? prev?.agents ?? []; + return { + agents, + tools: payload.tools ?? prev?.tools ?? [], + total: agents.length, + }; + }); +} + +function invalidateAgentSettings(queryClient: QueryClient) { + queryClient.invalidateQueries({ queryKey: qk.settings.agents() }); + queryClient.invalidateQueries({ queryKey: qk.settings.agentDiscovery() }); + queryClient.invalidateQueries({ queryKey: qk.settings.availableAgents() }); +} + +function invalidateExecutorSettings(queryClient: QueryClient) { + queryClient.invalidateQueries({ queryKey: qk.settings.executors() }); + queryClient.invalidateQueries({ queryKey: qk.settings.allExecutorProfiles() }); +} + +function patchInstallJob(queryClient: QueryClient, job: AgentInstallJobPayload) { + queryClient.setQueryData(qk.settings.installJob(job.job_id), job); + queryClient.setQueryData<{ jobs: InstallJob[] }>(qk.settings.installJobs(), (prev) => { + if (!prev || !Array.isArray(prev.jobs)) return prev; + return { + ...prev, + jobs: upsertBy(prev.jobs, job as InstallJob, (item) => item.job_id), + }; + }); + queryClient.invalidateQueries({ exact: true, queryKey: qk.settings.installJobs() }); +} + +function patchInstallOutput( + queryClient: QueryClient, + payload: BackendMessageMap["agent.install.output"]["payload"], +) { + const patch = (job: InstallJob | undefined): InstallJob | undefined => + job ? { ...job, output: `${job.output ?? ""}${payload.chunk}` } : job; + queryClient.setQueryData(qk.settings.installJob(payload.job_id), patch); + queryClient.setQueryData<{ jobs: InstallJob[] }>(qk.settings.installJobs(), (prev) => { + if (!prev || !Array.isArray(prev.jobs)) return prev; + return { + ...prev, + jobs: prev.jobs.map((job) => (job.job_id === payload.job_id ? patch(job)! : job)), + }; + }); + queryClient.invalidateQueries({ exact: true, queryKey: qk.settings.installJobs() }); +} + +function patchExecutorProfile(queryClient: QueryClient, payload: ExecutorProfilePayload) { + queryClient.invalidateQueries({ queryKey: qk.settings.allExecutorProfiles() }); + queryClient.invalidateQueries({ queryKey: qk.settings.executors() }); + queryClient.invalidateQueries({ queryKey: qk.settings.executorProfiles(payload.executor_id) }); +} + +function removeExecutorProfile(queryClient: QueryClient, profileId: string) { + queryClient.setQueriesData<{ profiles: Array<{ id: string }> }>( + { queryKey: ["settings", "executors"] }, + (prev) => + prev?.profiles + ? { + ...prev, + profiles: prev.profiles.filter((profile) => profile.id !== profileId), + } + : prev, + ); +} + +function patchTaskPr(queryClient: QueryClient, pr: TaskPR) { + if (!pr.task_id) return; + queryClient.setQueryData(qk.integrations.github.taskPr(pr.task_id), (prev) => + upsertBy(prev ?? [], pr, (item) => `${item.repository_id ?? ""}:${item.pr_number}`), + ); + queryClient.invalidateQueries({ queryKey: ["integrations", "github", "prs"] }); +} + +function patchTaskCiOptions(queryClient: QueryClient, options: TaskCIAutomationOptions) { + if (!options.task_id) return; + queryClient.setQueryData(qk.integrations.github.taskCiOptions(options.task_id), options); +} + +function upsertSecret(queryClient: QueryClient, item: SecretListItem) { + const queryKey = qk.settings.secrets(); + const existing = queryClient.getQueryData(queryKey); + if (existing === undefined) { + queryClient.invalidateQueries({ exact: true, queryKey }); + return; + } + queryClient.setQueryData(queryKey, (prev) => + prev ? upsertBy(prev, item, (secret) => secret.id) : prev, + ); +} + +function removeSecret(queryClient: QueryClient, secretId: string) { + const queryKey = qk.settings.secrets(); + const existing = queryClient.getQueryData(queryKey); + if (existing === undefined) { + queryClient.invalidateQueries({ exact: true, queryKey }); + return; + } + queryClient.setQueryData(queryKey, (prev) => + prev ? prev.filter((secret) => secret.id !== secretId) : prev, + ); +} + +function patchSystemJob(queryClient: QueryClient, job: SystemJob) { + queryClient.setQueryData(qk.system.job(job.id), job); + queryClient.setQueryData>(qk.system.jobs(), (prev) => ({ + ...(prev ?? {}), + [job.id]: job, + })); +} + +function patchGitHubStatusRateLimit(queryClient: QueryClient, update: GitHubRateLimitUpdate) { + queryClient.setQueryData(qk.integrations.github.status(), (prev: unknown) => { + if (!prev || typeof prev !== "object") return prev; + const status = prev as { rate_limit?: GitHubRateLimitInfo }; + const rateLimit: GitHubRateLimitInfo = { ...(status.rate_limit ?? {}) }; + for (const snapshot of update.snapshots ?? []) { + rateLimit[snapshot.resource] = snapshot; + } + return { ...status, rate_limit: rateLimit }; + }); +} + +function upsertBy(items: T[], next: T, keyOf: (item: T) => string): T[] { + const key = keyOf(next); + let replaced = false; + const result = items.map((item) => { + if (keyOf(item) !== key) return item; + replaced = true; + return next; + }); + return replaced ? result : [...result, next]; +} diff --git a/apps/web/lib/query/bridge/tasks.test.ts b/apps/web/lib/query/bridge/tasks.test.ts new file mode 100644 index 0000000000..2384f89cfa --- /dev/null +++ b/apps/web/lib/query/bridge/tasks.test.ts @@ -0,0 +1,468 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { describe, expect, it } from "vitest"; +import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; +import type { BackendMessage } from "@/lib/types/backend-message"; +import type { Task, WorkflowSnapshot } from "@/lib/types/http"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { taskId } from "@/lib/types/ids"; +import { makeQueryClient } from "../client"; +import { qk } from "../keys"; +import { registerTaskBridge } from "./tasks"; + +type AnyBackendMessage = BackendMessage>; +type Handler = (message: AnyBackendMessage) => void; +type TaskCreatedPayload = BackendMessageMap["task.created"]["payload"]; +type TaskUpdatedPayload = BackendMessageMap["task.updated"]["payload"]; + +const WORKSPACE_ID = "workspace-1"; +const TASK_ID = "task-1"; +const PARENT_TASK_ID = taskId("parent-1"); +const WF_ID = "wf-1"; +const STEP_ID = "step-1"; +const TASK_CREATED_ACTION = "task.created"; +const TASK_UPDATED_ACTION = "task.updated"; +const TEST_TIMESTAMP = "2026-06-24T00:00:00Z"; +const RENAMED_CHILD_TITLE = "Renamed child"; + +class FakeWebSocketClient { + private handlers = new Map>(); + + on(type: T, handler: (message: BackendMessageMap[T]) => void) { + const bucket = this.handlers.get(type) ?? new Set(); + bucket.add(handler as Handler); + this.handlers.set(type, bucket); + return () => { + bucket.delete(handler as Handler); + }; + } + + emit(message: AnyBackendMessage) { + this.handlers.get(message.action)?.forEach((handler) => handler(message)); + } +} + +function makeTask(id: string, workflowId: string, stepId: string, title = "Task"): Task { + return { + id, + workspace_id: WORKSPACE_ID, + workflow_id: workflowId, + workflow_step_id: stepId, + position: 0, + title, + description: "", + state: "TODO", + priority: 0, + repositories: [], + created_at: TEST_TIMESTAMP, + updated_at: TEST_TIMESTAMP, + } as unknown as Task; +} + +function makeTaskRepository(repositoryId: string): NonNullable[number] { + return { + id: `task-repo-${repositoryId}`, + task_id: TASK_ID, + repository_id: repositoryId, + base_branch: "main", + checkout_branch: "feature/rename", + position: 0, + created_at: TEST_TIMESTAMP, + updated_at: TEST_TIMESTAMP, + } as NonNullable[number]; +} + +function makeSnapshot(workflowId: string, stepId: string, tasks: Task[]): WorkflowSnapshot { + return { + workflow: { + id: workflowId, + workspace_id: WORKSPACE_ID, + name: workflowId, + sort_order: 0, + hidden: false, + }, + steps: [ + { + id: stepId, + workflow_id: workflowId, + name: "Todo", + position: 0, + color: "bg-blue-500", + allow_manual_move: true, + }, + ], + tasks, + } as WorkflowSnapshot; +} + +function setupBridge() { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + const registration = registerTaskBridge(ws as unknown as WebSocketClient, queryClient); + return { ws, queryClient, cleanup: registration.cleanup }; +} + +function taskUpdatedPayload(overrides: Partial = {}): TaskUpdatedPayload { + return { + task_id: TASK_ID, + workflow_id: WF_ID, + workflow_step_id: STEP_ID, + title: "Updated task", + description: "", + state: "TODO", + is_ephemeral: false, + ...overrides, + }; +} + +function emitTaskUpdated(ws: FakeWebSocketClient, overrides: Partial = {}) { + ws.emit({ + type: "notification", + action: TASK_UPDATED_ACTION, + payload: taskUpdatedPayload(overrides), + }); +} + +function cacheTaskDetailWithRepository(queryClient: QueryClient, repositoryId = "repo-a") { + const repo = makeTaskRepository(repositoryId); + queryClient.setQueryData(qk.tasks.detail(TASK_ID), { + ...makeTask(TASK_ID, WF_ID, STEP_ID, "Old title"), + repositories: [repo], + }); + return repo; +} + +function cacheWorkflowSnapshotWithRepository(queryClient: QueryClient, repositoryId = "repo-a") { + const repo = makeTaskRepository(repositoryId); + const existingTask = { + ...makeTask(TASK_ID, WF_ID, STEP_ID, "Old title"), + repositories: [repo], + }; + queryClient.setQueryData( + qk.workflows.snapshot(WF_ID), + makeSnapshot(WF_ID, STEP_ID, [existingTask]), + ); + return repo; +} + +describe("task query bridge task detail core", () => { + it("upserts and invalidates task detail when an update arrives before detail is cached", () => { + const { ws, queryClient, cleanup } = setupBridge(); + + emitTaskUpdated(ws, { + title: "Renamed sender", + description: "updated", + state: "IN_PROGRESS", + position: 2, + }); + + expect(queryClient.getQueryData(qk.tasks.detail(TASK_ID))).toMatchObject({ + id: TASK_ID, + task_id: TASK_ID, + workflow_id: WF_ID, + workflow_step_id: STEP_ID, + title: "Renamed sender", + description: "updated", + state: "IN_PROGRESS", + position: 2, + }); + expect(queryClient.getQueryState(qk.tasks.detail(TASK_ID))?.isInvalidated).toBe(true); + + cleanup(); + }); +}); + +describe("task query bridge task detail repository metadata", () => { + it("preserves cached metadata when a rename update omits repository fields", () => { + const { ws, queryClient, cleanup } = setupBridge(); + const repo = cacheTaskDetailWithRepository(queryClient); + + emitTaskUpdated(ws, { title: "Renamed detail" }); + + expect(queryClient.getQueryData(qk.tasks.detail(TASK_ID))).toMatchObject({ + title: "Renamed detail", + repositories: [repo], + }); + + cleanup(); + }); + + it("does not preserve stale cached repositories when the primary repository changes", () => { + const { ws, queryClient, cleanup } = setupBridge(); + cacheTaskDetailWithRepository(queryClient); + + emitTaskUpdated(ws, { + title: "Retargeted detail", + repository_id: "repo-b", + }); + + expect(queryClient.getQueryData(qk.tasks.detail(TASK_ID))).toMatchObject({ + repositories: [expect.objectContaining({ repository_id: "repo-b" })], + }); + + cleanup(); + }); + + it("clears cached repository metadata when an update explicitly sends an empty list", () => { + const { ws, queryClient, cleanup } = setupBridge(); + cacheTaskDetailWithRepository(queryClient); + + emitTaskUpdated(ws, { + title: "Repo-less detail", + repositories: [], + }); + + expect(queryClient.getQueryData(qk.tasks.detail(TASK_ID))).toMatchObject({ + repositories: [], + }); + + cleanup(); + }); +}); + +describe("task query bridge task detail parent metadata", () => { + it("preserves parent links when an update omits parent_id", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData(qk.tasks.detail(TASK_ID), { + ...makeTask(TASK_ID, WF_ID, STEP_ID, "Old title"), + parent_id: PARENT_TASK_ID, + }); + + emitTaskUpdated(ws, { title: RENAMED_CHILD_TITLE }); + + expect(queryClient.getQueryData(qk.tasks.detail(TASK_ID))).toMatchObject({ + title: RENAMED_CHILD_TITLE, + parent_id: PARENT_TASK_ID, + }); + + cleanup(); + }); + + it("clears parent links when an update explicitly sends parent_id null", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData(qk.tasks.detail(TASK_ID), { + ...makeTask(TASK_ID, WF_ID, STEP_ID, "Old title"), + parent_id: PARENT_TASK_ID, + }); + + emitTaskUpdated(ws, { title: "Promoted task", parent_id: null }); + + expect( + queryClient.getQueryData>(qk.tasks.detail(TASK_ID))?.parent_id, + ).toBeUndefined(); + + cleanup(); + }); +}); + +describe("task query bridge workflow snapshots", () => { + it("patches a cached workflow snapshot when a task is created", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData(qk.workflows.snapshot(WF_ID), makeSnapshot(WF_ID, STEP_ID, [])); + + ws.emit({ + type: "notification", + action: TASK_CREATED_ACTION, + payload: { + task_id: "child-before-snapshot", + workflow_id: WF_ID, + workflow_step_id: STEP_ID, + title: "Child before snapshot", + state: "IN_PROGRESS", + parent_id: PARENT_TASK_ID, + is_ephemeral: false, + updated_at: TEST_TIMESTAMP, + } satisfies TaskCreatedPayload, + }); + + const snapshot = queryClient.getQueryData(qk.workflows.snapshot(WF_ID)); + expect(snapshot?.tasks).toEqual([ + expect.objectContaining({ + id: "child-before-snapshot", + title: "Child before snapshot", + state: "IN_PROGRESS", + parent_id: PARENT_TASK_ID, + }), + ]); + expect(queryClient.getQueryState(qk.workflows.snapshot(WF_ID))?.isInvalidated).toBe(true); + + cleanup(); + }); + + it("patches a cached workflow snapshot when a task is updated", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData( + qk.workflows.snapshot(WF_ID), + makeSnapshot(WF_ID, STEP_ID, [makeTask(TASK_ID, WF_ID, STEP_ID, "Old title")]), + ); + + emitTaskUpdated(ws, { + title: "New title", + description: "updated", + state: "IN_PROGRESS", + position: 4, + primary_session_id: "session-1", + }); + + const snapshot = queryClient.getQueryData(qk.workflows.snapshot(WF_ID)); + expect(snapshot?.tasks).toEqual([ + expect.objectContaining({ + id: TASK_ID, + title: "New title", + description: "updated", + state: "IN_PROGRESS", + position: 4, + primary_session_id: "session-1", + }), + ]); + + cleanup(); + }); + + it("moves cached workflow snapshot tasks when a task changes workflows", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData( + qk.workflows.snapshot("wf-old"), + makeSnapshot("wf-old", "old-step", [makeTask(TASK_ID, "wf-old", "old-step")]), + ); + queryClient.setQueryData( + qk.workflows.snapshot("wf-new"), + makeSnapshot("wf-new", "new-step", []), + ); + + emitTaskUpdated(ws, { + workflow_id: "wf-new", + old_workflow_id: "wf-old", + workflow_step_id: "new-step", + title: "Moved task", + position: 0, + }); + + const oldSnapshot = queryClient.getQueryData(qk.workflows.snapshot("wf-old")); + const newSnapshot = queryClient.getQueryData(qk.workflows.snapshot("wf-new")); + expect(oldSnapshot?.tasks).toEqual([]); + expect(newSnapshot?.tasks).toEqual([ + expect.objectContaining({ + id: TASK_ID, + workflow_id: "wf-new", + workflow_step_id: "new-step", + title: "Moved task", + }), + ]); + + cleanup(); + }); + + it("removes archived tasks from cached workflow snapshots", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData( + qk.workflows.snapshot(WF_ID), + makeSnapshot(WF_ID, STEP_ID, [makeTask(TASK_ID, WF_ID, STEP_ID, "Old title")]), + ); + + emitTaskUpdated(ws, { archived_at: "2026-06-30T12:00:00Z" }); + + const snapshot = queryClient.getQueryData(qk.workflows.snapshot(WF_ID)); + expect(snapshot?.tasks).toEqual([]); + + cleanup(); + }); +}); + +describe("task query bridge workflow snapshot parent metadata", () => { + it("preserves parent links when an update omits parent_id", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData( + qk.workflows.snapshot(WF_ID), + makeSnapshot(WF_ID, STEP_ID, [ + { ...makeTask(TASK_ID, WF_ID, STEP_ID, "Old title"), parent_id: PARENT_TASK_ID } as Task, + ]), + ); + + emitTaskUpdated(ws, { title: RENAMED_CHILD_TITLE }); + + const snapshot = queryClient.getQueryData(qk.workflows.snapshot(WF_ID)); + expect(snapshot?.tasks[0]).toEqual( + expect.objectContaining({ title: RENAMED_CHILD_TITLE, parent_id: PARENT_TASK_ID }), + ); + + cleanup(); + }); + + it("applies explicit parent_id when adding a task from an event", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData(qk.workflows.snapshot(WF_ID), makeSnapshot(WF_ID, STEP_ID, [])); + + emitTaskUpdated(ws, { title: "New child", parent_id: PARENT_TASK_ID }); + + const snapshot = queryClient.getQueryData(qk.workflows.snapshot(WF_ID)); + expect(snapshot?.tasks[0]).toEqual( + expect.objectContaining({ title: "New child", parent_id: PARENT_TASK_ID }), + ); + + cleanup(); + }); + + it("clears parent links when an update explicitly sends parent_id null", () => { + const { ws, queryClient, cleanup } = setupBridge(); + queryClient.setQueryData( + qk.workflows.snapshot(WF_ID), + makeSnapshot(WF_ID, STEP_ID, [ + { ...makeTask(TASK_ID, WF_ID, STEP_ID, "Old title"), parent_id: PARENT_TASK_ID } as Task, + ]), + ); + + emitTaskUpdated(ws, { title: "Promoted task", parent_id: null }); + + const snapshot = queryClient.getQueryData(qk.workflows.snapshot(WF_ID)); + expect(snapshot?.tasks[0]?.parent_id).toBeUndefined(); + + cleanup(); + }); +}); + +describe("task query bridge workflow snapshot repository metadata", () => { + it("preserves repository metadata when a rename update omits repository fields", () => { + const { ws, queryClient, cleanup } = setupBridge(); + const repo = cacheWorkflowSnapshotWithRepository(queryClient); + + emitTaskUpdated(ws, { title: "Renamed task" }); + + const snapshot = queryClient.getQueryData(qk.workflows.snapshot(WF_ID)); + expect(snapshot?.tasks[0]).toEqual(expect.objectContaining({ title: "Renamed task" })); + expect(snapshot?.tasks[0]?.repositories).toEqual([repo]); + + cleanup(); + }); + + it("does not preserve stale repository rows when the primary repository changes", () => { + const { ws, queryClient, cleanup } = setupBridge(); + cacheWorkflowSnapshotWithRepository(queryClient); + + emitTaskUpdated(ws, { + title: "Retargeted task", + repository_id: "repo-b", + }); + + const snapshot = queryClient.getQueryData(qk.workflows.snapshot(WF_ID)); + expect(snapshot?.tasks[0]?.repositories).toEqual([ + expect.objectContaining({ repository_id: "repo-b" }), + ]); + + cleanup(); + }); + + it("clears repository metadata when an update explicitly sends an empty repository list", () => { + const { ws, queryClient, cleanup } = setupBridge(); + cacheWorkflowSnapshotWithRepository(queryClient); + + emitTaskUpdated(ws, { + title: "Repo-less task", + repositories: [], + }); + + const snapshot = queryClient.getQueryData(qk.workflows.snapshot(WF_ID)); + expect(snapshot?.tasks[0]?.repositories).toEqual([]); + + cleanup(); + }); +}); diff --git a/apps/web/lib/query/bridge/tasks.ts b/apps/web/lib/query/bridge/tasks.ts new file mode 100644 index 0000000000..ee1bfb7c11 --- /dev/null +++ b/apps/web/lib/query/bridge/tasks.ts @@ -0,0 +1,327 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { BackendMessageMap } from "@/lib/types/backend"; +import { primaryTaskRepository, type Task, type WorkflowSnapshot } from "@/lib/types/http"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { qk } from "../keys"; +import { registerBridgeHandlers, type QueryBridgeRegistration } from "./registrar"; + +type TaskEventMessage = + | BackendMessageMap["task.created"] + | BackendMessageMap["task.deleted"] + | BackendMessageMap["task.state_changed"] + | BackendMessageMap["task.updated"]; + +type CachedTask = Record; + +export function registerTaskBridge( + ws: WebSocketClient, + queryClient: QueryClient, +): QueryBridgeRegistration { + return registerBridgeHandlers(ws, queryClient, { + "kanban.update": (message) => { + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.workflows.snapshot(message.payload.workflowId), + }); + }, + "task.created": (message) => { + if (!message.payload.is_ephemeral) { + upsertSnapshotTask(queryClient, message.payload.workflow_id, message.payload); + } + invalidateTaskPages(queryClient); + invalidateWorkflowSnapshots(queryClient, message); + }, + "task.deleted": (message) => { + queryClient.removeQueries({ + exact: true, + queryKey: qk.tasks.detail(message.payload.task_id), + }); + removeSnapshotTask(queryClient, message.payload.workflow_id, message.payload.task_id); + invalidateTaskPages(queryClient); + invalidateWorkflowSnapshots(queryClient, message); + }, + "task.state_changed": (message) => { + patchTaskDetail(queryClient, message); + if (!message.payload.is_ephemeral) { + upsertSnapshotTask(queryClient, message.payload.workflow_id, message.payload); + } + invalidateTaskPages(queryClient); + invalidateWorkflowSnapshots(queryClient, message); + }, + "task.updated": (message) => { + patchTaskDetail(queryClient, message); + patchSnapshotTaskFromEvent(queryClient, message); + invalidateTaskPages(queryClient); + invalidateWorkflowSnapshots(queryClient, message); + }, + }); +} + +function patchTaskDetail(queryClient: QueryClient, message: TaskEventMessage): void { + const payload = message.payload; + const queryKey = qk.tasks.detail(payload.task_id); + queryClient.setQueryData(queryKey, (current: unknown) => { + if (!isCachedTask(current)) return taskDetailFromPayload(payload); + return taskDetailFromPayload(payload, current); + }); + queryClient.invalidateQueries({ exact: true, queryKey }); +} + +function invalidateTaskPages(queryClient: QueryClient): void { + queryClient.invalidateQueries({ queryKey: ["tasks", "page"] }); + queryClient.invalidateQueries({ queryKey: ["tasks", "infinite"] }); +} + +function invalidateWorkflowSnapshots(queryClient: QueryClient, message: TaskEventMessage): void { + const payload = message.payload; + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.workflows.snapshot(payload.workflow_id), + }); + if (payload.old_workflow_id && payload.old_workflow_id !== payload.workflow_id) { + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.workflows.snapshot(payload.old_workflow_id), + }); + } +} + +function patchSnapshotTaskFromEvent(queryClient: QueryClient, message: TaskEventMessage): void { + const payload = message.payload; + if (payload.old_workflow_id && payload.old_workflow_id !== payload.workflow_id) { + removeSnapshotTask(queryClient, payload.old_workflow_id, payload.task_id); + } + if (payload.archived_at) { + removeSnapshotTask(queryClient, payload.workflow_id, payload.task_id); + return; + } + if (payload.is_ephemeral) return; + upsertSnapshotTask(queryClient, payload.workflow_id, payload); +} + +function upsertSnapshotTask( + queryClient: QueryClient, + workflowId: string, + payload: TaskEventMessage["payload"], +): void { + queryClient.setQueryData(qk.workflows.snapshot(workflowId), (current: unknown) => { + if (!isWorkflowSnapshot(current)) return current; + const existing = current.tasks.find((task) => task.id === payload.task_id); + const nextTask = taskFromPayload(payload, current, existing); + const tasks = existing + ? current.tasks.map((task) => (task.id === payload.task_id ? nextTask : task)) + : [...current.tasks, nextTask]; + return { ...current, tasks }; + }); +} + +function removeSnapshotTask(queryClient: QueryClient, workflowId: string, taskId: string): void { + queryClient.setQueryData(qk.workflows.snapshot(workflowId), (current: unknown) => { + if (!isWorkflowSnapshot(current)) return current; + if (!current.tasks.some((task) => task.id === taskId)) return current; + return { + ...current, + tasks: current.tasks.filter((task) => task.id !== taskId), + }; + }); +} + +function taskFromPayload( + payload: TaskEventMessage["payload"], + snapshot: WorkflowSnapshot, + existing: Task | undefined, +): Task { + return { + ...(existing ?? {}), + id: payload.task_id, + workspace_id: workspaceIdFromSnapshot(snapshot, existing), + workflow_id: payload.workflow_id, + workflow_step_id: payload.workflow_step_id, + parent_id: parentIdFromPayload(payload, existing), + position: positionFromPayload(payload, existing), + title: payload.title, + description: descriptionFromPayload(payload, existing), + state: stateFromPayload(payload, existing), + priority: priorityFromPayload(payload, existing), + repositories: repositoriesFromPayload(payload, existing?.repositories), + primary_session_id: primarySessionIdFromPayload(payload, existing), + session_count: sessionCountFromPayload(payload, existing), + review_status: reviewStatusFromPayload(payload, existing), + created_at: createdAtFromPayload(payload, existing), + updated_at: updatedAtFromPayload(payload, existing), + } as Task; +} + +function workspaceIdFromSnapshot(snapshot: WorkflowSnapshot, existing: Task | undefined) { + return existing?.workspace_id ?? snapshot.workflow.workspace_id; +} + +function positionFromPayload(payload: TaskEventMessage["payload"], existing: Task | undefined) { + return payload.position ?? existing?.position ?? 0; +} + +function parentIdFromPayload(payload: TaskEventMessage["payload"], existing: Task | undefined) { + if (Object.prototype.hasOwnProperty.call(payload, "parent_id")) { + return payload.parent_id ?? undefined; + } + return existing?.parent_id; +} + +function descriptionFromPayload(payload: TaskEventMessage["payload"], existing: Task | undefined) { + return payload.description ?? existing?.description ?? ""; +} + +function stateFromPayload(payload: TaskEventMessage["payload"], existing: Task | undefined) { + return payload.state ?? existing?.state ?? "TODO"; +} + +function priorityFromPayload(payload: TaskEventMessage["payload"], existing: Task | undefined) { + return payload.priority ?? existing?.priority ?? 0; +} + +function sessionCountFromPayload(payload: TaskEventMessage["payload"], existing: Task | undefined) { + return payload.session_count ?? existing?.session_count; +} + +function reviewStatusFromPayload(payload: TaskEventMessage["payload"], existing: Task | undefined) { + return payload.review_status ?? existing?.review_status; +} + +function createdAtFromPayload(payload: TaskEventMessage["payload"], existing: Task | undefined) { + return existing?.created_at ?? payload.updated_at ?? ""; +} + +function updatedAtFromPayload(payload: TaskEventMessage["payload"], existing: Task | undefined) { + return payload.updated_at ?? existing?.updated_at ?? ""; +} + +function repositoriesFromPayload( + payload: TaskEventMessage["payload"], + existingRepositories: Task["repositories"] | undefined, +): Task["repositories"] { + if (payload.repositories !== undefined) { + return payload.repositories as Task["repositories"]; + } + const payloadRepositoryId = payload.repository_id; + const existingRepositoryId = primaryRepositoryId(existingRepositories); + if (payloadRepositoryId && payloadRepositoryId !== existingRepositoryId) { + return [fallbackTaskRepository(payload, payloadRepositoryId)]; + } + if (existingRepositories) return existingRepositories; + if (!payloadRepositoryId) return []; + return [fallbackTaskRepository(payload, payloadRepositoryId)]; +} + +function primaryRepositoryId(repositories: Task["repositories"] | undefined): string | undefined { + return primaryTaskRepository(repositories)?.repository_id; +} + +function fallbackTaskRepository( + payload: TaskEventMessage["payload"], + repositoryId: string, +): NonNullable[number] { + return { + id: "", + task_id: payload.task_id, + repository_id: repositoryId, + base_branch: "", + position: 0, + created_at: payload.updated_at ?? "", + updated_at: payload.updated_at ?? "", + } as NonNullable[number]; +} + +function taskDetailFromPayload( + payload: TaskEventMessage["payload"], + existing: CachedTask = {}, +): CachedTask { + return { + ...existing, + ...payload, + repositories: repositoriesFromPayload(payload, cachedRepositories(existing)), + ...taskDetailCoreFields(payload, existing), + ...taskDetailSessionFields(payload, existing), + ...taskDetailTimestampFields(payload, existing), + }; +} + +function cachedRepositories(existing: CachedTask): Task["repositories"] | undefined { + return Array.isArray(existing.repositories) + ? (existing.repositories as Task["repositories"]) + : undefined; +} + +function taskDetailCoreFields( + payload: TaskEventMessage["payload"], + existing: CachedTask, +): CachedTask { + return { + id: existing.id ?? payload.task_id, + description: payload.description ?? existing.description ?? "", + state: payload.state ?? existing.state ?? "TODO", + priority: payload.priority ?? existing.priority ?? 0, + position: payload.position ?? existing.position ?? 0, + parent_id: cachedParentIdFromPayload(payload, existing), + }; +} + +function cachedParentIdFromPayload( + payload: TaskEventMessage["payload"], + existing: CachedTask, +): unknown { + if (Object.prototype.hasOwnProperty.call(payload, "parent_id")) { + return payload.parent_id ?? undefined; + } + return existing.parent_id; +} + +function taskDetailSessionFields( + payload: TaskEventMessage["payload"], + existing: CachedTask, +): CachedTask { + return { + primary_session_id: cachedPrimarySessionIdFromPayload(payload, existing), + session_count: payload.session_count ?? existing.session_count, + review_status: payload.review_status ?? existing.review_status, + archived_at: payload.archived_at ?? existing.archived_at, + }; +} + +function taskDetailTimestampFields( + payload: TaskEventMessage["payload"], + existing: CachedTask, +): CachedTask { + return { + updated_at: payload.updated_at ?? existing.updated_at ?? "", + created_at: existing.created_at ?? payload.updated_at ?? "", + }; +} + +function cachedPrimarySessionIdFromPayload( + payload: TaskEventMessage["payload"], + existing: CachedTask, +): unknown { + if (payload.primary_session_id === undefined) return existing.primary_session_id; + return payload.primary_session_id; +} + +function primarySessionIdFromPayload( + payload: TaskEventMessage["payload"], + existing: Task | undefined, +): Task["primary_session_id"] { + if (payload.primary_session_id === undefined) return existing?.primary_session_id; + return payload.primary_session_id as Task["primary_session_id"]; +} + +function isCachedTask(value: unknown): value is CachedTask { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isWorkflowSnapshot(value: unknown): value is WorkflowSnapshot { + return ( + isCachedTask(value) && + isCachedTask(value.workflow) && + Array.isArray(value.steps) && + Array.isArray(value.tasks) + ); +} diff --git a/apps/web/lib/query/bridge/workspace.test.ts b/apps/web/lib/query/bridge/workspace.test.ts new file mode 100644 index 0000000000..e84f8e0059 --- /dev/null +++ b/apps/web/lib/query/bridge/workspace.test.ts @@ -0,0 +1,252 @@ +/* eslint-disable max-lines-per-function, sonarjs/no-duplicate-string */ +import { describe, expect, it } from "vitest"; +import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; +import type { BackendMessage } from "@/lib/types/backend-message"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { makeQueryClient } from "../client"; +import { qk } from "../keys"; +import { registerWorkspaceBridge } from "./workspace"; + +type AnyBackendMessage = BackendMessage>; +type Handler = (message: AnyBackendMessage) => void; +const WORKFLOW_ID = "workflow-1"; +const WORKSPACE_ID = "workspace-1"; + +class FakeWebSocketClient { + private handlers = new Map>(); + + on(type: T, handler: (message: BackendMessageMap[T]) => void) { + const bucket = this.handlers.get(type) ?? new Set(); + bucket.add(handler as Handler); + this.handlers.set(type, bucket); + return () => { + bucket.delete(handler as Handler); + }; + } + + emit(message: AnyBackendMessage) { + this.handlers.get(message.action)?.forEach((handler) => handler(message)); + } +} + +describe("workspace query bridge", () => { + it("invalidates workflow step query data for workflow step events", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workflows.steps(WORKFLOW_ID), [ + { id: "step-1", workflow_id: WORKFLOW_ID, name: "Todo", position: 1 }, + ]); + queryClient.setQueryData(qk.workflows.snapshot(WORKFLOW_ID), { + workflow: { id: WORKFLOW_ID }, + steps: [], + tasks: [], + }); + + const registration = registerWorkspaceBridge(ws as unknown as WebSocketClient, queryClient); + ws.emit({ + type: "notification", + action: "workflow.step.updated", + payload: { + step: { + id: "step-1", + workflow_id: WORKFLOW_ID, + name: "Doing", + position: 1, + state: "active", + color: "#00f", + }, + }, + }); + + expect(queryClient.getQueryState(qk.workflows.steps(WORKFLOW_ID))?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(qk.workflows.snapshot(WORKFLOW_ID))?.isInvalidated).toBe(true); + + registration.cleanup(); + }); + + it("removes deleted workflows from cached workflow lists", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID), [ + { id: WORKFLOW_ID, workspace_id: WORKSPACE_ID, name: "Delete me" }, + { id: "workflow-2", workspace_id: WORKSPACE_ID, name: "Keep me" }, + ]); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [ + { id: WORKFLOW_ID, workspace_id: WORKSPACE_ID, name: "Delete me" }, + { id: "workflow-hidden", workspace_id: WORKSPACE_ID, name: "Hidden" }, + ]); + queryClient.setQueryData(qk.workflows.all("workspace-2"), [ + { id: "workflow-other", workspace_id: "workspace-2", name: "Other workspace" }, + ]); + + const registration = registerWorkspaceBridge(ws as unknown as WebSocketClient, queryClient); + ws.emit({ + type: "notification", + action: "workflow.deleted", + payload: { id: WORKFLOW_ID, workspace_id: WORKSPACE_ID }, + }); + + expect(queryClient.getQueryData(qk.workflows.all(WORKSPACE_ID))).toEqual([ + expect.objectContaining({ id: "workflow-2" }), + ]); + expect( + queryClient.getQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true })), + ).toEqual([expect.objectContaining({ id: "workflow-hidden" })]); + expect(queryClient.getQueryData(qk.workflows.all("workspace-2"))).toEqual([ + expect.objectContaining({ id: "workflow-other" }), + ]); + + registration.cleanup(); + }); + + it("patches cached workflow lists when workflow metadata changes", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID), [ + { id: WORKFLOW_ID, workspace_id: WORKSPACE_ID, name: "Old name", hidden: false }, + { id: "workflow-2", workspace_id: WORKSPACE_ID, name: "Keep me", hidden: false }, + ]); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [ + { id: WORKFLOW_ID, workspace_id: WORKSPACE_ID, name: "Old name", hidden: false }, + { id: "workflow-hidden", workspace_id: WORKSPACE_ID, name: "Hidden", hidden: true }, + ]); + queryClient.setQueryData(qk.workflows.all("workspace-2"), [ + { id: WORKFLOW_ID, workspace_id: "workspace-2", name: "Other workspace" }, + ]); + + const registration = registerWorkspaceBridge(ws as unknown as WebSocketClient, queryClient); + ws.emit({ + type: "notification", + action: "workflow.updated", + payload: { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Hidden now", + hidden: true, + }, + }); + + expect(queryClient.getQueryData(qk.workflows.all(WORKSPACE_ID))).toEqual([ + expect.objectContaining({ id: "workflow-2" }), + ]); + expect( + queryClient.getQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true })), + ).toEqual([ + expect.objectContaining({ id: WORKFLOW_ID, name: "Hidden now", hidden: true }), + expect.objectContaining({ id: "workflow-hidden" }), + ]); + expect(queryClient.getQueryData(qk.workflows.all("workspace-2"))).toEqual([ + expect.objectContaining({ id: WORKFLOW_ID, name: "Other workspace" }), + ]); + + registration.cleanup(); + }); + + it("adds unhidden workflow updates back to cached visible workflow lists", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID), [ + { id: "workflow-2", workspace_id: WORKSPACE_ID, name: "Keep me", hidden: false }, + ]); + queryClient.setQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }), [ + { id: WORKFLOW_ID, workspace_id: WORKSPACE_ID, name: "Hidden", hidden: true }, + ]); + + const registration = registerWorkspaceBridge(ws as unknown as WebSocketClient, queryClient); + ws.emit({ + type: "notification", + action: "workflow.updated", + payload: { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Visible now", + hidden: false, + }, + }); + + expect(queryClient.getQueryData(qk.workflows.all(WORKSPACE_ID))).toEqual([ + expect.objectContaining({ id: "workflow-2" }), + expect.objectContaining({ id: WORKFLOW_ID, name: "Visible now", hidden: false }), + ]); + expect( + queryClient.getQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true })), + ).toEqual([expect.objectContaining({ id: WORKFLOW_ID, name: "Visible now", hidden: false })]); + + registration.cleanup(); + }); + + it("patches cached repository lists before invalidating", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), [ + { id: "repo-1", workspace_id: WORKSPACE_ID, name: "Old repo" }, + { id: "repo-2", workspace_id: WORKSPACE_ID, name: "Keep me" }, + ]); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID, { includeScripts: true }), [ + { id: "repo-1", workspace_id: WORKSPACE_ID, name: "Old repo", scripts: [{ id: "script-1" }] }, + ]); + queryClient.setQueryData(qk.workspaces.repositories("workspace-2"), [ + { id: "repo-1", workspace_id: "workspace-2", name: "Other workspace" }, + ]); + + const registration = registerWorkspaceBridge(ws as unknown as WebSocketClient, queryClient); + ws.emit({ + type: "notification", + action: "repository.updated", + payload: { + id: "repo-1", + workspace_id: WORKSPACE_ID, + name: "Renamed repo", + }, + }); + + expect(queryClient.getQueryData(qk.workspaces.repositories(WORKSPACE_ID))).toEqual([ + expect.objectContaining({ id: "repo-1", name: "Renamed repo" }), + expect.objectContaining({ id: "repo-2", name: "Keep me" }), + ]); + expect( + queryClient.getQueryData( + qk.workspaces.repositories(WORKSPACE_ID, { includeScripts: true }), + ), + ).toEqual([ + expect.objectContaining({ + id: "repo-1", + name: "Renamed repo", + scripts: [{ id: "script-1" }], + }), + ]); + expect(queryClient.getQueryData(qk.workspaces.repositories("workspace-2"))).toEqual([ + expect.objectContaining({ id: "repo-1", name: "Other workspace" }), + ]); + expect(queryClient.getQueryState(qk.workspaces.repositories(WORKSPACE_ID))?.isInvalidated).toBe( + true, + ); + + registration.cleanup(); + }); + + it("removes deleted repositories from cached repository lists", () => { + const ws = new FakeWebSocketClient(); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workspaces.repositories(WORKSPACE_ID), [ + { id: "repo-1", workspace_id: WORKSPACE_ID, name: "Delete me" }, + { id: "repo-2", workspace_id: WORKSPACE_ID, name: "Keep me" }, + ]); + + const registration = registerWorkspaceBridge(ws as unknown as WebSocketClient, queryClient); + ws.emit({ + type: "notification", + action: "repository.deleted", + payload: { + id: "repo-1", + workspace_id: WORKSPACE_ID, + }, + }); + + expect(queryClient.getQueryData(qk.workspaces.repositories(WORKSPACE_ID))).toEqual([ + expect.objectContaining({ id: "repo-2" }), + ]); + + registration.cleanup(); + }); +}); diff --git a/apps/web/lib/query/bridge/workspace.ts b/apps/web/lib/query/bridge/workspace.ts new file mode 100644 index 0000000000..777212fb3a --- /dev/null +++ b/apps/web/lib/query/bridge/workspace.ts @@ -0,0 +1,288 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { BackendMessageMap } from "@/lib/types/backend"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { qk } from "../keys"; +import { registerBridgeHandlers, type QueryBridgeRegistration } from "./registrar"; + +type WorkspacePayload = BackendMessageMap["workspace.updated"]["payload"]; +type WorkflowPayload = BackendMessageMap["workflow.updated"]["payload"]; +type RepositoryPayload = BackendMessageMap["repository.updated"]["payload"]; + +export function registerWorkspaceBridge( + ws: WebSocketClient, + queryClient: QueryClient, +): QueryBridgeRegistration { + return registerBridgeHandlers(ws, queryClient, { + "workflow.created": (message) => { + patchWorkflowInCachedLists(queryClient, message.payload); + invalidateWorkflows(queryClient); + }, + "workflow.deleted": (message) => { + queryClient.removeQueries({ + exact: true, + queryKey: qk.workflows.snapshot(message.payload.id), + }); + queryClient.removeQueries({ exact: true, queryKey: qk.workflows.steps(message.payload.id) }); + removeWorkflowFromCachedLists(queryClient, message.payload.id); + invalidateWorkflows(queryClient); + }, + "workflow.step.created": (message) => { + invalidateWorkflowSnapshot(queryClient, message.payload.step.workflow_id); + invalidateWorkflowSteps(queryClient, message.payload.step.workflow_id); + invalidateWorkflows(queryClient); + }, + "workflow.step.deleted": (message) => { + invalidateWorkflowSnapshot(queryClient, message.payload.step.workflow_id); + invalidateWorkflowSteps(queryClient, message.payload.step.workflow_id); + invalidateWorkflows(queryClient); + }, + "workflow.step.updated": (message) => { + invalidateWorkflowSnapshot(queryClient, message.payload.step.workflow_id); + invalidateWorkflowSteps(queryClient, message.payload.step.workflow_id); + invalidateWorkflows(queryClient); + }, + "workflow.updated": (message) => { + invalidateWorkflowSnapshot(queryClient, message.payload.id); + patchWorkflowInCachedLists(queryClient, message.payload); + invalidateWorkflows(queryClient); + }, + "repository.created": (message) => { + patchRepositoryInCachedLists(queryClient, message.payload); + invalidateRepositoryCaches(queryClient, message.payload); + }, + "repository.updated": (message) => { + patchRepositoryInCachedLists(queryClient, message.payload); + invalidateRepositoryCaches(queryClient, message.payload); + }, + "repository.deleted": (message) => { + removeRepositoryFromCachedLists(queryClient, message.payload.id); + invalidateRepositoryCaches(queryClient, message.payload); + }, + "repository.script.created": (message) => + invalidateRepositoryScriptCaches(queryClient, message.payload), + "repository.script.updated": (message) => + invalidateRepositoryScriptCaches(queryClient, message.payload), + "repository.script.deleted": (message) => + invalidateRepositoryScriptCaches(queryClient, message.payload), + "workspace.created": () => { + queryClient.invalidateQueries({ queryKey: qk.workspaces.all() }); + }, + "workspace.deleted": (message) => { + queryClient.setQueryData(qk.workspaces.all(), (current: unknown) => { + if (!Array.isArray(current)) return current; + return current.filter((workspace) => !hasWorkspaceId(workspace, message.payload.id)); + }); + queryClient.invalidateQueries({ queryKey: qk.workspaces.all() }); + invalidateWorkspaceScopedQueries(queryClient, message.payload.id); + }, + "workspace.updated": (message) => { + patchWorkspaceList(queryClient, message.payload); + queryClient.invalidateQueries({ queryKey: qk.workspaces.all() }); + }, + }); +} + +function patchWorkspaceList(queryClient: QueryClient, payload: WorkspacePayload): void { + queryClient.setQueryData(qk.workspaces.all(), (current: unknown) => { + if (!Array.isArray(current)) return current; + return current.map((workspace) => + hasWorkspaceId(workspace, payload.id) && isRecord(workspace) + ? { ...workspace, ...payload } + : workspace, + ); + }); +} + +function invalidateWorkflows(queryClient: QueryClient): void { + queryClient.invalidateQueries({ queryKey: ["workflows"] }); +} + +function removeWorkflowFromCachedLists(queryClient: QueryClient, workflowId: string): void { + queryClient.setQueriesData( + { predicate: (query) => isWorkflowListKey(query.queryKey) }, + (current: unknown) => { + if (!Array.isArray(current)) return current; + return current.filter((workflow) => !hasWorkflowId(workflow, workflowId)); + }, + ); +} + +function patchWorkflowInCachedLists(queryClient: QueryClient, payload: WorkflowPayload): void { + const queries = queryClient.getQueryCache().findAll({ + predicate: (query) => isWorkflowListKey(query.queryKey), + }); + for (const query of queries) { + queryClient.setQueryData(query.queryKey, (current: unknown) => + patchWorkflowList(query.queryKey, current, payload), + ); + } +} + +function patchWorkflowList( + queryKey: readonly unknown[], + current: unknown, + payload: WorkflowPayload, +): unknown { + if (!Array.isArray(current) || workflowListWorkspaceId(queryKey) !== payload.workspace_id) { + return current; + } + const includeHidden = workflowListIncludesHidden(queryKey); + const isVisible = payload.hidden !== true; + const hasExisting = current.some((workflow) => hasWorkflowId(workflow, payload.id)); + if (!includeHidden && !isVisible) { + return current.filter((workflow) => !hasWorkflowId(workflow, payload.id)); + } + if (hasExisting) { + return current.map((workflow) => + hasWorkflowId(workflow, payload.id) && isRecord(workflow) + ? { ...workflow, ...payload } + : workflow, + ); + } + return includeHidden || isVisible ? [...current, payload] : current; +} + +function isWorkflowListKey(queryKey: readonly unknown[]): boolean { + return ( + queryKey.length === 3 && + queryKey[0] === "workflows" && + typeof queryKey[1] === "string" && + isRecord(queryKey[2]) && + "includeHidden" in queryKey[2] + ); +} + +function workflowListWorkspaceId(queryKey: readonly unknown[]): string | null { + return typeof queryKey[1] === "string" ? queryKey[1] : null; +} + +function workflowListIncludesHidden(queryKey: readonly unknown[]): boolean { + return isRecord(queryKey[2]) && queryKey[2].includeHidden === true; +} + +function removeRepositoryFromCachedLists(queryClient: QueryClient, repositoryId: string): void { + queryClient.setQueriesData( + { predicate: (query) => isWorkspaceRepositoryListKey(query.queryKey) }, + (current: unknown) => { + if (!Array.isArray(current)) return current; + return current.filter((repository) => !hasRepositoryId(repository, repositoryId)); + }, + ); +} + +function patchRepositoryInCachedLists(queryClient: QueryClient, payload: RepositoryPayload): void { + const queries = queryClient.getQueryCache().findAll({ + predicate: (query) => isWorkspaceRepositoryListKey(query.queryKey), + }); + for (const query of queries) { + queryClient.setQueryData(query.queryKey, (current: unknown) => + patchRepositoryList(query.queryKey, current, payload), + ); + } +} + +function patchRepositoryList( + queryKey: readonly unknown[], + current: unknown, + payload: RepositoryPayload, +): unknown { + if (!Array.isArray(current)) return current; + const workspaceId = workspaceRepositoryListWorkspaceId(queryKey); + if (payload.workspace_id && workspaceId !== payload.workspace_id) return current; + const hasExisting = current.some((repository) => hasRepositoryId(repository, payload.id)); + if (hasExisting) { + return current.map((repository) => + hasRepositoryId(repository, payload.id) && isRecord(repository) + ? { ...repository, ...payload } + : repository, + ); + } + return payload.workspace_id === workspaceId ? [...current, payload] : current; +} + +function workspaceRepositoryListWorkspaceId(queryKey: readonly unknown[]): string | null { + return typeof queryKey[1] === "string" ? queryKey[1] : null; +} + +function invalidateWorkflowSnapshot(queryClient: QueryClient, workflowId: string): void { + queryClient.invalidateQueries({ exact: true, queryKey: qk.workflows.snapshot(workflowId) }); +} + +function invalidateWorkflowSteps(queryClient: QueryClient, workflowId: string): void { + queryClient.invalidateQueries({ exact: true, queryKey: qk.workflows.steps(workflowId) }); +} + +function invalidateRepositoryCaches( + queryClient: QueryClient, + payload: BackendMessageMap["repository.updated"]["payload"], +): void { + if (typeof payload.workspace_id === "string" && payload.workspace_id) { + queryClient.invalidateQueries({ + queryKey: ["workspaces", payload.workspace_id, "repositories"], + }); + } else { + invalidateAllWorkspaceRepositoryLists(queryClient); + } + if (typeof payload.id === "string" && payload.id) { + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.workspaces.repositoryScripts(payload.id), + }); + } +} + +function invalidateRepositoryScriptCaches( + queryClient: QueryClient, + payload: BackendMessageMap["repository.script.updated"]["payload"], +): void { + if (typeof payload.repository_id === "string" && payload.repository_id) { + queryClient.invalidateQueries({ + exact: true, + queryKey: qk.workspaces.repositoryScripts(payload.repository_id), + }); + } + invalidateAllWorkspaceRepositoryLists(queryClient); +} + +function invalidateAllWorkspaceRepositoryLists(queryClient: QueryClient): void { + queryClient.invalidateQueries({ + predicate: (query) => isWorkspaceRepositoryListKey(query.queryKey), + }); +} + +function isWorkspaceRepositoryListKey(queryKey: readonly unknown[]): boolean { + return ( + queryKey.length === 4 && + queryKey[0] === "workspaces" && + typeof queryKey[1] === "string" && + queryKey[2] === "repositories" && + isRecord(queryKey[3]) && + "includeScripts" in queryKey[3] + ); +} + +function invalidateWorkspaceScopedQueries(queryClient: QueryClient, workspaceId: string): void { + // Workspace switches update the active workspace before workspace-scoped + // queries enable, so broad stale marking here is enough; enabled guards avoid + // refetching the previous workspace after deletion. + queryClient.invalidateQueries({ queryKey: ["workflows", workspaceId] }); + queryClient.invalidateQueries({ queryKey: ["workspaces", workspaceId] }); + queryClient.invalidateQueries({ queryKey: ["tasks", "page", workspaceId] }); + queryClient.invalidateQueries({ queryKey: ["tasks", "infinite", workspaceId] }); +} + +function hasWorkspaceId(value: unknown, id: string): boolean { + return isRecord(value) && value.id === id; +} + +function hasWorkflowId(value: unknown, id: string): boolean { + return isRecord(value) && value.id === id; +} + +function hasRepositoryId(value: unknown, id: string): boolean { + return isRecord(value) && value.id === id; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/apps/web/lib/query/client.test.ts b/apps/web/lib/query/client.test.ts new file mode 100644 index 0000000000..a3e708ad1c --- /dev/null +++ b/apps/web/lib/query/client.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { ApiError } from "@/lib/api/client"; +import { getBrowserQueryClient, isAuthError, makeQueryClient } from "./client"; + +describe("makeQueryClient", () => { + it("uses Kandev defaults for server-state reads and mutations", () => { + const client = makeQueryClient(); + + expect(client.getDefaultOptions().queries).toMatchObject({ + staleTime: 30_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + expect(client.getDefaultOptions().mutations).toMatchObject({ retry: 0 }); + }); + + it("does not retry auth failures", () => { + const client = makeQueryClient(); + const retry = client.getDefaultOptions().queries?.retry; + + expect(typeof retry).toBe("function"); + expect((retry as (failureCount: number, error: unknown) => boolean)(0, new Error("net"))).toBe( + true, + ); + expect( + (retry as (failureCount: number, error: unknown) => boolean)( + 0, + new ApiError("forbidden", 403, null), + ), + ).toBe(false); + expect((retry as (failureCount: number, error: unknown) => boolean)(2, new Error("net"))).toBe( + false, + ); + }); +}); + +describe("isAuthError", () => { + it("identifies 401 and 403 API errors", () => { + expect(isAuthError(new ApiError("unauthorized", 401, null))).toBe(true); + expect(isAuthError(new ApiError("forbidden", 403, null))).toBe(true); + expect(isAuthError(new ApiError("missing", 404, null))).toBe(false); + expect(isAuthError(new Error("forbidden"))).toBe(false); + }); +}); + +describe("getBrowserQueryClient", () => { + it("reuses one browser client", () => { + expect(getBrowserQueryClient()).toBe(getBrowserQueryClient()); + }); +}); diff --git a/apps/web/lib/query/client.ts b/apps/web/lib/query/client.ts new file mode 100644 index 0000000000..d1fcc9b507 --- /dev/null +++ b/apps/web/lib/query/client.ts @@ -0,0 +1,33 @@ +import { QueryClient } from "@tanstack/react-query"; +import { ApiError } from "@/lib/api/client"; + +export function isAuthError(err: unknown): boolean { + return err instanceof ApiError && (err.status === 401 || err.status === 403); +} + +export function makeQueryClient(): QueryClient { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + gcTime: 5 * 60_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + retry: (failureCount, err) => !isAuthError(err) && failureCount < 2, + }, + mutations: { + retry: 0, + }, + }, + }); +} + +let browserClient: QueryClient | undefined; + +export function getBrowserQueryClient(): QueryClient { + if (typeof window === "undefined") { + return makeQueryClient(); + } + browserClient ??= makeQueryClient(); + return browserClient; +} diff --git a/apps/web/lib/query/keys.test.ts b/apps/web/lib/query/keys.test.ts new file mode 100644 index 0000000000..f67e6f5303 --- /dev/null +++ b/apps/web/lib/query/keys.test.ts @@ -0,0 +1,101 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { describe, expect, it } from "vitest"; +import { officeTaskFiltersKey, qk, taskListFiltersKey } from "./keys"; + +describe("query keys", () => { + it("normalizes optional task list filters into serializable values", () => { + expect(taskListFiltersKey({ workflowId: undefined, repositoryId: null })).toEqual({ + page: null, + pageSize: null, + query: "", + includeArchived: false, + workflowId: null, + repositoryId: null, + sort: null, + }); + expect(() => + JSON.stringify(qk.tasks.infinite("workspace-1", { query: "search" })), + ).not.toThrow(); + }); + + it("sorts array filters so equivalent office task filters produce the same key", () => { + expect( + officeTaskFiltersKey({ + status: ["done", "todo"], + priority: ["p2", "p1"], + assignee: ["agent-2", "agent-1"], + project: ["project-2", "project-1"], + }), + ).toEqual( + officeTaskFiltersKey({ + status: ["todo", "done"], + priority: ["p1", "p2"], + assignee: ["agent-1", "agent-2"], + project: ["project-1", "project-2"], + }), + ); + }); + + it("keeps pagination cursors out of infinite query keys", () => { + expect(qk.session.messagesInfinite("session-1", { limit: 25 })).toEqual( + qk.session.messagesInfinite("session-1", { limit: 25 }), + ); + expect(qk.office.tasks("workspace-1", { limit: 25 })).toEqual( + qk.office.tasks("workspace-1", { limit: 25 }), + ); + }); + + it("keeps workflow step keys scoped by workflow", () => { + expect(qk.workflows.steps("workflow-1")).toEqual(["workflows", "workflow-1", "steps"]); + expect(() => JSON.stringify(qk.workflows.steps("workflow-1"))).not.toThrow(); + }); + + it("keeps session plan and queue keys stable and serializable", () => { + expect(qk.taskPlan.detail("task-1")).toEqual(["taskPlan", "task-1"]); + expect(qk.taskPlan.revisions("task-1")).toEqual(["taskPlan", "task-1", "revisions"]); + expect(qk.taskPlan.revision("task-1", "revision-1")).toEqual([ + "taskPlan", + "task-1", + "revisions", + "revision-1", + ]); + expect(qk.session.queue("session-1")).toEqual(["session", "session-1", "queue"]); + expect(() => JSON.stringify(qk.taskPlan.revision("task-1", "revision-1"))).not.toThrow(); + }); + + it("keeps session runtime keys scoped and serializable", () => { + expect(qk.sessionRuntime.gitStatus("env-1")).toEqual([ + "sessionRuntime", + "environment", + "env-1", + "gitStatus", + ]); + expect(qk.sessionRuntime.userShells("env-1", "task-1")).toEqual([ + "sessionRuntime", + "environment", + "env-1", + "userShells", + { taskId: "task-1" }, + ]); + expect(qk.sessionRuntime.models("session-1")).toEqual([ + "sessionRuntime", + "session", + "session-1", + "models", + ]); + expect(() => JSON.stringify(qk.sessionRuntime.agentctl("session-1"))).not.toThrow(); + }); + + it("keeps office task detail subresources on stable serializable keys", () => { + expect(qk.office.taskComments("task-1")).toEqual(["office", "tasks", "task-1", "comments"]); + expect(qk.office.taskActivity("workspace-1", "task-1")).toEqual([ + "office", + "workspaces", + "workspace-1", + "tasks", + "task-1", + "activity", + ]); + expect(() => JSON.stringify(qk.office.taskSearch("workspace-1", "needle", 10))).not.toThrow(); + }); +}); diff --git a/apps/web/lib/query/keys.ts b/apps/web/lib/query/keys.ts new file mode 100644 index 0000000000..5ba97395fa --- /dev/null +++ b/apps/web/lib/query/keys.ts @@ -0,0 +1,329 @@ +type OptionalString = string | null | undefined; + +export type TaskListFilters = { + page?: number; + pageSize?: number; + query?: string; + includeArchived?: boolean; + workflowId?: OptionalString; + repositoryId?: OptionalString; + sort?: string | null; +}; + +export type OfficeTaskFilters = { + status?: string[]; + priority?: string[]; + assignee?: string | string[]; + project?: string | string[]; + sort?: "updated_at" | "created_at" | "priority" | null; + order?: "asc" | "desc" | null; + limit?: number; + includeSystem?: boolean; +}; + +const NONE = "__none__"; + +function id(value: OptionalString) { + return value ?? NONE; +} + +function sorted(values: string[] | undefined): string[] { + return [...(values ?? [])].sort(); +} + +export function taskListFiltersKey(filters: TaskListFilters = {}) { + return { + page: filters.page ?? null, + pageSize: filters.pageSize ?? null, + query: filters.query ?? "", + includeArchived: filters.includeArchived ?? false, + workflowId: filters.workflowId ?? null, + repositoryId: filters.repositoryId ?? null, + sort: filters.sort ?? null, + } as const; +} + +export function officeTaskFiltersKey(filters: OfficeTaskFilters = {}) { + const sort = filters.sort === undefined ? "updated_at" : filters.sort; + const order = filters.order === undefined ? "desc" : filters.order; + return { + status: sorted(filters.status), + priority: sorted(filters.priority), + assignee: Array.isArray(filters.assignee) + ? sorted(filters.assignee) + : (filters.assignee ?? null), + project: Array.isArray(filters.project) ? sorted(filters.project) : (filters.project ?? null), + sort, + order, + limit: filters.limit ?? null, + includeSystem: filters.includeSystem ?? false, + } as const; +} + +export const qk = { + boot: { + initialState: () => ["boot", "initialState"] as const, + routeData: () => ["boot", "routeData"] as const, + }, + features: () => ["features"] as const, + workspaces: { + all: () => ["workspaces"] as const, + detail: (workspaceId: string) => ["workspaces", workspaceId] as const, + repositories: (workspaceId: string, params?: { includeScripts?: boolean }) => + [ + "workspaces", + workspaceId, + "repositories", + { includeScripts: params?.includeScripts ?? false }, + ] as const, + branches: (workspaceId: string, source: { repositoryId?: string; path?: string }) => + [ + "workspaces", + workspaceId, + "branches", + { repositoryId: source.repositoryId ?? null, path: source.path ?? null }, + ] as const, + repositoryBranches: (repositoryId: string) => + ["repositories", repositoryId, "branches"] as const, + repositoryScripts: (repositoryId: string) => ["repositories", repositoryId, "scripts"] as const, + quickChatSessions: (workspaceId: string) => + ["workspaces", workspaceId, "quickChatSessions"] as const, + }, + workflows: { + all: (workspaceId: string, params?: { includeHidden?: boolean }) => + ["workflows", workspaceId, { includeHidden: params?.includeHidden ?? false }] as const, + snapshot: (workflowId: string) => ["workflows", workflowId, "snapshot"] as const, + steps: (workflowId: string) => ["workflows", workflowId, "steps"] as const, + }, + tasks: { + detail: (taskId: string) => ["tasks", taskId] as const, + page: (workspaceId: OptionalString, filters?: TaskListFilters) => + ["tasks", "page", id(workspaceId), taskListFiltersKey(filters)] as const, + infinite: (workspaceId: string, filters?: TaskListFilters) => + ["tasks", "infinite", workspaceId, taskListFiltersKey(filters)] as const, + subtaskCount: (taskId: string) => ["tasks", taskId, "subtaskCount"] as const, + }, + taskSession: { + byTask: (taskId: string) => ["session", "byTask", taskId] as const, + byId: (sessionId: string) => ["session", "byId", sessionId] as const, + }, + taskPlan: { + detail: (taskId: string) => ["taskPlan", taskId] as const, + revisions: (taskId: string) => ["taskPlan", taskId, "revisions"] as const, + revision: (taskId: string, revisionId: string) => + ["taskPlan", taskId, "revisions", revisionId] as const, + }, + taskWalkthrough: { + detail: (taskId: string) => ["taskWalkthrough", taskId] as const, + }, + session: { + messages: (sessionId: string) => ["session", sessionId, "messages"] as const, + messagesPage: ( + sessionId: string, + params?: { limit?: number; before?: string; after?: string; sort?: "asc" | "desc" }, + ) => + [ + "session", + sessionId, + "messagesPage", + { + limit: params?.limit ?? null, + sort: params?.sort ?? "asc", + before: params?.before ?? null, + after: params?.after ?? null, + }, + ] as const, + messagesInfinite: (sessionId: string, params?: { limit?: number; sort?: "asc" | "desc" }) => + [ + "session", + sessionId, + "messagesInfinite", + { limit: params?.limit ?? null, sort: params?.sort ?? "asc" }, + ] as const, + turns: (sessionId: string) => ["session", sessionId, "turns"] as const, + search: (sessionId: string, query: string, limit = 50) => + ["session", sessionId, "search", { query, limit }] as const, + queue: (sessionId: string) => ["session", sessionId, "queue"] as const, + }, + sessionRuntime: { + gitStatus: (environmentId: string) => + ["sessionRuntime", "environment", id(environmentId), "gitStatus"] as const, + commits: (environmentId: string) => + ["sessionRuntime", "environment", id(environmentId), "commits"] as const, + userShells: (environmentId: string, taskId?: OptionalString) => + [ + "sessionRuntime", + "environment", + id(environmentId), + "userShells", + { taskId: taskId ?? null }, + ] as const, + processes: (sessionId: string) => + ["sessionRuntime", "session", id(sessionId), "processes"] as const, + prepare: (sessionId: string) => + ["sessionRuntime", "session", id(sessionId), "prepare"] as const, + contextWindow: (sessionId: string) => + ["sessionRuntime", "session", id(sessionId), "contextWindow"] as const, + availableCommands: (sessionId: string) => + ["sessionRuntime", "session", id(sessionId), "availableCommands"] as const, + mode: (sessionId: string) => ["sessionRuntime", "session", id(sessionId), "mode"] as const, + agentCapabilities: (sessionId: string) => + ["sessionRuntime", "session", id(sessionId), "agentCapabilities"] as const, + models: (sessionId: string) => ["sessionRuntime", "session", id(sessionId), "models"] as const, + promptUsage: (sessionId: string) => + ["sessionRuntime", "session", id(sessionId), "promptUsage"] as const, + todos: (sessionId: string) => ["sessionRuntime", "session", id(sessionId), "todos"] as const, + pollMode: (sessionId: string) => + ["sessionRuntime", "session", id(sessionId), "pollMode"] as const, + agentctl: (sessionId: string) => + ["sessionRuntime", "session", id(sessionId), "agentctl"] as const, + worktrees: (sessionId: string) => + ["sessionRuntime", "session", id(sessionId), "worktrees"] as const, + }, + settings: { + user: () => ["settings", "userSettings"] as const, + systemMetrics: () => ["settings", "systemMetrics"] as const, + executors: () => ["settings", "executors"] as const, + executor: (executorId: string) => ["settings", "executors", executorId] as const, + executorProfiles: (executorId: string) => + ["settings", "executors", executorId, "profiles"] as const, + allExecutorProfiles: () => ["settings", "executorProfiles"] as const, + scriptPlaceholders: () => ["settings", "scriptPlaceholders"] as const, + defaultScripts: (executorType: string) => ["settings", "defaultScripts", executorType] as const, + agents: () => ["settings", "agents"] as const, + agentDiscovery: () => ["settings", "agents", "discovery"] as const, + availableAgents: () => ["settings", "agents", "available"] as const, + agentMcpConfig: (profileId: string) => + ["settings", "agentProfiles", profileId, "mcpConfig"] as const, + installJobs: () => ["settings", "agentInstallJobs"] as const, + installJob: (jobId: string) => ["settings", "agentInstallJobs", jobId] as const, + dynamicModels: (agentName: string) => ["settings", "agentModels", agentName] as const, + editors: () => ["settings", "editors"] as const, + prompts: () => ["settings", "prompts"] as const, + notificationProviders: () => ["settings", "notificationProviders"] as const, + secrets: () => ["settings", "secrets"] as const, + spritesStatus: (secretId?: OptionalString) => + ["settings", "sprites", "status", id(secretId)] as const, + spritesInstances: (secretId?: OptionalString) => + ["settings", "sprites", "instances", id(secretId)] as const, + systemHealth: () => ["settings", "systemHealth"] as const, + runtimeFlags: () => ["settings", "runtimeFlags"] as const, + }, + office: { + meta: () => ["office", "meta"] as const, + dashboard: (workspaceId: string) => ["office", "workspaces", workspaceId, "dashboard"] as const, + tasks: (workspaceId: string, filters?: OfficeTaskFilters) => + ["office", "workspaces", workspaceId, "tasks", officeTaskFiltersKey(filters)] as const, + task: (workspaceId: string, taskId: string) => + ["office", "workspaces", workspaceId, "tasks", taskId] as const, + taskComments: (taskId: string) => ["office", "tasks", taskId, "comments"] as const, + taskActivity: (workspaceId: string, taskId: string) => + ["office", "workspaces", workspaceId, "tasks", taskId, "activity"] as const, + taskSearch: (workspaceId: string, query: string, limit = 50) => + ["office", "workspaces", workspaceId, "tasks", "search", { query, limit }] as const, + agents: (workspaceId: string) => ["office", "workspaces", workspaceId, "agents"] as const, + projects: (workspaceId: string) => ["office", "workspaces", workspaceId, "projects"] as const, + project: (projectId: string) => ["office", "projects", projectId] as const, + inbox: (workspaceId: string) => ["office", "workspaces", workspaceId, "inbox"] as const, + activity: (workspaceId: string, filterType = "all") => + ["office", "workspaces", workspaceId, "activity", { filterType }] as const, + runs: (workspaceId: string) => ["office", "workspaces", workspaceId, "runs"] as const, + routing: (workspaceId: string) => ["office", "workspaces", workspaceId, "routing"] as const, + providerHealth: (workspaceId: string) => + ["office", "workspaces", workspaceId, "providerHealth"] as const, + routingPreview: (workspaceId: string) => + ["office", "workspaces", workspaceId, "routingPreview"] as const, + agentRoute: (agentId: string) => ["office", "agents", agentId, "route"] as const, + agentSummary: (agentId: string, days?: number) => + ["office", "agents", agentId, "summary", { days: days ?? null }] as const, + agentRuns: (agentId: string, params?: { limit?: number }) => + ["office", "agents", agentId, "runs", { limit: params?.limit ?? null }] as const, + runDetail: (agentId: string, runId: string) => + ["office", "agents", agentId, "runs", runId] as const, + runAttempts: (runId: string) => ["office", "runs", runId, "attempts"] as const, + costs: (workspaceId: string) => ["office", "workspaces", workspaceId, "costs"] as const, + costBreakdown: (workspaceId: string) => + ["office", "workspaces", workspaceId, "costBreakdown"] as const, + budgets: (workspaceId: string) => ["office", "workspaces", workspaceId, "budgets"] as const, + routines: (workspaceId: string) => ["office", "workspaces", workspaceId, "routines"] as const, + routineRuns: (workspaceId: string) => + ["office", "workspaces", workspaceId, "routineRuns"] as const, + routineTriggers: (routineId: string) => ["office", "routines", routineId, "triggers"] as const, + skills: (workspaceId: string) => ["office", "workspaces", workspaceId, "skills"] as const, + }, + integrations: { + github: { + status: () => ["integrations", "github", "status"] as const, + prs: (workspaceId: OptionalString) => + ["integrations", "github", "prs", id(workspaceId)] as const, + issues: (workspaceId: OptionalString) => + ["integrations", "github", "issues", id(workspaceId)] as const, + prWatches: () => ["integrations", "github", "prWatches"] as const, + reviewWatches: (workspaceId?: OptionalString) => + ["integrations", "github", "reviewWatches", id(workspaceId)] as const, + issueWatches: (workspaceId?: OptionalString) => + ["integrations", "github", "issueWatches", id(workspaceId)] as const, + taskPr: (taskId: string) => ["integrations", "github", "taskPr", id(taskId)] as const, + taskCiOptions: (taskId: string) => + ["integrations", "github", "taskCiOptions", id(taskId)] as const, + actionPresets: (workspaceId: OptionalString) => + ["integrations", "github", "actionPresets", id(workspaceId)] as const, + rateLimit: () => ["integrations", "github", "rateLimit"] as const, + }, + gitlab: { + status: () => ["integrations", "gitlab", "status"] as const, + stats: () => ["integrations", "gitlab", "stats"] as const, + mrs: (workspaceId: OptionalString) => + ["integrations", "gitlab", "mrs", id(workspaceId)] as const, + taskMr: (taskId: string) => ["integrations", "gitlab", "taskMr", id(taskId)] as const, + reviewWatches: (workspaceId?: OptionalString) => + ["integrations", "gitlab", "reviewWatches", id(workspaceId)] as const, + issueWatches: (workspaceId?: OptionalString) => + ["integrations", "gitlab", "issueWatches", id(workspaceId)] as const, + actionPresets: (workspaceId: OptionalString) => + ["integrations", "gitlab", "actionPresets", id(workspaceId)] as const, + projects: () => ["integrations", "gitlab", "projects"] as const, + }, + jira: { + config: (workspaceId?: OptionalString) => + ["integrations", "jira", "config", id(workspaceId)] as const, + projects: () => ["integrations", "jira", "projects"] as const, + issueWatches: (workspaceId?: OptionalString) => + ["integrations", "jira", "issueWatches", id(workspaceId)] as const, + }, + linear: { + config: (workspaceId?: OptionalString) => + ["integrations", "linear", "config", id(workspaceId)] as const, + teams: () => ["integrations", "linear", "teams"] as const, + issueWatches: (workspaceId?: OptionalString) => + ["integrations", "linear", "issueWatches", id(workspaceId)] as const, + }, + slack: { + config: (workspaceId?: OptionalString) => + ["integrations", "slack", "config", id(workspaceId)] as const, + }, + sentry: { + instances: (workspaceId?: OptionalString) => + ["integrations", "sentry", "instances", id(workspaceId)] as const, + issueWatches: (workspaceId?: OptionalString) => + ["integrations", "sentry", "issueWatches", id(workspaceId)] as const, + }, + }, + system: { + info: () => ["system", "info"] as const, + diskUsage: () => ["system", "diskUsage"] as const, + database: () => ["system", "database"] as const, + backups: () => ["system", "backups"] as const, + logFiles: () => ["system", "logs"] as const, + logTail: (n = 1000) => ["system", "logs", "tail", { n }] as const, + jobs: () => ["system", "jobs"] as const, + job: (jobId: string) => ["system", "jobs", jobId] as const, + metrics: () => ["system", "metrics"] as const, + updates: () => ["system", "updates"] as const, + restartCapability: () => ["system", "restartCapability"] as const, + }, + automations: { + list: (workspaceId: OptionalString) => ["automations", id(workspaceId)] as const, + runs: (automationId: OptionalString) => ["automations", id(automationId), "runs"] as const, + }, +}; diff --git a/apps/web/lib/query/provider.test.tsx b/apps/web/lib/query/provider.test.tsx new file mode 100644 index 0000000000..3e2c3b55d4 --- /dev/null +++ b/apps/web/lib/query/provider.test.tsx @@ -0,0 +1,107 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; +import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend"; +import type { BackendMessage } from "@/lib/types/backend-message"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { setWebSocketClient } from "@/lib/ws/connection"; +import { makeQueryClient } from "./client"; +import { qk } from "./keys"; +import { QueryProvider } from "./provider"; + +type E2EWindow = Window & { + __KANDEV_E2E_EXPOSE_STORE__?: boolean; + __KANDEV_E2E_QUERY_CLIENT__?: ReturnType; +}; + +type AnyBackendMessage = BackendMessage>; +type Handler = (message: AnyBackendMessage) => void; + +class FakeWebSocketClient { + private envelopeHandlers = new Set(); + private handlers = new Map>(); + + on(type: T, handler: (message: BackendMessageMap[T]) => void) { + const bucket = this.handlers.get(type) ?? new Set(); + bucket.add(handler as Handler); + this.handlers.set(type, bucket); + return () => { + bucket.delete(handler as Handler); + }; + } + + onEnvelope(handler: (message: BackendMessageMap[BackendMessageType]) => void) { + this.envelopeHandlers.add(handler as Handler); + return () => { + this.envelopeHandlers.delete(handler as Handler); + }; + } + + emit(message: AnyBackendMessage) { + this.envelopeHandlers.forEach((handler) => handler(message)); + this.handlers.get(message.action)?.forEach((handler) => handler(message)); + } +} + +describe("QueryProvider", () => { + beforeEach(() => { + delete (window as E2EWindow).__KANDEV_E2E_EXPOSE_STORE__; + delete (window as E2EWindow).__KANDEV_E2E_QUERY_CLIENT__; + setWebSocketClient(null); + }); + + it("renders children inside the query client provider", () => { + render( + +
child
+
, + ); + + expect(screen.getByText("child")).toBeTruthy(); + }); + + it("exposes the query client only when E2E store exposure is enabled", async () => { + const client = makeQueryClient(); + (window as E2EWindow).__KANDEV_E2E_EXPOSE_STORE__ = true; + + render( + +
child
+
, + ); + + await waitFor(() => { + expect((window as E2EWindow).__KANDEV_E2E_QUERY_CLIENT__).toBe(client); + }); + }); + + it("registers the query bridge when the WebSocket client is installed later", async () => { + const client = makeQueryClient(); + const ws = new FakeWebSocketClient(); + client.setQueryData(qk.tasks.detail("task-1"), { id: "task-1", title: "Old title" }); + + render( + +
child
+
, + ); + + setWebSocketClient(ws as unknown as WebSocketClient); + ws.emit({ + type: "notification", + action: "task.updated", + payload: { + task_id: "task-1", + workflow_id: "workflow-1", + workflow_step_id: "step-1", + title: "Updated title", + is_ephemeral: false, + }, + }); + + await waitFor(() => { + expect(client.getQueryData(qk.tasks.detail("task-1"))).toMatchObject({ + title: "Updated title", + }); + }); + }); +}); diff --git a/apps/web/lib/query/provider.tsx b/apps/web/lib/query/provider.tsx new file mode 100644 index 0000000000..b45bbfd844 --- /dev/null +++ b/apps/web/lib/query/provider.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; +import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { subscribeWebSocketClient } from "@/lib/ws/connection"; +import { registerQueryBridge } from "./bridge"; +import { getBrowserQueryClient } from "./client"; + +type QueryProviderProps = { + children: ReactNode; + client?: QueryClient; +}; + +type E2EWindow = Window & { + __KANDEV_E2E_EXPOSE_STORE__?: boolean; + __KANDEV_E2E_QUERY_CLIENT__?: QueryClient; +}; + +export function QueryProvider({ children, client }: QueryProviderProps) { + const queryClient = client ?? getBrowserQueryClient(); + const showDevtools = + typeof process !== "undefined" ? process.env.NODE_ENV !== "production" : false; + + useEffect(() => { + const win = window as E2EWindow; + if (win.__KANDEV_E2E_EXPOSE_STORE__) { + win.__KANDEV_E2E_QUERY_CLIENT__ = queryClient; + } + }, [queryClient]); + + useEffect(() => { + let cleanupBridge: (() => void) | null = null; + const unsubscribeClient = subscribeWebSocketClient((client) => { + cleanupBridge?.(); + cleanupBridge = client ? registerQueryBridge(client, queryClient) : null; + }); + + return () => { + unsubscribeClient(); + cleanupBridge?.(); + }; + }, [queryClient]); + + return ( + + {children} + {showDevtools ? : null} + + ); +} diff --git a/apps/web/lib/query/query-options/automations.ts b/apps/web/lib/query/query-options/automations.ts new file mode 100644 index 0000000000..231c31a159 --- /dev/null +++ b/apps/web/lib/query/query-options/automations.ts @@ -0,0 +1,19 @@ +import { queryOptions } from "@tanstack/react-query"; +import { listAutomationRuns, listAutomations } from "@/lib/api/domains/automation-api"; +import { qk } from "../keys"; + +export function automationsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.automations.list(workspaceId), + queryFn: () => listAutomations(workspaceId), + enabled: Boolean(workspaceId), + }); +} + +export function automationRunsQueryOptions(automationId: string) { + return queryOptions({ + queryKey: qk.automations.runs(automationId), + queryFn: () => listAutomationRuns(automationId), + enabled: Boolean(automationId), + }); +} diff --git a/apps/web/lib/query/query-options/features.ts b/apps/web/lib/query/query-options/features.ts new file mode 100644 index 0000000000..0d28beae13 --- /dev/null +++ b/apps/web/lib/query/query-options/features.ts @@ -0,0 +1,11 @@ +import { queryOptions } from "@tanstack/react-query"; +import { fetchFeatureFlags } from "@/lib/api/domains/features-api"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export function featureFlagsQueryOptions() { + return queryOptions({ + queryKey: qk.features(), + queryFn: ({ signal }) => fetchFeatureFlags(withSignal(signal)), + }); +} diff --git a/apps/web/lib/query/query-options/github.ts b/apps/web/lib/query/query-options/github.ts new file mode 100644 index 0000000000..a5b686f527 --- /dev/null +++ b/apps/web/lib/query/query-options/github.ts @@ -0,0 +1,84 @@ +import { queryOptions } from "@tanstack/react-query"; +import { + fetchGitHubActionPresets, + fetchGitHubStatus, + getTaskCIAutomationOptions, + listIssueWatches, + listPRWatches, + listReviewWatches, + listWorkspaceTaskPRs, +} from "@/lib/api/domains/github-api"; +import type { TaskPR } from "@/lib/types/github"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export function githubStatusQueryOptions() { + return queryOptions({ + queryKey: qk.integrations.github.status(), + queryFn: ({ signal }) => fetchGitHubStatus(withSignal(signal)), + }); +} + +export function workspaceTaskPrsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.integrations.github.prs(workspaceId), + queryFn: ({ signal }) => listWorkspaceTaskPRs(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function taskPrsQueryOptions(taskId: string) { + return queryOptions({ + queryKey: qk.integrations.github.taskPr(taskId), + queryFn: async (): Promise => [], + enabled: false, + }); +} + +export function taskCiOptionsQueryOptions(taskId: string) { + return queryOptions({ + queryKey: qk.integrations.github.taskCiOptions(taskId), + queryFn: ({ signal }) => getTaskCIAutomationOptions(taskId, withSignal(signal)), + enabled: Boolean(taskId), + }); +} + +export function prWatchesQueryOptions() { + return queryOptions({ + queryKey: qk.integrations.github.prWatches(), + queryFn: async ({ signal }) => { + const response = await listPRWatches(withSignal(signal)); + return response?.watches ?? []; + }, + }); +} + +export function reviewWatchesQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.github.reviewWatches(workspaceId), + queryFn: async ({ signal }) => { + const response = await listReviewWatches(workspaceId ?? undefined, withSignal(signal)); + return response?.watches ?? []; + }, + enabled: workspaceId !== null, + }); +} + +export function issueWatchesQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.github.issueWatches(workspaceId), + queryFn: async ({ signal }) => { + const response = await listIssueWatches(workspaceId ?? undefined, withSignal(signal)); + return response?.watches ?? []; + }, + enabled: workspaceId !== null, + }); +} + +export function githubActionPresetsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.integrations.github.actionPresets(workspaceId), + queryFn: ({ signal }) => fetchGitHubActionPresets(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} diff --git a/apps/web/lib/query/query-options/gitlab.ts b/apps/web/lib/query/query-options/gitlab.ts new file mode 100644 index 0000000000..974b0faa66 --- /dev/null +++ b/apps/web/lib/query/query-options/gitlab.ts @@ -0,0 +1,72 @@ +import { queryOptions } from "@tanstack/react-query"; +import { + fetchGitLabStats, + fetchGitLabStatus, + getActionPresets, + listIssueWatches, + listReviewWatches, + listWorkspaceTaskMRs, +} from "@/lib/api/domains/gitlab-api"; +import type { TaskMR } from "@/lib/types/gitlab"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export function gitlabStatusQueryOptions() { + return queryOptions({ + queryKey: qk.integrations.gitlab.status(), + queryFn: ({ signal }) => fetchGitLabStatus(withSignal(signal)), + }); +} + +export function gitlabStatsQueryOptions() { + return queryOptions({ + queryKey: qk.integrations.gitlab.stats(), + queryFn: () => fetchGitLabStats(), + }); +} + +export function workspaceTaskMrsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.integrations.gitlab.mrs(workspaceId), + queryFn: ({ signal }) => listWorkspaceTaskMRs(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function taskMrsQueryOptions(taskId: string) { + return queryOptions({ + queryKey: qk.integrations.gitlab.taskMr(taskId), + queryFn: async (): Promise => [], + enabled: false, + }); +} + +export function gitlabReviewWatchesQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.gitlab.reviewWatches(workspaceId), + queryFn: async ({ signal }) => { + const response = await listReviewWatches(workspaceId ?? undefined, withSignal(signal)); + return response?.watches ?? []; + }, + enabled: workspaceId !== null, + }); +} + +export function gitlabIssueWatchesQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.gitlab.issueWatches(workspaceId), + queryFn: async ({ signal }) => { + const response = await listIssueWatches(workspaceId ?? undefined, withSignal(signal)); + return response?.watches ?? []; + }, + enabled: workspaceId !== null, + }); +} + +export function gitlabActionPresetsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.integrations.gitlab.actionPresets(workspaceId), + queryFn: () => getActionPresets(workspaceId), + enabled: Boolean(workspaceId), + }); +} diff --git a/apps/web/lib/query/query-options/index.ts b/apps/web/lib/query/query-options/index.ts new file mode 100644 index 0000000000..008ceb81f5 --- /dev/null +++ b/apps/web/lib/query/query-options/index.ts @@ -0,0 +1,15 @@ +export * from "./automations"; +export * from "./features"; +export * from "./github"; +export * from "./gitlab"; +export * from "./jira"; +export * from "./kanban"; +export * from "./linear"; +export * from "./office"; +export * from "./sentry"; +export * from "./session"; +export * from "./session-runtime"; +export * from "./settings"; +export * from "./slack"; +export * from "./system"; +export * from "./workspace"; diff --git a/apps/web/lib/query/query-options/jira.ts b/apps/web/lib/query/query-options/jira.ts new file mode 100644 index 0000000000..ed4713b349 --- /dev/null +++ b/apps/web/lib/query/query-options/jira.ts @@ -0,0 +1,22 @@ +import { queryOptions } from "@tanstack/react-query"; +import { getJiraConfig, listJiraIssueWatches } from "@/lib/api/domains/jira-api"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export function jiraConfigQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.jira.config(workspaceId), + queryFn: ({ signal }) => + getJiraConfig({ ...withSignal(signal), ...(workspaceId ? { workspaceId } : {}) }), + enabled: workspaceId !== null, + refetchInterval: 90_000, + }); +} + +export function jiraIssueWatchesQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.jira.issueWatches(workspaceId), + queryFn: ({ signal }) => listJiraIssueWatches(workspaceId ?? undefined, withSignal(signal)), + enabled: workspaceId !== null, + }); +} diff --git a/apps/web/lib/query/query-options/kanban.ts b/apps/web/lib/query/query-options/kanban.ts new file mode 100644 index 0000000000..90366b8579 --- /dev/null +++ b/apps/web/lib/query/query-options/kanban.ts @@ -0,0 +1,103 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { + fetchTask, + fetchWorkflowSnapshot, + getSubtaskCount, + listTasksByWorkspace, + listWorkflows, +} from "@/lib/api/domains/kanban-api"; +import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import { qk, type TaskListFilters } from "../keys"; +import { withSignal } from "./utils"; + +const DEFAULT_TASK_PAGE_SIZE = 50; + +export function workflowsQueryOptions(workspaceId: string, params?: { includeHidden?: boolean }) { + return queryOptions({ + queryKey: qk.workflows.all(workspaceId, params), + queryFn: async ({ signal }) => { + const response = await listWorkflows(workspaceId, { ...withSignal(signal), ...params }); + return response.workflows; + }, + enabled: Boolean(workspaceId), + }); +} + +export function workflowSnapshotQueryOptions(workflowId: string) { + return queryOptions({ + queryKey: qk.workflows.snapshot(workflowId), + queryFn: ({ signal }) => fetchWorkflowSnapshot(workflowId, withSignal(signal)), + enabled: Boolean(workflowId), + }); +} + +export function workflowStepsQueryOptions(workflowId: string) { + return queryOptions({ + queryKey: qk.workflows.steps(workflowId), + queryFn: async ({ signal }) => { + const response = await listWorkflowSteps(workflowId, withSignal(signal)); + return [...response.steps].sort((a, b) => a.position - b.position); + }, + enabled: Boolean(workflowId), + }); +} + +export function taskQueryOptions(taskId: string) { + return queryOptions({ + queryKey: qk.tasks.detail(taskId), + queryFn: ({ signal }) => fetchTask(taskId, withSignal(signal)), + enabled: Boolean(taskId), + }); +} + +export function workspaceTasksQueryOptions(workspaceId: string, filters: TaskListFilters = {}) { + return queryOptions({ + queryKey: qk.tasks.page(workspaceId, filters), + queryFn: ({ signal }) => + listTasksByWorkspace( + workspaceId, + { + ...filters, + sort: filters.sort ?? undefined, + page: filters.page ?? 1, + pageSize: filters.pageSize ?? DEFAULT_TASK_PAGE_SIZE, + }, + withSignal(signal), + ), + enabled: Boolean(workspaceId), + }); +} + +export function workspaceTasksInfiniteQueryOptions( + workspaceId: string, + filters: TaskListFilters = {}, +) { + return infiniteQueryOptions({ + queryKey: qk.tasks.infinite(workspaceId, filters), + initialPageParam: 1, + queryFn: ({ pageParam, signal }) => + listTasksByWorkspace( + workspaceId, + { + ...filters, + sort: filters.sort ?? undefined, + page: Number(pageParam), + pageSize: filters.pageSize ?? DEFAULT_TASK_PAGE_SIZE, + }, + withSignal(signal), + ), + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((total, page) => total + page.tasks.length, 0); + return loaded < lastPage.total ? allPages.length + 1 : undefined; + }, + enabled: Boolean(workspaceId), + }); +} + +export function subtaskCountQueryOptions(taskId: string) { + return queryOptions({ + queryKey: qk.tasks.subtaskCount(taskId), + queryFn: ({ signal }) => getSubtaskCount(taskId, withSignal(signal)), + enabled: Boolean(taskId), + }); +} diff --git a/apps/web/lib/query/query-options/linear.ts b/apps/web/lib/query/query-options/linear.ts new file mode 100644 index 0000000000..549f1bab5c --- /dev/null +++ b/apps/web/lib/query/query-options/linear.ts @@ -0,0 +1,22 @@ +import { queryOptions } from "@tanstack/react-query"; +import { getLinearConfig, listLinearIssueWatches } from "@/lib/api/domains/linear-api"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export function linearConfigQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.linear.config(workspaceId), + queryFn: ({ signal }) => + getLinearConfig({ ...withSignal(signal), ...(workspaceId ? { workspaceId } : {}) }), + enabled: workspaceId !== null, + refetchInterval: 90_000, + }); +} + +export function linearIssueWatchesQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.linear.issueWatches(workspaceId), + queryFn: ({ signal }) => listLinearIssueWatches(workspaceId ?? undefined, withSignal(signal)), + enabled: workspaceId !== null, + }); +} diff --git a/apps/web/lib/query/query-options/office.ts b/apps/web/lib/query/query-options/office.ts new file mode 100644 index 0000000000..a28b4f3e0f --- /dev/null +++ b/apps/web/lib/query/query-options/office.ts @@ -0,0 +1,303 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { + getCostsBreakdown, + getCostSummary, + getDashboard, + getInbox, + getMeta, + getProject, + getTask, + listAllRoutineRuns, + listActivity, + listActivityForTarget, + listAgentProfiles, + listBudgets, + listComments, + listProjects, + listRoutines, + listRoutineTriggers, + searchTasks, +} from "@/lib/api/domains/office-api"; +import { + getAgentRoute, + getProviderHealth, + getRoutingPreview, + getWorkspaceRouting, +} from "@/lib/api/domains/office-routing-api"; +import { + getAgentSummary, + getRunAttempts, + getRunDetail, + listAgentRuns, + listRuns, +} from "@/lib/api/domains/office-runs-api"; +import { listSkills } from "@/lib/api/domains/office-skills-api"; +import { listTasks, type ListTasksParams } from "@/lib/api/domains/office-tasks-api"; +import { qk, type OfficeTaskFilters } from "../keys"; +import { withSignal } from "./utils"; + +const DEFAULT_OFFICE_TASK_LIMIT = 50; + +function singleFilterValue(value: string | string[] | undefined): string | undefined { + if (Array.isArray(value)) return value.length === 1 ? value[0] : undefined; + return value; +} + +function toOfficeTaskParams(filters: OfficeTaskFilters = {}): ListTasksParams { + const params: ListTasksParams = { + limit: filters.limit ?? DEFAULT_OFFICE_TASK_LIMIT, + }; + if (filters.status?.length) params.status = filters.status; + if (filters.priority?.length) params.priority = filters.priority; + const assignee = singleFilterValue(filters.assignee); + if (assignee) params.assignee = assignee; + const project = singleFilterValue(filters.project); + if (project) params.project = project; + if (filters.includeSystem) params.include_system = true; + if (filters.sort !== null) { + params.sort = filters.sort ?? "updated_at"; + } + if (filters.order !== null) { + params.order = filters.order ?? "desc"; + } + return params; +} + +export function officeMetaQueryOptions() { + return queryOptions({ + queryKey: qk.office.meta(), + queryFn: ({ signal }) => getMeta(withSignal(signal)), + }); +} + +export function officeDashboardQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.dashboard(workspaceId), + queryFn: ({ signal }) => getDashboard(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeTasksInfiniteQueryOptions( + workspaceId: string, + filters: OfficeTaskFilters = {}, +) { + return infiniteQueryOptions({ + queryKey: qk.office.tasks(workspaceId, filters), + initialPageParam: undefined as { cursor?: string; cursor_id?: string } | undefined, + queryFn: ({ pageParam, signal }) => + listTasks(workspaceId, { ...toOfficeTaskParams(filters), ...pageParam }, withSignal(signal)), + getNextPageParam: (lastPage) => + lastPage.next_cursor + ? { cursor: lastPage.next_cursor, cursor_id: lastPage.next_id } + : undefined, + enabled: Boolean(workspaceId), + }); +} + +export function officeTaskQueryOptions(workspaceId: string, taskId: string) { + return queryOptions({ + queryKey: qk.office.task(workspaceId, taskId), + queryFn: ({ signal }) => getTask(taskId, withSignal(signal)), + enabled: Boolean(workspaceId && taskId), + }); +} + +export function officeTaskCommentsQueryOptions(taskId: string) { + return queryOptions({ + queryKey: qk.office.taskComments(taskId), + queryFn: ({ signal }) => listComments(taskId, withSignal(signal)), + enabled: Boolean(taskId), + }); +} + +export function officeTaskActivityQueryOptions(workspaceId: string, taskId: string) { + return queryOptions({ + queryKey: qk.office.taskActivity(workspaceId, taskId), + queryFn: ({ signal }) => listActivityForTarget(workspaceId, taskId, withSignal(signal)), + enabled: Boolean(workspaceId && taskId), + }); +} + +export function officeTaskSearchQueryOptions(workspaceId: string, query: string, limit = 50) { + const normalizedQuery = query.trim(); + return queryOptions({ + queryKey: qk.office.taskSearch(workspaceId, normalizedQuery, limit), + queryFn: ({ signal }) => searchTasks(workspaceId, normalizedQuery, limit, withSignal(signal)), + enabled: Boolean(workspaceId && normalizedQuery), + }); +} + +export function officeAgentsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.agents(workspaceId), + queryFn: ({ signal }) => listAgentProfiles(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeProjectsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.projects(workspaceId), + queryFn: ({ signal }) => listProjects(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeProjectQueryOptions(projectId: string) { + return queryOptions({ + queryKey: qk.office.project(projectId), + queryFn: ({ signal }) => getProject(projectId, withSignal(signal)), + enabled: Boolean(projectId), + }); +} + +export function officeInboxQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.inbox(workspaceId), + queryFn: ({ signal }) => getInbox(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeActivityQueryOptions(workspaceId: string, filterType = "all") { + return queryOptions({ + queryKey: qk.office.activity(workspaceId, filterType), + queryFn: ({ signal }) => listActivity(workspaceId, filterType, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeRunsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.runs(workspaceId), + queryFn: ({ signal }) => listRuns(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeRoutingQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.routing(workspaceId), + queryFn: ({ signal }) => getWorkspaceRouting(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeProviderHealthQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.providerHealth(workspaceId), + queryFn: ({ signal }) => getProviderHealth(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeRoutingPreviewQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.routingPreview(workspaceId), + queryFn: ({ signal }) => getRoutingPreview(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeAgentRouteQueryOptions(agentId: string) { + return queryOptions({ + queryKey: qk.office.agentRoute(agentId), + queryFn: ({ signal }) => getAgentRoute(agentId, withSignal(signal)), + enabled: Boolean(agentId), + }); +} + +export function officeAgentSummaryQueryOptions(agentId: string, days?: number) { + return queryOptions({ + queryKey: qk.office.agentSummary(agentId, days), + queryFn: ({ signal }) => getAgentSummary(agentId, days, withSignal(signal)), + enabled: Boolean(agentId), + }); +} + +export function officeAgentRunsInfiniteQueryOptions(agentId: string, params?: { limit?: number }) { + return infiniteQueryOptions({ + queryKey: qk.office.agentRuns(agentId, params), + initialPageParam: undefined as { cursor?: string; cursorId?: string } | undefined, + queryFn: ({ pageParam, signal }) => + listAgentRuns(agentId, { limit: params?.limit ?? 25, ...pageParam }, withSignal(signal)), + getNextPageParam: (lastPage) => + lastPage.next_cursor + ? { cursor: lastPage.next_cursor, cursorId: lastPage.next_id } + : undefined, + enabled: Boolean(agentId), + }); +} + +export function officeRunDetailQueryOptions(agentId: string, runId: string) { + return queryOptions({ + queryKey: qk.office.runDetail(agentId, runId), + queryFn: ({ signal }) => getRunDetail(agentId, runId, withSignal(signal)), + enabled: Boolean(agentId && runId), + }); +} + +export function officeRunAttemptsQueryOptions(runId: string) { + return queryOptions({ + queryKey: qk.office.runAttempts(runId), + queryFn: ({ signal }) => getRunAttempts(runId, withSignal(signal)), + enabled: Boolean(runId), + }); +} + +export function officeCostsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.costs(workspaceId), + queryFn: ({ signal }) => getCostSummary(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeCostBreakdownQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.costBreakdown(workspaceId), + queryFn: ({ signal }) => getCostsBreakdown(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeBudgetsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.budgets(workspaceId), + queryFn: ({ signal }) => listBudgets(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeRoutinesQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.routines(workspaceId), + queryFn: ({ signal }) => listRoutines(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeRoutineRunsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.routineRuns(workspaceId), + queryFn: ({ signal }) => listAllRoutineRuns(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} + +export function officeRoutineTriggersQueryOptions(routineId: string) { + return queryOptions({ + queryKey: qk.office.routineTriggers(routineId), + queryFn: ({ signal }) => listRoutineTriggers(routineId, withSignal(signal)), + enabled: Boolean(routineId), + }); +} + +export function officeSkillsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.office.skills(workspaceId), + queryFn: ({ signal }) => listSkills(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} diff --git a/apps/web/lib/query/query-options/query-options.test.ts b/apps/web/lib/query/query-options/query-options.test.ts new file mode 100644 index 0000000000..9641b523d1 --- /dev/null +++ b/apps/web/lib/query/query-options/query-options.test.ts @@ -0,0 +1,447 @@ +/* eslint-disable max-lines-per-function, sonarjs/no-duplicate-string */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchFeatureFlags } from "@/lib/api/domains/features-api"; +import { listTasksByWorkspace } from "@/lib/api/domains/kanban-api"; +import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import { + fetchTaskSession, + listTaskSessionMessages, + listTaskSessions, +} from "@/lib/api/domains/session-api"; +import { listTasks as listOfficeTasks } from "@/lib/api/domains/office-tasks-api"; +import { getWorkspaceRouting } from "@/lib/api/domains/office-routing-api"; +import { getTaskPlan, getPlanRevision, listPlanRevisions } from "@/lib/api/domains/plan-api"; +import { getQueueStatus } from "@/lib/api/domains/queue-api"; +import { featureFlagsQueryOptions } from "./features"; +import { workflowStepsQueryOptions, workspaceTasksInfiniteQueryOptions } from "./kanban"; +import { + officeRoutingQueryOptions, + officeTaskCommentsQueryOptions, + officeTasksInfiniteQueryOptions, + officeTaskSearchQueryOptions, +} from "./office"; +import { + planRevisionQueryOptions, + queueStatusQueryOptions, + sessionMessagesInfiniteQueryOptions, + sessionMessagesLatestQueryOptions, + taskPlanQueryOptions, + taskPlanRevisionsQueryOptions, + taskSessionQueryOptions, + taskSessionsQueryOptions, +} from "./session"; +import { + fetchSessionCommitsSnapshot, + sessionCommitsQueryOptions, + sessionModelsQueryOptions, + userShellsQueryOptions, +} from "./session-runtime"; +import { qk } from "../keys"; + +vi.mock("@/lib/api/domains/features-api", () => ({ + fetchFeatureFlags: vi.fn(async () => ({ office: true })), +})); + +vi.mock("@/lib/api/domains/kanban-api", () => ({ + fetchTask: vi.fn(), + fetchWorkflowSnapshot: vi.fn(), + getSubtaskCount: vi.fn(), + listTasksByWorkspace: vi.fn(async () => ({ tasks: [], total: 0 })), + listWorkflows: vi.fn(), +})); + +vi.mock("@/lib/api/domains/workflow-api", () => ({ + listWorkflowSteps: vi.fn(), +})); + +vi.mock("@/lib/api/domains/session-api", () => ({ + fetchTaskSession: vi.fn(), + listSessionTurns: vi.fn(), + listTaskSessionMessages: vi.fn(async () => ({ + messages: [], + total: 0, + has_more: false, + cursor: "", + })), + listTaskSessions: vi.fn(), + searchSessionMessages: vi.fn(), +})); + +vi.mock("@/lib/api/domains/plan-api", () => ({ + createTaskPlan: vi.fn(), + deleteTaskPlan: vi.fn(), + getPlanRevision: vi.fn(), + getTaskPlan: vi.fn(), + listPlanRevisions: vi.fn(), + revertPlanRevision: vi.fn(), + updateTaskPlan: vi.fn(), +})); + +vi.mock("@/lib/api/domains/queue-api", () => ({ + clearQueue: vi.fn(), + drainQueuedMessage: vi.fn(), + getQueueStatus: vi.fn(), + queueMessage: vi.fn(), + removeQueuedEntry: vi.fn(), + updateQueuedMessage: vi.fn(), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: vi.fn(), +})); + +vi.mock("@/lib/api/domains/office-api", () => ({ + getCostsBreakdown: vi.fn(), + getCostSummary: vi.fn(), + getDashboard: vi.fn(), + getInbox: vi.fn(), + getMeta: vi.fn(), + getProject: vi.fn(), + getTask: vi.fn(), + listAllRoutineRuns: vi.fn(), + listActivity: vi.fn(), + listActivityForTarget: vi.fn(), + listAgentProfiles: vi.fn(), + listBudgets: vi.fn(), + listComments: vi.fn(async () => ({ comments: [] })), + listProjects: vi.fn(), + listRoutines: vi.fn(), + listRoutineTriggers: vi.fn(), + searchTasks: vi.fn(async () => ({ tasks: [] })), +})); + +vi.mock("@/lib/api/domains/office-runs-api", () => ({ + getAgentSummary: vi.fn(), + getRunAttempts: vi.fn(), + getRunDetail: vi.fn(), + listAgentRuns: vi.fn(), + listRuns: vi.fn(), +})); + +vi.mock("@/lib/api/domains/office-routing-api", () => ({ + getAgentRoute: vi.fn(), + getProviderHealth: vi.fn(), + getRoutingPreview: vi.fn(), + getWorkspaceRouting: vi.fn(async () => ({ config: null, known_providers: [] })), +})); + +vi.mock("@/lib/api/domains/office-skills-api", () => ({ + listSkills: vi.fn(), +})); + +vi.mock("@/lib/api/domains/office-tasks-api", () => ({ + listTasks: vi.fn(async () => ({ tasks: [] })), +})); + +function queryFnOf(options: { queryFn?: unknown }) { + if (typeof options.queryFn !== "function") { + throw new Error("queryFn was not a function"); + } + return options.queryFn as (ctx: Record) => Promise; +} + +describe("query option factories", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("maps feature flags to the feature API with the query abort signal", async () => { + const signal = new AbortController().signal; + const options = featureFlagsQueryOptions(); + + await queryFnOf(options)({ signal }); + + expect(fetchFeatureFlags).toHaveBeenCalledWith({ init: { signal } }); + }); + + it("maps workspace task infinite pages to page params", async () => { + const signal = new AbortController().signal; + const options = workspaceTasksInfiniteQueryOptions("workspace-1", { + pageSize: 2, + query: "needle", + }); + + await queryFnOf(options)({ pageParam: 2, signal }); + + expect(listTasksByWorkspace).toHaveBeenCalledWith( + "workspace-1", + { pageSize: 2, query: "needle", page: 2 }, + { init: { signal } }, + ); + expect( + options.getNextPageParam?.( + { tasks: [{ id: "task-2" }], total: 3 } as never, + [ + { tasks: [{ id: "task-1" }], total: 3 }, + { tasks: [{ id: "task-2" }], total: 3 }, + ] as never, + 2, + [1, 2], + ), + ).toBe(3); + }); + + it("maps workflow steps to a stable workflow-scoped key and sorted result", async () => { + vi.mocked(listWorkflowSteps).mockResolvedValueOnce({ + steps: [ + { id: "step-2", name: "Second", workflow_id: "workflow-1", position: 2 }, + { id: "step-1", name: "First", workflow_id: "workflow-1", position: 1 }, + ], + } as never); + const signal = new AbortController().signal; + const options = workflowStepsQueryOptions("workflow-1"); + + const result = await queryFnOf(options)({ signal }); + + expect(options.queryKey).toEqual(qk.workflows.steps("workflow-1")); + expect(listWorkflowSteps).toHaveBeenCalledWith("workflow-1", { init: { signal } }); + expect(result).toEqual([ + expect.objectContaining({ id: "step-1" }), + expect.objectContaining({ id: "step-2" }), + ]); + }); + + it("maps session message cursors through page params", async () => { + const signal = new AbortController().signal; + const options = sessionMessagesInfiniteQueryOptions("session-1", { limit: 10 }); + + await queryFnOf(options)({ pageParam: "message-1", signal }); + + expect(listTaskSessionMessages).toHaveBeenCalledWith( + "session-1", + { limit: 10, before: "message-1" }, + { init: { signal } }, + ); + expect( + options.getNextPageParam?.( + { messages: [], total: 10, has_more: true, cursor: "message-0" } as never, + [] as never, + undefined, + [], + ), + ).toBe("message-0"); + }); + + it("maps latest session messages to the stable session message key in render order", async () => { + vi.mocked(listTaskSessionMessages).mockResolvedValueOnce({ + messages: [ + { id: "new", created_at: "2026-06-23T00:00:02Z" }, + { id: "old", created_at: "2026-06-23T00:00:01Z" }, + ], + total: 2, + has_more: true, + cursor: "old", + } as never); + const signal = new AbortController().signal; + const options = sessionMessagesLatestQueryOptions("session-1", 2); + + const result = await queryFnOf(options)({ signal }); + + expect(options.queryKey).toEqual(qk.session.messages("session-1")); + expect(listTaskSessionMessages).toHaveBeenCalledWith( + "session-1", + { limit: 2, sort: "desc" }, + { init: { signal } }, + ); + expect(result).toMatchObject({ + hasMore: true, + messages: [{ id: "old" }, { id: "new" }], + oldestCursor: "old", + }); + }); + + it("unwraps task session by id responses to match hydrated query shape", async () => { + vi.mocked(fetchTaskSession).mockResolvedValueOnce({ + session: { id: "session-1", task_id: "task-1", state: "RUNNING" }, + } as never); + const signal = new AbortController().signal; + const options = taskSessionQueryOptions("session-1"); + + const result = await queryFnOf(options)({ signal }); + + expect(options.queryKey).toEqual(qk.taskSession.byId("session-1")); + expect(fetchTaskSession).toHaveBeenCalledWith("session-1", { init: { signal } }); + expect(result).toMatchObject({ id: "session-1", task_id: "task-1" }); + }); + + it("maps task sessions, task plans, plan revisions, and queue status to stable keys", async () => { + vi.mocked(listTaskSessions).mockResolvedValueOnce({ sessions: [], total: 0 } as never); + vi.mocked(getTaskPlan).mockResolvedValueOnce({ id: "plan-1", task_id: "task-1" } as never); + vi.mocked(listPlanRevisions).mockResolvedValueOnce([ + { id: "revision-1", task_id: "task-1", revision_number: 1 }, + ] as never); + vi.mocked(getPlanRevision).mockResolvedValueOnce({ + id: "revision-1", + task_id: "task-1", + content: "content", + } as never); + vi.mocked(getQueueStatus).mockResolvedValueOnce({ + entries: [], + count: 0, + max: 10, + }); + const signal = new AbortController().signal; + + expect(taskPlanQueryOptions("task-1").queryKey).toEqual(qk.taskPlan.detail("task-1")); + expect(taskPlanRevisionsQueryOptions("task-1").queryKey).toEqual( + qk.taskPlan.revisions("task-1"), + ); + expect(planRevisionQueryOptions("task-1", "revision-1").queryKey).toEqual( + qk.taskPlan.revision("task-1", "revision-1"), + ); + expect(queueStatusQueryOptions("session-1").queryKey).toEqual(qk.session.queue("session-1")); + + await queryFnOf(taskSessionsQueryOptions("task-1"))({ signal }); + await queryFnOf(taskPlanQueryOptions("task-1"))({ signal }); + await queryFnOf(taskPlanRevisionsQueryOptions("task-1"))({ signal }); + await queryFnOf(planRevisionQueryOptions("task-1", "revision-1"))({ signal }); + await queryFnOf(queueStatusQueryOptions("session-1"))({ signal }); + + expect(listTaskSessions).toHaveBeenCalledWith("task-1", { init: { signal } }); + expect(getTaskPlan).toHaveBeenCalledWith("task-1"); + expect(listPlanRevisions).toHaveBeenCalledWith("task-1"); + expect(getPlanRevision).toHaveBeenCalledWith("revision-1", "task-1"); + expect(getQueueStatus).toHaveBeenCalledWith("session-1"); + }); + + it("maps session runtime snapshot queries to stable keys", async () => { + const { getWebSocketClient } = await import("@/lib/ws/connection"); + const request = vi + .fn() + .mockResolvedValueOnce({ commits: [{ id: "commit-1" }], ready: true }) + .mockResolvedValueOnce({ + shells: [ + { + id: "term-1", + kind: "ordinary", + seq: 1, + display_name: "Terminal 1", + state: "parked", + pty_status: "running", + }, + ], + }); + vi.mocked(getWebSocketClient).mockReturnValue({ request } as never); + + expect(sessionCommitsQueryOptions("env-1", "session-1").queryKey).toEqual( + qk.sessionRuntime.commits("env-1"), + ); + expect(sessionModelsQueryOptions("session-1").queryKey).toEqual( + qk.sessionRuntime.models("session-1"), + ); + expect(userShellsQueryOptions("env-1", "task-1").queryKey).toEqual( + qk.sessionRuntime.userShells("env-1", "task-1"), + ); + + await expect(fetchSessionCommitsSnapshot("session-1")).resolves.toEqual({ + commits: [{ id: "commit-1" }], + ready: true, + }); + await expect(queryFnOf(userShellsQueryOptions("env-1", "task-1"))({})).resolves.toEqual([ + expect.objectContaining({ + terminalId: "term-1", + state: "parked", + ptyStatus: "running", + }), + ]); + expect(request).toHaveBeenNthCalledWith(1, "session.git.commits", { + session_id: "session-1", + }); + expect(request).toHaveBeenNthCalledWith( + 2, + "user_shell.list", + { + task_environment_id: "env-1", + include_parked: true, + task_id: "task-1", + }, + 10000, + ); + }); + + it("maps office task cursors through page params", async () => { + const signal = new AbortController().signal; + const options = officeTasksInfiniteQueryOptions("workspace-1", { + status: ["todo"], + limit: 2, + }); + + await queryFnOf(options)({ pageParam: { cursor: "cursor-1", cursor_id: "task-1" }, signal }); + + expect(listOfficeTasks).toHaveBeenCalledWith( + "workspace-1", + expect.objectContaining({ + status: ["todo"], + sort: "updated_at", + order: "desc", + limit: 2, + cursor: "cursor-1", + cursor_id: "task-1", + }), + { init: { signal } }, + ); + expect( + options.getNextPageParam?.( + { tasks: [], next_cursor: "cursor-2", next_id: "task-2" } as never, + [] as never, + undefined, + [], + ), + ).toEqual({ cursor: "cursor-2", cursor_id: "task-2" }); + }); + + it("keeps multi-select office assignee and project filters out of unsupported request params", async () => { + const signal = new AbortController().signal; + const options = officeTasksInfiniteQueryOptions("workspace-1", { + assignee: ["agent-1", "agent-2"], + project: ["project-1", "project-2"], + sort: null, + order: null, + }); + + await queryFnOf(options)({ pageParam: undefined, signal }); + + expect(listOfficeTasks).toHaveBeenCalledWith( + "workspace-1", + expect.not.objectContaining({ + assignee: expect.anything(), + project: expect.anything(), + sort: expect.anything(), + order: expect.anything(), + }), + { init: { signal } }, + ); + }); + + it("maps office routing queries through the routing API", async () => { + const signal = new AbortController().signal; + const options = officeRoutingQueryOptions("workspace-1"); + + await queryFnOf(options)({ signal }); + + expect(getWorkspaceRouting).toHaveBeenCalledWith("workspace-1", { init: { signal } }); + }); + + it("maps office task comments through the office API", async () => { + const signal = new AbortController().signal; + const options = officeTaskCommentsQueryOptions("task-1"); + const { listComments } = await import("@/lib/api/domains/office-api"); + + await queryFnOf(options)({ signal }); + + expect(listComments).toHaveBeenCalledWith("task-1", { init: { signal } }); + }); + + it("normalizes office task search queries before calling the API", async () => { + const signal = new AbortController().signal; + const options = officeTaskSearchQueryOptions("workspace-1", " needle ", 10); + const { searchTasks } = await import("@/lib/api/domains/office-api"); + + await queryFnOf(options)({ signal }); + + expect(options.queryKey).toEqual(qk.office.taskSearch("workspace-1", "needle", 10)); + expect(searchTasks).toHaveBeenCalledWith("workspace-1", "needle", 10, { + init: { signal }, + }); + }); +}); diff --git a/apps/web/lib/query/query-options/sentry.ts b/apps/web/lib/query/query-options/sentry.ts new file mode 100644 index 0000000000..fec914ebb8 --- /dev/null +++ b/apps/web/lib/query/query-options/sentry.ts @@ -0,0 +1,23 @@ +import { queryOptions } from "@tanstack/react-query"; +import { listSentryInstances, listSentryIssueWatches } from "@/lib/api/domains/sentry-api"; +import { INTEGRATION_STATUS_REFRESH_MS } from "@/hooks/domains/integrations/use-integration-availability"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export function sentryInstancesQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.sentry.instances(workspaceId), + queryFn: ({ signal }) => + workspaceId ? listSentryInstances(workspaceId, withSignal(signal)) : Promise.resolve([]), + enabled: workspaceId !== null, + refetchInterval: INTEGRATION_STATUS_REFRESH_MS, + }); +} + +export function sentryIssueWatchesQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.sentry.issueWatches(workspaceId), + queryFn: ({ signal }) => listSentryIssueWatches(workspaceId ?? undefined, withSignal(signal)), + enabled: workspaceId !== null, + }); +} diff --git a/apps/web/lib/query/query-options/session-runtime.ts b/apps/web/lib/query/query-options/session-runtime.ts new file mode 100644 index 0000000000..977b0c9bac --- /dev/null +++ b/apps/web/lib/query/query-options/session-runtime.ts @@ -0,0 +1,202 @@ +import { queryOptions } from "@tanstack/react-query"; +import type { SessionAgentctlStatus, Worktree } from "@/lib/state/slices/session/types"; +import type { + AgentCapabilitiesEntry, + AvailableCommand, + ContextWindowEntry, + GitStatusEntry, + ProcessStatusEntry, + PromptUsageEntry, + SessionCommit, + SessionModeEntry, + SessionModelEntry, + ConfigOptionEntry, + SessionPollMode, + SessionPrepareState, + TodoEntry, + UserShellInfo, +} from "@/lib/state/slices/session-runtime/types"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import { qk } from "../keys"; + +export type GitStatusQueryData = { + latest?: GitStatusEntry; + byRepo: Record; +}; + +export type SessionModelsQueryData = { + currentModelId: string; + models: SessionModelEntry[]; + configOptions: ConfigOptionEntry[]; +}; + +export type SessionModeQueryData = { + currentModeId: string; + availableModes: SessionModeEntry[]; +}; + +export type SessionProcessesQueryData = { + processesById: Record; + processIds: string[]; + activeProcessId?: string; + devProcessId?: string; +}; + +export type SessionCommitsResponse = { + commits: SessionCommit[]; + ready: boolean; +}; + +function passiveQueryOptions(queryKey: readonly unknown[], enabled = true) { + return queryOptions({ + queryKey, + queryFn: async () => null, + enabled: false, + staleTime: Infinity, + gcTime: 10 * 60_000, + meta: { passive: enabled }, + }); +} + +export function gitStatusQueryOptions(environmentId: string) { + return passiveQueryOptions(qk.sessionRuntime.gitStatus(environmentId)); +} + +export async function fetchSessionCommitsSnapshot( + sessionId: string, +): Promise { + const client = getWebSocketClient(); + if (!client) return { commits: [], ready: false }; + const response = await client.request<{ commits?: SessionCommit[]; ready?: boolean }>( + "session.git.commits", + { session_id: sessionId }, + ); + return { commits: response.commits ?? [], ready: response.ready !== false }; +} + +export function sessionCommitsQueryOptions(environmentId: string, sessionId: string) { + return queryOptions({ + queryKey: qk.sessionRuntime.commits(environmentId), + queryFn: async () => { + const response = await fetchSessionCommitsSnapshot(sessionId); + if (!response.ready) { + throw new Error("session commits are not ready"); + } + return response.commits; + }, + enabled: Boolean(environmentId && sessionId), + staleTime: 60_000, + }); +} + +export function prepareProgressQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.prepare(sessionId)); +} + +export function contextWindowQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.contextWindow(sessionId)); +} + +export function availableCommandsQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.availableCommands(sessionId)); +} + +export function sessionModeQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.mode(sessionId)); +} + +export function agentCapabilitiesQueryOptions(sessionId: string) { + return passiveQueryOptions( + qk.sessionRuntime.agentCapabilities(sessionId), + ); +} + +export function sessionModelsQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.models(sessionId)); +} + +export function promptUsageQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.promptUsage(sessionId)); +} + +export function sessionTodosQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.todos(sessionId)); +} + +export function sessionPollModeQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.pollMode(sessionId)); +} + +export function sessionAgentctlQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.agentctl(sessionId)); +} + +export function sessionWorktreesQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.worktrees(sessionId)); +} + +export function sessionProcessesQueryOptions(sessionId: string) { + return passiveQueryOptions(qk.sessionRuntime.processes(sessionId)); +} + +export async function fetchUserShellsSnapshot( + environmentId: string, + taskId?: string | null, +): Promise { + const client = getWebSocketClient(); + if (!client) return []; + const payload: Record = { + task_environment_id: environmentId, + include_parked: true, + }; + if (taskId) payload.task_id = taskId; + const response = await client.request<{ shells?: UserShellListItem[] }>( + "user_shell.list", + payload, + 10000, + ); + return (response.shells ?? []).map(mapTerminalInfoToUserShell); +} + +export function userShellsQueryOptions(environmentId: string, taskId?: string | null) { + return queryOptions({ + queryKey: qk.sessionRuntime.userShells(environmentId, taskId), + queryFn: () => fetchUserShellsSnapshot(environmentId, taskId), + enabled: Boolean(environmentId), + staleTime: 30_000, + }); +} + +type UserShellListItem = { + id?: string; + terminal_id?: string; + kind?: UserShellInfo["kind"]; + seq?: number; + display_name?: string; + custom_name?: string | null; + state?: UserShellInfo["state"]; + pty_status?: UserShellInfo["ptyStatus"]; + label?: string; + closable?: boolean; + initial_command?: string; + process_id?: string; + running?: boolean; +}; + +function mapTerminalInfoToUserShell(item: UserShellListItem): UserShellInfo { + const terminalId = item.id ?? item.terminal_id ?? ""; + return { + terminalId, + kind: item.kind, + seq: item.seq, + customName: item.custom_name ?? null, + displayName: item.display_name, + state: item.state, + ptyStatus: item.pty_status, + processId: item.process_id, + running: item.running ?? item.pty_status === "running", + label: item.label || item.display_name || "Terminal", + closable: item.closable ?? true, + initialCommand: item.initial_command, + }; +} diff --git a/apps/web/lib/query/query-options/session.ts b/apps/web/lib/query/query-options/session.ts new file mode 100644 index 0000000000..50b5df048e --- /dev/null +++ b/apps/web/lib/query/query-options/session.ts @@ -0,0 +1,157 @@ +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; +import { getPlanRevision, getTaskPlan, listPlanRevisions } from "@/lib/api/domains/plan-api"; +import { getQueueStatus } from "@/lib/api/domains/queue-api"; +import { getTaskWalkthrough } from "@/lib/api/domains/walkthrough-api"; +import { + fetchTaskSession, + listSessionTurns, + listTaskSessionMessages, + listTaskSessions, + searchSessionMessages, +} from "@/lib/api/domains/session-api"; +import type { ListTurnsResponse, Message } from "@/lib/types/http"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export type SessionMessagesLatestData = { + messages: Message[]; + hasMore: boolean; + oldestCursor: string | null; +}; + +export type SessionTurnsData = ListTurnsResponse & { + activeTurnId: string | null; +}; + +export function taskSessionsQueryOptions(taskId: string) { + return queryOptions({ + queryKey: qk.taskSession.byTask(taskId), + queryFn: ({ signal }) => listTaskSessions(taskId, withSignal(signal)), + enabled: Boolean(taskId), + }); +} + +export function taskSessionQueryOptions(sessionId: string) { + return queryOptions({ + queryKey: qk.taskSession.byId(sessionId), + queryFn: async ({ signal }) => { + const response = await fetchTaskSession(sessionId, withSignal(signal)); + return response.session; + }, + enabled: Boolean(sessionId), + }); +} + +export function taskPlanQueryOptions(taskId: string, enabled = true) { + return queryOptions({ + queryKey: qk.taskPlan.detail(taskId), + queryFn: () => getTaskPlan(taskId), + enabled: Boolean(taskId && enabled), + staleTime: 60_000, + }); +} + +export function taskPlanRevisionsQueryOptions(taskId: string, enabled = true) { + return queryOptions({ + queryKey: qk.taskPlan.revisions(taskId), + queryFn: () => listPlanRevisions(taskId), + enabled: Boolean(taskId && enabled), + staleTime: 60_000, + }); +} + +export function planRevisionQueryOptions(taskId: string, revisionId: string, enabled = true) { + return queryOptions({ + queryKey: qk.taskPlan.revision(taskId, revisionId), + queryFn: () => getPlanRevision(revisionId, taskId), + enabled: Boolean(taskId && revisionId && enabled), + staleTime: 60_000, + }); +} + +export function queueStatusQueryOptions(sessionId: string, enabled = true) { + return queryOptions({ + queryKey: qk.session.queue(sessionId), + queryFn: () => getQueueStatus(sessionId), + enabled: Boolean(sessionId && enabled), + staleTime: 30_000, + }); +} + +export function taskWalkthroughQueryOptions(taskId: string | null | undefined, enabled = true) { + return queryOptions({ + queryKey: qk.taskWalkthrough.detail(taskId ?? ""), + queryFn: () => (taskId ? getTaskWalkthrough(taskId) : Promise.resolve(null)), + enabled: Boolean(taskId && enabled), + staleTime: 60_000, + }); +} + +export function sessionMessagesLatestQueryOptions(sessionId: string, limit = 100) { + return queryOptions({ + queryKey: qk.session.messages(sessionId), + queryFn: async ({ signal }): Promise => { + const response = await listTaskSessionMessages( + sessionId, + { limit, sort: "desc" }, + withSignal(signal), + ); + const messages = [...(response.messages ?? [])].reverse(); + return { + messages, + hasMore: response.has_more ?? false, + oldestCursor: messages[0]?.id ?? response.cursor ?? null, + }; + }, + enabled: Boolean(sessionId), + staleTime: 60_000, + }); +} + +export function sessionMessagesQueryOptions( + sessionId: string, + params: { limit?: number; before?: string; after?: string; sort?: "asc" | "desc" } = {}, +) { + return queryOptions({ + queryKey: qk.session.messagesPage(sessionId, params), + queryFn: ({ signal }) => listTaskSessionMessages(sessionId, params, withSignal(signal)), + enabled: Boolean(sessionId), + staleTime: 60_000, + }); +} + +export function sessionMessagesInfiniteQueryOptions( + sessionId: string, + params: { limit?: number; sort?: "asc" | "desc" } = {}, +) { + return infiniteQueryOptions({ + queryKey: qk.session.messagesInfinite(sessionId, params), + initialPageParam: undefined as string | undefined, + queryFn: ({ pageParam, signal }) => + listTaskSessionMessages(sessionId, { ...params, before: pageParam }, withSignal(signal)), + getNextPageParam: (lastPage) => + lastPage.has_more ? lastPage.cursor || lastPage.messages[0]?.id : undefined, + enabled: Boolean(sessionId), + staleTime: 60_000, + }); +} + +export function sessionTurnsQueryOptions(sessionId: string) { + return queryOptions({ + queryKey: qk.session.turns(sessionId), + queryFn: async ({ signal }): Promise => { + const response = await listSessionTurns(sessionId, withSignal(signal)); + return { ...response, activeTurnId: null }; + }, + enabled: Boolean(sessionId), + staleTime: 60_000, + }); +} + +export function sessionSearchQueryOptions(sessionId: string, query: string, limit = 50) { + return queryOptions({ + queryKey: qk.session.search(sessionId, query, limit), + queryFn: () => searchSessionMessages(sessionId, query, limit), + enabled: Boolean(sessionId && query.trim()), + }); +} diff --git a/apps/web/lib/query/query-options/settings.ts b/apps/web/lib/query/query-options/settings.ts new file mode 100644 index 0000000000..0bea2cb6d6 --- /dev/null +++ b/apps/web/lib/query/query-options/settings.ts @@ -0,0 +1,200 @@ +import { queryOptions } from "@tanstack/react-query"; +import { fetchSystemHealth } from "@/lib/api/domains/health-api"; +import { fetchRuntimeFlags } from "@/lib/api/domains/runtime-flags-api"; +import { listSecrets } from "@/lib/api/domains/secrets-api"; +import { getSpritesStatus, listSpritesInstances } from "@/lib/api/domains/sprites-api"; +import { + fetchDefaultScripts, + fetchDynamicModels, + fetchExecutor, + fetchSystemMetricsSettings, + fetchUserSettings, + getAgentProfileMcpConfig, + getInstallJob, + listAgentDiscovery, + listAgents, + listAllExecutorProfiles, + listAvailableAgents, + listEditors, + listExecutorProfiles, + listExecutors, + listInstallJobs, + listNotificationProviders, + listPrompts, + listScriptPlaceholders, +} from "@/lib/api/domains/settings-api"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export function userSettingsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.user(), + queryFn: ({ signal }) => fetchUserSettings(withSignal(signal)), + }); +} + +export function systemMetricsSettingsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.systemMetrics(), + queryFn: ({ signal }) => fetchSystemMetricsSettings(withSignal(signal)), + }); +} + +export function executorsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.executors(), + queryFn: ({ signal }) => listExecutors(withSignal(signal)), + }); +} + +export function executorQueryOptions(executorId: string) { + return queryOptions({ + queryKey: qk.settings.executor(executorId), + queryFn: ({ signal }) => fetchExecutor(executorId, withSignal(signal)), + enabled: Boolean(executorId), + }); +} + +export function executorProfilesQueryOptions(executorId: string) { + return queryOptions({ + queryKey: qk.settings.executorProfiles(executorId), + queryFn: ({ signal }) => listExecutorProfiles(executorId, withSignal(signal)), + enabled: Boolean(executorId), + }); +} + +export function allExecutorProfilesQueryOptions() { + return queryOptions({ + queryKey: qk.settings.allExecutorProfiles(), + queryFn: ({ signal }) => listAllExecutorProfiles(withSignal(signal)), + }); +} + +export function scriptPlaceholdersQueryOptions() { + return queryOptions({ + queryKey: qk.settings.scriptPlaceholders(), + queryFn: ({ signal }) => listScriptPlaceholders(withSignal(signal)), + }); +} + +export function defaultScriptsQueryOptions(executorType: string) { + return queryOptions({ + queryKey: qk.settings.defaultScripts(executorType), + queryFn: ({ signal }) => fetchDefaultScripts(executorType, withSignal(signal)), + enabled: Boolean(executorType), + }); +} + +export function agentsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.agents(), + queryFn: ({ signal }) => listAgents(withSignal(signal)), + }); +} + +export function agentDiscoveryQueryOptions() { + return queryOptions({ + queryKey: qk.settings.agentDiscovery(), + queryFn: ({ signal }) => listAgentDiscovery(withSignal(signal)), + }); +} + +export function availableAgentsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.availableAgents(), + queryFn: ({ signal }) => listAvailableAgents(withSignal(signal)), + }); +} + +export function agentProfileMcpConfigQueryOptions(profileId: string) { + return queryOptions({ + queryKey: qk.settings.agentMcpConfig(profileId), + queryFn: ({ signal }) => getAgentProfileMcpConfig(profileId, withSignal(signal)), + enabled: Boolean(profileId), + }); +} + +export function installJobsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.installJobs(), + queryFn: ({ signal }) => listInstallJobs(withSignal(signal)), + }); +} + +export function installJobQueryOptions(jobId: string) { + return queryOptions({ + queryKey: qk.settings.installJob(jobId), + queryFn: ({ signal }) => getInstallJob(jobId, withSignal(signal)), + enabled: Boolean(jobId), + }); +} + +export function dynamicModelsQueryOptions(agentName: string, params?: { refresh?: boolean }) { + return queryOptions({ + queryKey: qk.settings.dynamicModels(agentName), + queryFn: ({ signal }) => + fetchDynamicModels(agentName, { ...withSignal(signal), refresh: params?.refresh }), + enabled: Boolean(agentName), + }); +} + +export function editorsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.editors(), + queryFn: ({ signal }) => listEditors(withSignal(signal)), + }); +} + +export function promptsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.prompts(), + queryFn: ({ signal }) => listPrompts(withSignal(signal)), + }); +} + +export function notificationProvidersQueryOptions() { + return queryOptions({ + queryKey: qk.settings.notificationProviders(), + queryFn: ({ signal }) => listNotificationProviders(withSignal(signal)), + }); +} + +export function secretsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.secrets(), + queryFn: ({ signal }) => listSecrets(withSignal(signal)), + }); +} + +export function spritesStatusQueryOptions(secretId?: string | null) { + return queryOptions({ + queryKey: qk.settings.spritesStatus(secretId), + queryFn: ({ signal }) => getSpritesStatus(secretId ?? undefined, withSignal(signal)), + enabled: secretId !== null, + }); +} + +export function spritesInstancesQueryOptions(secretId?: string | null) { + return queryOptions({ + queryKey: qk.settings.spritesInstances(secretId), + queryFn: ({ signal }) => listSpritesInstances(secretId ?? undefined, withSignal(signal)), + enabled: secretId !== null, + }); +} + +export function systemHealthQueryOptions() { + return queryOptions({ + queryKey: qk.settings.systemHealth(), + queryFn: ({ signal }) => fetchSystemHealth(withSignal(signal)), + refetchInterval: 5 * 60 * 1000, + refetchOnWindowFocus: true, + }); +} + +export function runtimeFlagsQueryOptions() { + return queryOptions({ + queryKey: qk.settings.runtimeFlags(), + queryFn: () => fetchRuntimeFlags(), + staleTime: 30_000, + }); +} diff --git a/apps/web/lib/query/query-options/slack.ts b/apps/web/lib/query/query-options/slack.ts new file mode 100644 index 0000000000..b2f057798e --- /dev/null +++ b/apps/web/lib/query/query-options/slack.ts @@ -0,0 +1,14 @@ +import { queryOptions } from "@tanstack/react-query"; +import { getSlackConfig } from "@/lib/api/domains/slack-api"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export function slackConfigQueryOptions(workspaceId?: string | null) { + return queryOptions({ + queryKey: qk.integrations.slack.config(workspaceId), + queryFn: ({ signal }) => + getSlackConfig({ ...withSignal(signal), ...(workspaceId ? { workspaceId } : {}) }), + enabled: workspaceId !== null, + refetchInterval: 90_000, + }); +} diff --git a/apps/web/lib/query/query-options/system.ts b/apps/web/lib/query/query-options/system.ts new file mode 100644 index 0000000000..f228df7ed1 --- /dev/null +++ b/apps/web/lib/query/query-options/system.ts @@ -0,0 +1,95 @@ +import { queryOptions } from "@tanstack/react-query"; +import { + fetchBackups, + fetchDatabaseStats, + fetchDiskUsage, + fetchLogFiles, + fetchLogTail, + fetchRestartCapability, + fetchSystemInfo, + fetchSystemJob, + fetchUpdates, +} from "@/lib/api/domains/system-api"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; +import type { SystemJob, SystemMetricsSnapshot } from "@/lib/types/system"; + +export function systemInfoQueryOptions() { + return queryOptions({ + queryKey: qk.system.info(), + queryFn: ({ signal }) => fetchSystemInfo(withSignal(signal)), + }); +} + +export function diskUsageQueryOptions() { + return queryOptions({ + queryKey: qk.system.diskUsage(), + queryFn: ({ signal }) => fetchDiskUsage(withSignal(signal)), + }); +} + +export function databaseStatsQueryOptions() { + return queryOptions({ + queryKey: qk.system.database(), + queryFn: ({ signal }) => fetchDatabaseStats(withSignal(signal)), + }); +} + +export function backupsQueryOptions() { + return queryOptions({ + queryKey: qk.system.backups(), + queryFn: ({ signal }) => fetchBackups(withSignal(signal)), + }); +} + +export function logFilesQueryOptions() { + return queryOptions({ + queryKey: qk.system.logFiles(), + queryFn: ({ signal }) => fetchLogFiles(withSignal(signal)), + }); +} + +export function logTailQueryOptions(n = 1000) { + return queryOptions({ + queryKey: qk.system.logTail(n), + queryFn: ({ signal }) => fetchLogTail(n, withSignal(signal)), + }); +} + +export function systemJobsQueryOptions() { + return queryOptions({ + queryKey: qk.system.jobs(), + queryFn: async (): Promise> => ({}), + enabled: false, + }); +} + +export function systemJobQueryOptions(jobId: string) { + return queryOptions({ + queryKey: qk.system.job(jobId), + queryFn: ({ signal }) => fetchSystemJob(jobId, withSignal(signal)), + enabled: Boolean(jobId), + }); +} + +export function systemMetricsQueryOptions() { + return queryOptions({ + queryKey: qk.system.metrics(), + queryFn: async (): Promise => null, + enabled: false, + }); +} + +export function updatesQueryOptions() { + return queryOptions({ + queryKey: qk.system.updates(), + queryFn: ({ signal }) => fetchUpdates(withSignal(signal)), + }); +} + +export function restartCapabilityQueryOptions() { + return queryOptions({ + queryKey: qk.system.restartCapability(), + queryFn: ({ signal }) => fetchRestartCapability(withSignal(signal)), + }); +} diff --git a/apps/web/lib/query/query-options/utils.ts b/apps/web/lib/query/query-options/utils.ts new file mode 100644 index 0000000000..57d49b9363 --- /dev/null +++ b/apps/web/lib/query/query-options/utils.ts @@ -0,0 +1,5 @@ +import type { ApiRequestOptions } from "@/lib/api/client"; + +export function withSignal(signal: AbortSignal): ApiRequestOptions { + return { init: { signal } }; +} diff --git a/apps/web/lib/query/query-options/workspace.ts b/apps/web/lib/query/query-options/workspace.ts new file mode 100644 index 0000000000..576f2d2616 --- /dev/null +++ b/apps/web/lib/query/query-options/workspace.ts @@ -0,0 +1,82 @@ +import { queryOptions } from "@tanstack/react-query"; +import { + listBranches, + listQuickChatSessions, + listRepositories, + listRepositoryBranches, + listRepositoryScripts, + listWorkspaces, +} from "@/lib/api/domains/workspace-api"; +import { qk } from "../keys"; +import { withSignal } from "./utils"; + +export function workspacesQueryOptions() { + return queryOptions({ + queryKey: qk.workspaces.all(), + queryFn: async ({ signal }) => { + const response = await listWorkspaces(withSignal(signal)); + return response.workspaces; + }, + }); +} + +export function workspaceRepositoriesQueryOptions( + workspaceId: string, + params?: { includeScripts?: boolean }, +) { + return queryOptions({ + queryKey: qk.workspaces.repositories(workspaceId, params), + queryFn: async ({ signal }) => { + const response = await listRepositories(workspaceId, params, withSignal(signal)); + return response.repositories; + }, + enabled: Boolean(workspaceId), + }); +} + +export function workspaceBranchesQueryOptions( + workspaceId: string, + source: { repositoryId: string } | { path: string }, +) { + return queryOptions({ + queryKey: qk.workspaces.branches(workspaceId, source), + queryFn: async ({ signal }) => { + const response = await listBranches(workspaceId, source, withSignal(signal)); + return response.branches; + }, + enabled: Boolean(workspaceId), + }); +} + +export function repositoryBranchesQueryOptions( + repositoryId: string, + params?: { refresh?: boolean }, +) { + return queryOptions({ + queryKey: qk.workspaces.repositoryBranches(repositoryId), + queryFn: async ({ signal }) => { + const response = await listRepositoryBranches(repositoryId, params, withSignal(signal)); + return response.branches; + }, + enabled: Boolean(repositoryId), + }); +} + +export function repositoryScriptsQueryOptions(repositoryId: string) { + return queryOptions({ + queryKey: qk.workspaces.repositoryScripts(repositoryId), + queryFn: async ({ signal }) => { + const response = await listRepositoryScripts(repositoryId, withSignal(signal)); + return response.scripts ?? []; + }, + enabled: Boolean(repositoryId), + }); +} + +export function quickChatSessionsQueryOptions(workspaceId: string) { + return queryOptions({ + queryKey: qk.workspaces.quickChatSessions(workspaceId), + queryFn: ({ signal }) => listQuickChatSessions(workspaceId, withSignal(signal)), + enabled: Boolean(workspaceId), + }); +} diff --git a/apps/web/lib/query/seed.test.ts b/apps/web/lib/query/seed.test.ts new file mode 100644 index 0000000000..6fc81fcda4 --- /dev/null +++ b/apps/web/lib/query/seed.test.ts @@ -0,0 +1,545 @@ +/* eslint-disable max-lines-per-function */ +import { describe, expect, it } from "vitest"; +import type { BootPayload } from "@/src/boot-payload"; +import type { AgentProfile, Skill } from "@/lib/state/slices/office/types"; +import { + repositoryId as toRepositoryId, + sessionId as toSessionId, + taskId as toTaskId, + workflowId as toWorkflowId, + workspaceId as toWorkspaceId, + type Message, + type Repository, + type RepositoryScript, + type Task, + type TaskSession, + type Turn, + type Workflow, + type WorkflowSnapshot, +} from "@/lib/types/http"; +import { makeQueryClient } from "./client"; +import { qk } from "./keys"; +import { + seedQueryClientFromBootPayload, + seedQueryClientFromInitialState, + type QuerySeedInitialState, +} from "./seed"; + +const WORKSPACE_ID = "workspace-1"; +const WORKFLOW_ID = "workflow-1"; +const REVIEW_WORKFLOW_ID = "workflow-2"; +const STEP_ID = "step-1"; +const REVIEW_STEP_ID = "step-2"; + +describe("seedQueryClientFromBootPayload", () => { + it("seeds generic boot state and feature flags", () => { + const client = makeQueryClient(); + const payload = { + initialState: { + features: { office: true }, + }, + } satisfies BootPayload; + + seedQueryClientFromBootPayload(client, payload); + + expect(client.getQueryData(qk.boot.initialState())).toEqual(payload.initialState); + expect(client.getQueryData(qk.features())).toEqual({ office: true }); + }); + + it("seeds task detail route data into task, session, messages, and turns keys", () => { + const client = makeQueryClient(); + const task = { id: "task-1", title: "Task", workspace_id: WORKSPACE_ID } as Task; + const session = { + id: "session-1", + task_id: "task-1", + repository_id: "repo-1", + worktree_id: "worktree-1", + worktree_path: "/tmp/kandev/worktrees/worktree-1", + worktree_branch: "feature/session", + } as TaskSession; + const message = { id: "message-1", session_id: "session-1", content: "hello" } as Message; + const turn = { + id: "turn-1", + session_id: "session-1", + completed_at: null, + } as unknown as Turn; + const payload = { + routeData: { + taskDetail: { + task, + sessionId: "session-1", + initialTerminals: [], + initialState: { + messages: { + bySession: { "session-1": [message] }, + metaBySession: { + "session-1": { hasMore: true, oldestCursor: "message-1", isLoading: false }, + }, + }, + taskSessions: { items: { "session-1": session } }, + taskSessionsByTask: { + itemsByTaskId: { "task-1": [session] }, + loadingByTaskId: { "task-1": false }, + loadedByTaskId: { "task-1": true }, + }, + turns: { + bySession: { "session-1": [turn] }, + activeBySession: { "session-1": "turn-1" }, + }, + }, + }, + }, + } satisfies BootPayload; + + seedQueryClientFromBootPayload(client, payload); + + expect(client.getQueryData(qk.tasks.detail("task-1"))).toEqual(task); + expect(client.getQueryData(qk.taskSession.byTask("task-1"))).toEqual({ + sessions: [session], + }); + expect(client.getQueryData(qk.taskSession.byId("session-1"))).toEqual(session); + expect(client.getQueryData(qk.session.messages("session-1"))).toEqual({ + messages: [message], + hasMore: true, + oldestCursor: "message-1", + }); + expect(client.getQueryData(qk.session.turns("session-1"))).toEqual({ + turns: [turn], + activeTurnId: "turn-1", + }); + expect(client.getQueryData(qk.sessionRuntime.worktrees("session-1"))).toEqual([ + { + id: "worktree-1", + sessionId: "session-1", + repositoryId: "repo-1", + path: "/tmp/kandev/worktrees/worktree-1", + branch: "feature/session", + }, + ]); + }); +}); + +describe("seedQueryClientFromInitialState", () => { + it("seeds settings server-state into query keys", () => { + const client = makeQueryClient(); + const executor = { id: "executor-1", name: "Docker" }; + const agent = { + id: "agent-1", + name: "codex", + profiles: [{ id: "profile-1", agentDisplayName: "Codex", name: "Default" }], + }; + const profile = { + id: "profile-1", + label: "Codex / Default", + agent_id: "agent-1", + agent_name: "codex", + cli_passthrough: false, + }; + const discoveryAgent = { name: "codex", display_name: "Codex" }; + const availableAgent = { name: "codex", display_name: "Codex", available: true }; + const tool = { name: "codex", installed: true }; + const editor = { id: "editor-1", name: "VS Code" }; + const prompt = { id: "prompt-1", name: "Review", content: "Check carefully." }; + const secret = { id: "secret-1", name: "TOKEN" }; + const spritesStatus = { configured: true, instance_count: 1 }; + const spritesInstance = { name: "sandbox-1" }; + const provider = { id: "provider-1", name: "Apprise" }; + + const initialState = { + executors: { items: [executor] }, + settingsAgents: { items: [agent] }, + agentProfiles: { items: [profile], version: 0 }, + agentDiscovery: { + items: [discoveryAgent], + loading: false, + loaded: true, + }, + availableAgents: { + items: [availableAgent], + tools: [tool], + loading: false, + loaded: true, + }, + editors: { items: [editor], loading: false, loaded: true }, + prompts: { items: [prompt], loading: false, loaded: true }, + secrets: { items: [secret], loading: false, loaded: true }, + sprites: { + status: spritesStatus, + instances: [spritesInstance], + loading: false, + loaded: true, + }, + notificationProviders: { + items: [provider], + events: ["task.created"], + appriseAvailable: true, + loading: false, + loaded: true, + }, + } as unknown as QuerySeedInitialState; + + seedQueryClientFromInitialState(client, initialState); + + expect(client.getQueryData(qk.settings.executors())).toEqual({ executors: [executor] }); + expect(client.getQueryData(qk.settings.agents())).toEqual({ + agents: [agent], + total: 1, + }); + expect(client.getQueryData(qk.settings.agentDiscovery())).toEqual({ + agents: [discoveryAgent], + total: 1, + }); + expect(client.getQueryData(qk.settings.availableAgents())).toEqual({ + agents: [availableAgent], + tools: [tool], + total: 1, + }); + expect(client.getQueryData(qk.settings.editors())).toEqual({ editors: [editor] }); + expect(client.getQueryData(qk.settings.prompts())).toEqual({ prompts: [prompt] }); + expect(client.getQueryData(qk.settings.secrets())).toEqual([secret]); + expect(client.getQueryData(qk.settings.spritesStatus())).toEqual(spritesStatus); + expect(client.getQueryData(qk.settings.spritesInstances())).toEqual([spritesInstance]); + expect(client.getQueryData(qk.settings.notificationProviders())).toEqual({ + providers: [provider], + events: ["task.created"], + apprise_available: true, + }); + }); + + it("can seed route-transition state from StateHydrator inputs", () => { + const client = makeQueryClient(); + const message = { + id: "message-2", + session_id: "session-2", + content: "from route", + } as Message; + + seedQueryClientFromInitialState( + client, + { + messages: { + bySession: { "session-2": [message] }, + metaBySession: { + "session-2": { hasMore: false, oldestCursor: "message-2", isLoading: false }, + }, + }, + }, + { sessionId: "session-2" }, + ); + + expect(client.getQueryData(qk.session.messages("session-2"))).toEqual({ + messages: [message], + hasMore: false, + oldestCursor: "message-2", + }); + }); + + it("seeds workspace repositories into the workspace repositories query cache", () => { + const client = makeQueryClient(); + const repository = { + id: "repo-1", + workspace_id: WORKSPACE_ID, + name: "frontend", + local_path: "/workspace/frontend", + } as Repository; + + seedQueryClientFromInitialState(client, { + repositories: { + itemsByWorkspaceId: { + [WORKSPACE_ID]: [repository], + }, + }, + }); + + expect(client.getQueryData(qk.workspaces.repositories(WORKSPACE_ID))).toEqual([repository]); + }); + + it("seeds workspace workflows into the visible workflow query cache", () => { + const client = makeQueryClient(); + const workflow = { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 10, + hidden: false, + } as Workflow; + + seedQueryClientFromInitialState(client, { + workflows: { + items: [workflow], + activeId: WORKFLOW_ID, + }, + }); + + expect(client.getQueryData(qk.workflows.all(WORKSPACE_ID))).toEqual([workflow]); + expect( + client.getQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true })), + ).toBeUndefined(); + }); + + it("seeds workflow lists into the visible workflow query cache by default", () => { + const client = makeQueryClient(); + const workflow = { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 10, + hidden: false, + } as Workflow; + + seedQueryClientFromInitialState(client, { + workflowLists: { + itemsByWorkspaceId: { + [WORKSPACE_ID]: [workflow], + }, + }, + }); + + expect(client.getQueryData(qk.workflows.all(WORKSPACE_ID))).toEqual([workflow]); + expect( + client.getQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true })), + ).toBeUndefined(); + }); + + it("seeds explicitly hidden-inclusive workflow lists under the hidden-inclusive key", () => { + const client = makeQueryClient(); + const workflow = { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 10, + hidden: true, + } as Workflow; + + seedQueryClientFromInitialState(client, { + workflowLists: { + itemsByWorkspaceId: { + [WORKSPACE_ID]: [workflow], + }, + includeHiddenByWorkspaceId: { + [WORKSPACE_ID]: true, + }, + }, + }); + + expect(client.getQueryData(qk.workflows.all(WORKSPACE_ID, { includeHidden: true }))).toEqual([ + workflow, + ]); + expect(client.getQueryData(qk.workflows.all(WORKSPACE_ID))).toBeUndefined(); + }); + + it("does not seed an empty office skills placeholder as fresh query data", () => { + const client = makeQueryClient(); + + seedQueryClientFromInitialState(client, { + workspaces: { activeId: WORKSPACE_ID, items: [] }, + office: { skills: [] }, + } as unknown as QuerySeedInitialState); + + expect(client.getQueryData(qk.office.skills(WORKSPACE_ID))).toBeUndefined(); + }); + + it("seeds non-empty office skills from route state", () => { + const client = makeQueryClient(); + const skill = { id: "skill-1", slug: "kandev-protocol", name: "Kandev Protocol" } as Skill; + + seedQueryClientFromInitialState(client, { + workspaces: { activeId: WORKSPACE_ID, items: [] }, + office: { skills: [skill] }, + } as unknown as QuerySeedInitialState); + + expect(client.getQueryData(qk.office.skills(WORKSPACE_ID))).toEqual({ skills: [skill] }); + }); + + it("seeds office agents from the boot agentProfiles field", () => { + const client = makeQueryClient(); + const profile = { + id: "agent-profile-1", + workspace_id: WORKSPACE_ID, + name: "Planner", + } as unknown as AgentProfile; + + seedQueryClientFromInitialState(client, { + workspaces: { activeId: WORKSPACE_ID, items: [] }, + office: { agentProfiles: [profile] }, + } as unknown as QuerySeedInitialState); + + expect(client.getQueryData(qk.office.agents(WORKSPACE_ID))).toEqual({ agents: [profile] }); + }); + + it("seeds workflow snapshots into the workflow snapshot query cache", () => { + const client = makeQueryClient(); + const snapshot = { + workflow: { + id: toWorkflowId(WORKFLOW_ID), + workspace_id: toWorkspaceId(WORKSPACE_ID), + name: "Build", + sort_order: 10, + hidden: false, + created_at: "", + updated_at: "", + }, + steps: [ + { + id: STEP_ID, + workflow_id: toWorkflowId(WORKFLOW_ID), + name: "Todo", + color: "bg-blue-500", + position: 0, + allow_manual_move: true, + }, + ], + tasks: [ + { + id: toTaskId("task-1"), + workspace_id: toWorkspaceId(WORKSPACE_ID), + workflow_id: toWorkflowId(WORKFLOW_ID), + workflow_step_id: STEP_ID, + title: "Task", + description: "", + state: "TODO", + priority: 0, + position: 0, + repositories: [], + primary_session_id: toSessionId("session-1"), + primary_session_state: "RUNNING", + created_at: "", + updated_at: "", + }, + ], + } as WorkflowSnapshot; + + seedQueryClientFromInitialState(client, { + workflowSnapshots: { + itemsByWorkflowId: { + [WORKFLOW_ID]: snapshot, + }, + }, + }); + + expect(client.getQueryData(qk.workflows.snapshot(WORKFLOW_ID))).toEqual( + snapshot, + ); + }); + + it("seeds multiple workflow snapshot query caches", () => { + const client = makeQueryClient(); + const snapshot = { + workflow: { + id: toWorkflowId(REVIEW_WORKFLOW_ID), + workspace_id: toWorkspaceId(WORKSPACE_ID), + name: "Review", + sort_order: 20, + hidden: false, + created_at: "", + updated_at: "", + }, + steps: [ + { + id: REVIEW_STEP_ID, + workflow_id: toWorkflowId(REVIEW_WORKFLOW_ID), + name: "Review", + color: "bg-green-500", + position: 1, + allow_manual_move: true, + }, + ], + tasks: [ + { + id: toTaskId("task-2"), + workspace_id: toWorkspaceId(WORKSPACE_ID), + workflow_id: toWorkflowId(REVIEW_WORKFLOW_ID), + workflow_step_id: REVIEW_STEP_ID, + title: "Review task", + description: "", + state: "TODO", + priority: 0, + position: 0, + repositories: [], + created_at: "", + updated_at: "", + }, + ], + } as WorkflowSnapshot; + + seedQueryClientFromInitialState(client, { + workflowSnapshots: { + itemsByWorkflowId: { + [REVIEW_WORKFLOW_ID]: snapshot, + }, + }, + }); + + expect( + client.getQueryData(qk.workflows.snapshot(REVIEW_WORKFLOW_ID)), + ).toEqual(snapshot); + }); + + it("does not treat workflow snapshot seeds as Zustand kanban state", () => { + const client = makeQueryClient(); + const workflow = { + id: WORKFLOW_ID, + workspace_id: WORKSPACE_ID, + name: "Build", + sort_order: 10, + hidden: false, + } as Workflow; + + seedQueryClientFromInitialState(client, { + workspaces: { + activeId: WORKSPACE_ID, + items: [], + }, + workflows: { + items: [workflow], + activeId: WORKFLOW_ID, + }, + workflowSnapshots: { + itemsByWorkflowId: {}, + }, + // @ts-expect-error Legacy store mirrors are intentionally no longer part of the seed API. + kanban: { + workflowId: WORKFLOW_ID, + isLoading: false, + steps: [{ id: STEP_ID, title: "Todo", color: "bg-blue-500", position: 0 }], + tasks: [ + { + id: "task-1", + workflowStepId: STEP_ID, + title: "Task", + position: 0, + primarySessionId: "session-1", + primarySessionState: "RUNNING", + }, + ], + }, + }); + + expect(client.getQueryData(qk.workflows.snapshot(WORKFLOW_ID))).toEqual( + undefined, + ); + }); + + it("seeds repository scripts into the repository scripts query cache", () => { + const client = makeQueryClient(); + const script = { + id: "script-1", + repository_id: toRepositoryId("repo-1"), + name: "Setup", + command: "pnpm install", + position: 0, + created_at: "2026-06-24T00:00:00Z", + updated_at: "2026-06-24T00:00:00Z", + } satisfies RepositoryScript; + + seedQueryClientFromInitialState(client, { + repositoryScripts: { + itemsByRepositoryId: { + "repo-1": [script], + }, + }, + }); + + expect(client.getQueryData(qk.workspaces.repositoryScripts("repo-1"))).toEqual([script]); + }); +}); diff --git a/apps/web/lib/query/seed.ts b/apps/web/lib/query/seed.ts new file mode 100644 index 0000000000..5963b86af1 --- /dev/null +++ b/apps/web/lib/query/seed.ts @@ -0,0 +1,490 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { BootPayload, BootRouteData } from "@/src/boot-payload"; +import type { AppState } from "@/lib/state/store"; +import type { + Agent, + AgentDiscovery, + AvailableAgent, + CustomPrompt, + EditorOption, + Executor, + NotificationProvider, + Repository, + RepositoryScript, + ToolStatus, + Workspace, + Workflow, + WorkflowSnapshot, +} from "@/lib/types/http"; +import type { WorkflowItem } from "@/lib/state/slices"; +import type { Worktree } from "@/lib/state/slices/session/types"; +import type { FeatureFlags } from "@/lib/state/slices/features/types"; +import type { SecretListItem } from "@/lib/types/http-secrets"; +import type { SpritesInstance, SpritesStatus } from "@/lib/types/http-sprites"; +import type { + GitStatusQueryData, + SessionProcessesQueryData, +} from "./query-options/session-runtime"; +import type { AvailableCommand } from "@/lib/state/slices/session-runtime/types"; +import type { + ActivityEntry, + AgentProfile, + DashboardData, + InboxItem, + OfficeMeta, + Project, + Routine, + Run, + Skill, +} from "@/lib/state/slices/office/types"; +import { qk } from "./keys"; + +type SeedOptions = { + sessionId?: string; +}; + +type OfficeQuerySeedState = Partial & { + agents?: AgentProfile[]; + agentProfiles?: AgentProfile[]; + projects?: Project[]; + skills?: Skill[]; + routines?: Routine[]; + inboxItems?: InboxItem[]; + inboxCount?: number; + dashboard?: DashboardData | null; + activity?: ActivityEntry[]; + runs?: Run[]; + meta?: OfficeMeta | null; +}; + +export type QuerySeedInitialState = Omit< + Partial, + "workflows" | "workspaces" | "office" +> & { + workspaces?: Partial & { + items?: Workspace[]; + }; + office?: OfficeQuerySeedState; + workflows?: Partial & { + items?: Array; + }; + workflowSnapshots?: { + itemsByWorkflowId?: Record; + }; + workflowLists?: { + itemsByWorkspaceId?: Record>; + includeHiddenByWorkspaceId?: Record; + }; + executors?: { items: Executor[] }; + settingsAgents?: { items: Agent[] }; + agentDiscovery?: { items: AgentDiscovery[]; loading?: boolean; loaded?: boolean }; + availableAgents?: { + items: AvailableAgent[]; + tools: ToolStatus[]; + loading?: boolean; + loaded?: boolean; + }; + editors?: { items: EditorOption[] }; + prompts?: { items: CustomPrompt[] }; + secrets?: { items: SecretListItem[] }; + sprites?: { + status: SpritesStatus | null; + instances: SpritesInstance[]; + }; + notificationProviders?: { + items: NotificationProvider[]; + events: string[]; + appriseAvailable: boolean; + }; + repositories?: { + itemsByWorkspaceId?: Record; + }; + repositoryScripts?: { + itemsByRepositoryId?: Record; + }; + features?: FeatureFlags; + worktrees?: { + items?: Record; + }; + sessionWorktreesBySessionId?: { + itemsBySessionId?: Record; + }; + availableCommands?: { + bySessionId?: Record; + }; +}; + +export function seedQueryClientFromBootPayload(client: QueryClient, payload: BootPayload) { + seedQueryClientFromInitialState(client, payload.initialState ?? {}); + seedQueryClientFromRouteData(client, payload.routeData); +} + +export function seedQueryClientFromInitialState( + client: QueryClient, + initialState: QuerySeedInitialState, + options: SeedOptions = {}, +) { + if (Object.keys(initialState).length === 0) return; + client.setQueryData(qk.boot.initialState(), initialState); + setIfDefined(client, qk.features(), initialState.features); + setIfDefined(client, qk.workspaces.all(), initialState.workspaces?.items); + seedWorkspaceWorkflows(client, initialState); + seedWorkflowSnapshots(client, initialState); + seedWorkspaceRepositories(client, initialState); + setIfDefined(client, qk.settings.user(), initialState.userSettings); + seedSettingsState(client, initialState); + seedOfficeState(client, initialState); + seedRepositoryScripts(client, initialState); + seedTaskSessions(client, initialState); + seedSessionMessages(client, initialState, options.sessionId); + seedSessionTurns(client, initialState, options.sessionId); + seedSessionWorktrees(client, initialState); + seedSessionRuntime(client, initialState); +} + +function seedWorkspaceRepositories(client: QueryClient, initialState: QuerySeedInitialState) { + for (const [workspaceId, repositories] of Object.entries( + initialState.repositories?.itemsByWorkspaceId ?? {}, + )) { + client.setQueryData(qk.workspaces.repositories(workspaceId), repositories); + } +} + +function seedWorkspaceWorkflows(client: QueryClient, initialState: QuerySeedInitialState) { + for (const [workspaceId, workflows] of Object.entries( + initialState.workflowLists?.itemsByWorkspaceId ?? {}, + )) { + const includeHidden = + initialState.workflowLists?.includeHiddenByWorkspaceId?.[workspaceId] ?? false; + client.setQueryData(qk.workflows.all(workspaceId, { includeHidden }), workflows); + } + + const workflows = initialState.workflows?.items ?? []; + const byWorkspace = new Map>(); + for (const workflow of workflows) { + const workspaceId = "workspaceId" in workflow ? workflow.workspaceId : workflow.workspace_id; + const items = byWorkspace.get(workspaceId) ?? []; + items.push(workflow); + byWorkspace.set(workspaceId, items); + } + for (const [workspaceId, items] of byWorkspace) { + client.setQueryData(qk.workflows.all(workspaceId), items); + } +} + +function seedWorkflowSnapshots(client: QueryClient, initialState: QuerySeedInitialState) { + for (const [workflowId, snapshot] of Object.entries( + initialState.workflowSnapshots?.itemsByWorkflowId ?? {}, + )) { + client.setQueryData(qk.workflows.snapshot(workflowId), snapshot); + } +} + +function seedQueryClientFromRouteData(client: QueryClient, routeData: BootRouteData | undefined) { + if (!routeData) return; + client.setQueryData(qk.boot.routeData(), routeData); + seedRouteContext(client, routeData.routeContext); + seedTasksPage(client, routeData.tasksPage); + seedTaskDetail(client, routeData.taskDetail); +} + +function seedRouteContext( + client: QueryClient, + routeContext: BootRouteData["routeContext"] | undefined, +) { + if (!routeContext) return; + const workspaceId = routeContext.activeWorkspaceId ?? null; + if (!workspaceId) return; + setIfDefined(client, qk.workflows.all(workspaceId), routeContext.workflows); + setIfDefined(client, qk.workspaces.repositories(workspaceId), routeContext.repositories); +} + +function seedTasksPage(client: QueryClient, tasksPage: BootRouteData["tasksPage"] | undefined) { + if (!tasksPage) return; + const workspaceId = tasksPage.activeWorkspaceId ?? null; + client.setQueryData(qk.tasks.page(workspaceId), tasksPage); + if (!workspaceId) return; + setIfDefined(client, qk.workflows.all(workspaceId), tasksPage.workflows); + setIfDefined(client, qk.workspaces.repositories(workspaceId), tasksPage.repositories); +} + +function seedTaskDetail(client: QueryClient, taskDetail: BootRouteData["taskDetail"] | undefined) { + if (!taskDetail) return; + client.setQueryData(qk.tasks.detail(taskDetail.task.id), taskDetail.task); + seedQueryClientFromInitialState(client, taskDetail.initialState, { + sessionId: taskDetail.sessionId ?? undefined, + }); +} + +function seedOfficeState(client: QueryClient, initialState: QuerySeedInitialState) { + const office = initialState.office; + if (!office) return; + setIfDefined(client, qk.office.meta(), office.meta); + + const workspaceId = initialState.workspaces?.activeId ?? null; + if (!workspaceId) return; + + seedOfficeEntityLists(client, workspaceId, office); + seedOfficeInbox(client, workspaceId, office); + seedOfficeDashboardAndActivity(client, workspaceId, office); +} + +function seedOfficeEntityLists( + client: QueryClient, + workspaceId: string, + office: OfficeQuerySeedState, +) { + const agents = office.agents ?? office.agentProfiles; + if (agents) { + client.setQueryData(qk.office.agents(workspaceId), { agents }); + } + if (office.projects) { + client.setQueryData(qk.office.projects(workspaceId), { projects: office.projects }); + } + if (office.skills && office.skills.length > 0) { + client.setQueryData(qk.office.skills(workspaceId), { skills: office.skills }); + } + if (office.routines && office.routines.length > 0) { + client.setQueryData(qk.office.routines(workspaceId), { routines: office.routines }); + } +} + +function seedOfficeInbox(client: QueryClient, workspaceId: string, office: OfficeQuerySeedState) { + if (!office.inboxItems && office.inboxCount === undefined) return; + client.setQueryData(qk.office.inbox(workspaceId), { + items: office.inboxItems ?? [], + total_count: office.inboxCount ?? office.inboxItems?.length ?? 0, + }); +} + +function seedOfficeDashboardAndActivity( + client: QueryClient, + workspaceId: string, + office: OfficeQuerySeedState, +) { + setIfDefined(client, qk.office.dashboard(workspaceId), office.dashboard ?? undefined); + if (office.activity) { + client.setQueryData(qk.office.activity(workspaceId), { activity: office.activity }); + } + if (office.runs) { + client.setQueryData(qk.office.runs(workspaceId), { runs: office.runs }); + } +} + +function seedSettingsState(client: QueryClient, initialState: QuerySeedInitialState) { + if (initialState.executors) { + client.setQueryData(qk.settings.executors(), { + executors: initialState.executors.items, + }); + } + if (initialState.settingsAgents) { + client.setQueryData(qk.settings.agents(), { + agents: initialState.settingsAgents.items, + total: initialState.settingsAgents.items.length, + }); + } + if (initialState.agentDiscovery) { + client.setQueryData(qk.settings.agentDiscovery(), { + agents: initialState.agentDiscovery.items, + total: initialState.agentDiscovery.items.length, + }); + } + if (initialState.availableAgents) { + client.setQueryData(qk.settings.availableAgents(), { + agents: initialState.availableAgents.items, + tools: initialState.availableAgents.tools, + total: initialState.availableAgents.items.length, + }); + } + if (initialState.editors) { + client.setQueryData(qk.settings.editors(), { editors: initialState.editors.items }); + } + if (initialState.prompts) { + client.setQueryData(qk.settings.prompts(), { prompts: initialState.prompts.items }); + } + if (initialState.secrets) { + client.setQueryData(qk.settings.secrets(), initialState.secrets.items); + } + if (initialState.sprites) { + setIfDefined(client, qk.settings.spritesStatus(), initialState.sprites.status ?? undefined); + client.setQueryData(qk.settings.spritesInstances(), initialState.sprites.instances); + } + if (initialState.notificationProviders) { + client.setQueryData(qk.settings.notificationProviders(), { + providers: initialState.notificationProviders.items, + events: initialState.notificationProviders.events, + apprise_available: initialState.notificationProviders.appriseAvailable, + }); + } +} + +function seedRepositoryScripts(client: QueryClient, initialState: QuerySeedInitialState) { + for (const [repositoryId, scripts] of Object.entries( + initialState.repositoryScripts?.itemsByRepositoryId ?? {}, + )) { + client.setQueryData(qk.workspaces.repositoryScripts(repositoryId), scripts); + } +} + +function seedTaskSessions(client: QueryClient, initialState: QuerySeedInitialState) { + const sessionsByTask = initialState.taskSessionsByTask?.itemsByTaskId ?? {}; + for (const [taskId, sessions] of Object.entries(sessionsByTask)) { + client.setQueryData(qk.taskSession.byTask(taskId), { sessions }); + } + const sessionsById = initialState.taskSessions?.items ?? {}; + for (const [sessionId, session] of Object.entries(sessionsById)) { + client.setQueryData(qk.taskSession.byId(sessionId), session); + } +} + +function seedSessionMessages( + client: QueryClient, + initialState: QuerySeedInitialState, + preferredSessionId: string | undefined, +) { + const messagesBySession = initialState.messages?.bySession ?? {}; + const metaBySession = initialState.messages?.metaBySession ?? {}; + for (const sessionId of sessionIds(messagesBySession, preferredSessionId)) { + const messages = messagesBySession[sessionId]; + if (!messages) continue; + const meta = metaBySession[sessionId]; + client.setQueryData(qk.session.messages(sessionId), { + messages, + hasMore: meta?.hasMore ?? false, + oldestCursor: meta?.oldestCursor ?? messages[0]?.id ?? null, + }); + } +} + +function seedSessionTurns( + client: QueryClient, + initialState: QuerySeedInitialState, + preferredSessionId: string | undefined, +) { + const turnsBySession = initialState.turns?.bySession ?? {}; + const activeBySession = initialState.turns?.activeBySession ?? {}; + for (const sessionId of sessionIds(turnsBySession, preferredSessionId)) { + const turns = turnsBySession[sessionId]; + if (!turns) continue; + client.setQueryData(qk.session.turns(sessionId), { + turns, + activeTurnId: activeBySession[sessionId] ?? null, + }); + } +} + +function seedSessionWorktrees(client: QueryClient, initialState: QuerySeedInitialState) { + const worktreesById = initialState.worktrees?.items ?? {}; + const idsBySession = initialState.sessionWorktreesBySessionId?.itemsBySessionId ?? {}; + const seenSessionIds = new Set(); + + for (const [sessionId, worktreeIds] of Object.entries(idsBySession)) { + const worktrees = worktreeIds + .map((worktreeId) => worktreesById[worktreeId]) + .filter((worktree): worktree is Worktree => Boolean(worktree)); + if (worktrees.length === 0) continue; + client.setQueryData(qk.sessionRuntime.worktrees(sessionId), worktrees); + seenSessionIds.add(sessionId); + } + + for (const session of Object.values(initialState.taskSessions?.items ?? {})) { + if (!session.worktree_id || seenSessionIds.has(session.id)) continue; + client.setQueryData(qk.sessionRuntime.worktrees(session.id), [ + { + id: session.worktree_id, + sessionId: session.id, + repositoryId: session.repository_id ?? undefined, + path: session.worktree_path ?? undefined, + branch: session.worktree_branch ?? undefined, + }, + ]); + } +} + +function seedSessionRuntime(client: QueryClient, initialState: QuerySeedInitialState) { + seedGitStatus(client, initialState); + seedSessionCommits(client, initialState); + seedSessionProcesses(client, initialState); + seedEnvScopedRuntime(client, initialState); + seedPerSessionRuntime(client, initialState); +} + +function seedGitStatus(client: QueryClient, initialState: QuerySeedInitialState) { + const latestByEnv = initialState.gitStatus?.byEnvironmentId ?? {}; + const byRepoByEnv = initialState.gitStatus?.byEnvironmentRepo ?? {}; + for (const envKey of new Set([...Object.keys(latestByEnv), ...Object.keys(byRepoByEnv)])) { + const latest = latestByEnv[envKey]; + const byRepo = + byRepoByEnv[envKey] ?? (latest ? { [latest.repository_name ?? ""]: latest } : {}); + const data: GitStatusQueryData = { latest, byRepo }; + client.setQueryData(qk.sessionRuntime.gitStatus(envKey), data); + } +} + +function seedSessionCommits(client: QueryClient, initialState: QuerySeedInitialState) { + const commitsByEnv = initialState.sessionCommits?.byEnvironmentId ?? {}; + for (const [envKey, commits] of Object.entries(commitsByEnv)) { + client.setQueryData(qk.sessionRuntime.commits(envKey), commits); + } +} + +function seedSessionProcesses(client: QueryClient, initialState: QuerySeedInitialState) { + const processIdsBySession = initialState.processes?.processIdsBySessionId ?? {}; + const processesById = initialState.processes?.processesById ?? {}; + const activeBySession = initialState.processes?.activeProcessBySessionId ?? {}; + const devBySession = initialState.processes?.devProcessBySessionId ?? {}; + for (const [sessionId, processIds] of Object.entries(processIdsBySession)) { + const data: SessionProcessesQueryData = { + processesById: Object.fromEntries( + processIds + .map((processId) => [processId, processesById[processId]] as const) + .filter(([, process]) => Boolean(process)), + ), + processIds, + activeProcessId: activeBySession[sessionId], + devProcessId: devBySession[sessionId], + }; + client.setQueryData(qk.sessionRuntime.processes(sessionId), data); + } +} + +function seedEnvScopedRuntime(client: QueryClient, initialState: QuerySeedInitialState) { + const userShells = initialState.userShells?.byEnvironmentId ?? {}; + for (const [envKey, shells] of Object.entries(userShells)) { + client.setQueryData(qk.sessionRuntime.userShells(envKey), shells); + } +} + +function seedPerSessionRuntime(client: QueryClient, initialState: QuerySeedInitialState) { + setBySession(client, qk.sessionRuntime.prepare, initialState.prepareProgress?.bySessionId); + setBySession(client, qk.sessionRuntime.contextWindow, initialState.contextWindow?.bySessionId); + setBySession( + client, + qk.sessionRuntime.availableCommands, + initialState.availableCommands?.bySessionId, + ); + setBySession(client, qk.sessionRuntime.models, initialState.sessionModels?.bySessionId); + setBySession(client, qk.sessionRuntime.agentctl, initialState.sessionAgentctl?.itemsBySessionId); +} + +function sessionIds(bySession: Record, preferredSessionId: string | undefined) { + const ids = new Set(Object.keys(bySession)); + if (preferredSessionId) ids.add(preferredSessionId); + return ids; +} + +function setBySession( + client: QueryClient, + keyForSession: (sessionId: string) => readonly unknown[], + bySession: Record | undefined, +) { + for (const [sessionId, value] of Object.entries(bySession ?? {})) { + client.setQueryData(keyForSession(sessionId), value); + } +} + +function setIfDefined(client: QueryClient, key: readonly unknown[], value: T | undefined) { + if (value !== undefined) { + client.setQueryData(key, value); + } +} diff --git a/apps/web/lib/query/workflow-snapshot-cache.ts b/apps/web/lib/query/workflow-snapshot-cache.ts new file mode 100644 index 0000000000..28e6756288 --- /dev/null +++ b/apps/web/lib/query/workflow-snapshot-cache.ts @@ -0,0 +1,69 @@ +import type { QueryClient } from "@tanstack/react-query"; +import type { WorkflowSnapshot } from "@/lib/types/http"; + +function isWorkflowSnapshotQueryKey(key: readonly unknown[]): boolean { + return key[0] === "workflows" && typeof key[1] === "string" && key[2] === "snapshot"; +} + +function isWorkflowSnapshot(value: unknown): value is WorkflowSnapshot { + return ( + typeof value === "object" && + value !== null && + "workflow" in value && + "tasks" in value && + Array.isArray((value as { tasks?: unknown }).tasks) + ); +} + +export function updateWorkflowSnapshotQueries( + queryClient: QueryClient, + updater: (snapshot: WorkflowSnapshot) => WorkflowSnapshot, +): void { + for (const query of queryClient.getQueryCache().findAll()) { + if (!isWorkflowSnapshotQueryKey(query.queryKey)) continue; + queryClient.setQueryData(query.queryKey, (current: unknown) => + isWorkflowSnapshot(current) ? updater(current) : current, + ); + } +} + +export function workflowSnapshotQueryDataForWorkflow( + queryClient: QueryClient, + workflowId: string, +): WorkflowSnapshot | undefined { + const data = queryClient.getQueryData(["workflows", workflowId, "snapshot"]); + return isWorkflowSnapshot(data) ? data : undefined; +} + +export function updateWorkflowSnapshotQuery( + queryClient: QueryClient, + workflowId: string, + updater: (snapshot: WorkflowSnapshot) => WorkflowSnapshot, +): void { + queryClient.setQueryData(["workflows", workflowId, "snapshot"], (current: unknown) => + isWorkflowSnapshot(current) ? updater(current) : current, + ); +} + +export function workflowSnapshotQueryData(queryClient: QueryClient): WorkflowSnapshot[] { + const snapshots: WorkflowSnapshot[] = []; + for (const query of queryClient.getQueryCache().findAll()) { + if (!isWorkflowSnapshotQueryKey(query.queryKey)) continue; + const data = queryClient.getQueryData(query.queryKey); + if (isWorkflowSnapshot(data)) snapshots.push(data); + } + return snapshots; +} + +export function removeTasksFromWorkflowSnapshotQueries( + queryClient: QueryClient, + ids: Set, +): void { + updateWorkflowSnapshotQueries(queryClient, (snapshot) => { + if (!snapshot.tasks.some((task) => ids.has(task.id))) return snapshot; + return { + ...snapshot, + tasks: snapshot.tasks.filter((task) => !ids.has(task.id)), + }; + }); +} diff --git a/apps/web/lib/query/workspace-cache.ts b/apps/web/lib/query/workspace-cache.ts new file mode 100644 index 0000000000..9f26475910 --- /dev/null +++ b/apps/web/lib/query/workspace-cache.ts @@ -0,0 +1,32 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { qk } from "@/lib/query/keys"; +import type { Workspace } from "@/lib/types/http"; + +export function updateWorkspacesCache( + queryClient: QueryClient, + updater: (items: Workspace[]) => Workspace[], +) { + queryClient.setQueryData(qk.workspaces.all(), (current) => updater(current ?? [])); +} + +export function upsertWorkspaceCache(queryClient: QueryClient, workspace: Workspace) { + updateWorkspacesCache(queryClient, (items) => { + const existingIndex = items.findIndex((item) => item.id === workspace.id); + if (existingIndex === -1) return [...items, workspace]; + return items.map((item) => (item.id === workspace.id ? workspace : item)); + }); +} + +export function patchWorkspaceCache( + queryClient: QueryClient, + workspaceId: string, + patch: Partial, +) { + updateWorkspacesCache(queryClient, (items) => + items.map((item) => (item.id === workspaceId ? { ...item, ...patch } : item)), + ); +} + +export function removeWorkspaceCache(queryClient: QueryClient, workspaceId: string) { + updateWorkspacesCache(queryClient, (items) => items.filter((item) => item.id !== workspaceId)); +} diff --git a/apps/web/lib/routing/kanban-route-hydration.test.ts b/apps/web/lib/routing/kanban-route-hydration.test.ts index 661e8c4ea2..6a6fb7037e 100644 --- a/apps/web/lib/routing/kanban-route-hydration.test.ts +++ b/apps/web/lib/routing/kanban-route-hydration.test.ts @@ -2,49 +2,47 @@ import { describe, expect, it } from "vitest"; import { hasHydratedKanbanRouteState } from "./kanban-route-hydration"; import type { AppState } from "@/lib/state/store"; +import type { WorkflowItem } from "@/lib/state/slices"; -type HydrationState = Pick; +type HydrationState = Pick; +const workflows: WorkflowItem[] = [{ id: "wf-1", workspaceId: "ws-1", name: "Development" }]; function state(overrides: Partial = {}): HydrationState { return { - workspaces: { activeId: "ws-1", items: [] }, - workflows: { - activeId: "wf-1", - items: [{ id: "wf-1", workspaceId: "ws-1", name: "Development" }], - }, - kanban: { workflowId: "wf-1", steps: [], tasks: [] }, - kanbanMulti: { - snapshots: { - "wf-1": { workflowId: "wf-1", workflowName: "Development", steps: [], tasks: [] }, - }, - isLoading: false, - }, + workspaces: { activeId: "ws-1" }, + workflows: { activeId: "wf-1" }, ...overrides, }; } describe("hasHydratedKanbanRouteState", () => { - it("accepts boot-hydrated state for the active workspace and workflow", () => { - expect(hasHydratedKanbanRouteState(state(), {})).toBe(true); - expect(hasHydratedKanbanRouteState(state(), { workspaceId: "ws-1", workflowId: "wf-1" })).toBe( - true, - ); + it("accepts query-hydrated snapshots for the active workspace and workflow", () => { + expect(hasHydratedKanbanRouteState(state(), {}, workflows, new Set(["wf-1"]))).toBe(true); + expect( + hasHydratedKanbanRouteState( + state(), + { workspaceId: "ws-1", workflowId: "wf-1" }, + workflows, + new Set(["wf-1"]), + ), + ).toBe(true); }); it("rejects missing or mismatched route state so the client can fetch", () => { - expect( - hasHydratedKanbanRouteState(state({ workspaces: { activeId: null, items: [] } }), {}), - ).toBe(false); - expect(hasHydratedKanbanRouteState(state(), { workspaceId: "ws-2" })).toBe(false); - expect(hasHydratedKanbanRouteState(state(), { workflowId: "wf-2" })).toBe(false); expect( hasHydratedKanbanRouteState( - state({ - kanban: { workflowId: null, steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - }), + state({ workspaces: { activeId: null } }), {}, + workflows, + new Set(["wf-1"]), ), ).toBe(false); + expect( + hasHydratedKanbanRouteState(state(), { workspaceId: "ws-2" }, workflows, new Set(["wf-1"])), + ).toBe(false); + expect( + hasHydratedKanbanRouteState(state(), { workflowId: "wf-2" }, workflows, new Set(["wf-1"])), + ).toBe(false); + expect(hasHydratedKanbanRouteState(state(), {}, workflows, new Set())).toBe(false); }); }); diff --git a/apps/web/lib/routing/kanban-route-hydration.ts b/apps/web/lib/routing/kanban-route-hydration.ts index 9e5b837aab..1487b25e4a 100644 --- a/apps/web/lib/routing/kanban-route-hydration.ts +++ b/apps/web/lib/routing/kanban-route-hydration.ts @@ -1,9 +1,7 @@ import type { AppState } from "@/lib/state/store"; +import type { WorkflowItem } from "@/lib/state/slices"; -type KanbanRouteHydrationState = Pick< - AppState, - "kanban" | "kanbanMulti" | "workflows" | "workspaces" ->; +type KanbanRouteHydrationState = Pick; export type KanbanRouteSelection = { workspaceId?: string; @@ -13,12 +11,14 @@ export type KanbanRouteSelection = { export function hasHydratedKanbanRouteState( state: KanbanRouteHydrationState, route: KanbanRouteSelection, + workflows: WorkflowItem[], + hydratedWorkflowIds: ReadonlySet, ): boolean { const activeWorkspaceId = state.workspaces.activeId; if (!activeWorkspaceId) return false; if (route.workspaceId && route.workspaceId !== activeWorkspaceId) return false; - const workspaceWorkflows = state.workflows.items.filter( + const workspaceWorkflows = workflows.filter( (workflow) => workflow.workspaceId === activeWorkspaceId, ); if (workspaceWorkflows.length === 0) return false; @@ -31,5 +31,5 @@ export function hasHydratedKanbanRouteState( const workflowId = route.workflowId ?? state.workflows.activeId; if (!workflowId) return false; - return Boolean(state.kanbanMulti.snapshots[workflowId] || state.kanban.workflowId === workflowId); + return hydratedWorkflowIds.has(workflowId); } diff --git a/apps/web/lib/routing/route-bootstrap.test.ts b/apps/web/lib/routing/route-bootstrap.test.ts index fc6fb05154..593820bdf4 100644 --- a/apps/web/lib/routing/route-bootstrap.test.ts +++ b/apps/web/lib/routing/route-bootstrap.test.ts @@ -34,7 +34,7 @@ describe("mapWorkspaceItem", () => { default_environment_id: null, default_agent_profile_id: null, default_config_agent_profile_id: null, - office_workflow_id: null, + office_workflow_id: undefined, created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T00:00:00Z", }); diff --git a/apps/web/lib/routing/route-bootstrap.ts b/apps/web/lib/routing/route-bootstrap.ts index 741fbc03ac..a37a7ce573 100644 --- a/apps/web/lib/routing/route-bootstrap.ts +++ b/apps/web/lib/routing/route-bootstrap.ts @@ -1,12 +1,11 @@ -import type { WorkspaceState } from "@/lib/state/slices/workspace/types"; -import type { ListWorkspacesResponse } from "@/lib/types/http"; +import type { ListWorkspacesResponse, Workspace } from "@/lib/types/http"; export const ACTIVE_WORKSPACE_COOKIE = "kandev-active-workspace"; export const LEGACY_OFFICE_ACTIVE_WORKSPACE_COOKIE = "office-active-workspace"; type WorkspaceItem = ListWorkspacesResponse["workspaces"][number]; -export function mapWorkspaceItem(ws: WorkspaceItem): WorkspaceState["items"][number] { +export function mapWorkspaceItem(ws: WorkspaceItem): Workspace { return { id: ws.id, name: ws.name, @@ -16,7 +15,7 @@ export function mapWorkspaceItem(ws: WorkspaceItem): WorkspaceState["items"][num default_environment_id: ws.default_environment_id ?? null, default_agent_profile_id: ws.default_agent_profile_id ?? null, default_config_agent_profile_id: ws.default_config_agent_profile_id ?? null, - office_workflow_id: ws.office_workflow_id ?? null, + office_workflow_id: ws.office_workflow_id ?? undefined, created_at: ws.created_at, updated_at: ws.updated_at, }; diff --git a/apps/web/lib/session/task-session-navigation.ts b/apps/web/lib/session/task-session-navigation.ts new file mode 100644 index 0000000000..fb70cbc3b6 --- /dev/null +++ b/apps/web/lib/session/task-session-navigation.ts @@ -0,0 +1,22 @@ +import type { TaskSession } from "@/lib/types/http"; + +type TaskSwitchSessionInput = Pick< + TaskSession, + "agent_profile_snapshot" | "is_passthrough" | "task_environment_id" +>; + +export function sessionHasRoutingInfo( + session: Pick | null | undefined, +): boolean { + return Boolean( + session && + (session.is_passthrough !== undefined || session.agent_profile_snapshot !== undefined), + ); +} + +export function taskSessionsAreNavigationReady(sessions: TaskSwitchSessionInput[]): boolean { + return ( + sessions.length === 0 || + sessions.every((session) => !!session.task_environment_id && sessionHasRoutingInfo(session)) + ); +} diff --git a/apps/web/lib/ssr/mapper.test.ts b/apps/web/lib/ssr/mapper.test.ts index c4b349da10..e79aea8dbc 100644 --- a/apps/web/lib/ssr/mapper.test.ts +++ b/apps/web/lib/ssr/mapper.test.ts @@ -47,16 +47,20 @@ function snapshotWithPendingAction(action: unknown): WorkflowSnapshot { } describe("snapshotToState", () => { - it("keeps known primary session pending action values", () => { - const state = snapshotToState(snapshotWithPendingAction("permission")); + it("seeds workflow snapshots for Query hydration", () => { + const snapshot = snapshotWithPendingAction("permission"); + const state = snapshotToState(snapshot); - expect(state.kanban?.tasks[0]?.primarySessionPendingAction).toBe("permission"); + expect(state.workflowSnapshots?.itemsByWorkflowId?.[workflowID]).toBe(snapshot); }); - it("drops unrecognized primary session pending action values", () => { - const state = snapshotToState(snapshotWithPendingAction("unknown")); + it("returns an empty seed when the snapshot has no workflow", () => { + const state = snapshotToState({ + ...snapshotWithPendingAction("unknown"), + workflow: null, + } as unknown as WorkflowSnapshot); - expect(state.kanban?.tasks[0]?.primarySessionPendingAction).toBeUndefined(); + expect(state).toEqual({}); }); it("preserves workflow step WIP fields", () => { @@ -83,7 +87,7 @@ describe("snapshotToState", () => { tasks: [], } as unknown as WorkflowSnapshot); - expect(state.kanban?.steps[0]).toMatchObject({ + expect(state.workflowSnapshots?.itemsByWorkflowId?.[workflowID]?.steps[0]).toMatchObject({ wip_limit: 2, pull_from_step_id: "step-0", }); diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 3ff63b99c5..928d8d88b0 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -1,83 +1,16 @@ -import type { AppState, KanbanState } from "@/lib/state/store"; -import { primaryTaskRepository } from "@/lib/types/http"; +import type { QuerySeedInitialState } from "@/lib/query/seed"; import type { WorkflowSnapshot, Message, Task } from "@/lib/types/http"; -import { pickPendingAction } from "@/lib/kanban/map-task"; -import { - isPRReviewFromMetadata, - isIssueWatchFromMetadata, - issueFieldsFromMetadata, -} from "@/lib/metadata-utils"; -type KanbanTask = KanbanState["tasks"][number]; - -export function snapshotToState(snapshot: WorkflowSnapshot): Partial { +export function snapshotToState(snapshot: WorkflowSnapshot): QuerySeedInitialState { // Handle empty snapshot (ephemeral tasks have no workflow) if (!snapshot.workflow) { - return { - kanban: { - workflowId: "", - isLoading: false, - steps: [], - tasks: [], - }, - }; + return {}; } - - const tasks = snapshot.tasks - .filter((task) => !task.is_ephemeral) // Filter out ephemeral tasks (e.g., quick chat) - .map((task) => { - const workflowStepId = task.workflow_step_id; - if (!workflowStepId) return null; - const primary = primaryTaskRepository(task.repositories); - return { - id: task.id, - workflowStepId, - title: task.title, - description: task.description ?? undefined, - position: task.position ?? 0, - state: task.state, - repositoryId: primary?.repository_id ?? undefined, - repositories: task.repositories?.map((r) => ({ - id: r.id, - repository_id: r.repository_id, - base_branch: r.base_branch, - checkout_branch: r.checkout_branch, - position: r.position, - })), - primarySessionId: task.primary_session_id ?? undefined, - primarySessionState: task.primary_session_state ?? undefined, - primarySessionPendingAction: pickPendingAction(task.primary_session_pending_action), - sessionCount: task.session_count ?? undefined, - reviewStatus: task.review_status ?? undefined, - parentTaskId: task.parent_id ?? undefined, - updatedAt: task.updated_at, - isPRReview: isPRReviewFromMetadata(task.metadata), - isIssueWatch: isIssueWatchFromMetadata(task.metadata), - ...issueFieldsFromMetadata(task.metadata), - } as KanbanTask; - }) - .filter((task): task is KanbanTask => task !== null); - return { - kanban: { - workflowId: snapshot.workflow.id, - isLoading: false, - steps: snapshot.steps.map((step) => ({ - id: step.id, - title: step.name, - color: step.color ?? "bg-neutral-400", - position: step.position, - events: step.events, - allow_manual_move: step.allow_manual_move, - prompt: step.prompt, - is_start_step: step.is_start_step, - show_in_command_panel: step.show_in_command_panel, - agent_profile_id: step.agent_profile_id, - wip_limit: step.wip_limit, - pull_from_step_id: step.pull_from_step_id ?? null, - stage_type: step.stage_type, - })), - tasks, + workflowSnapshots: { + itemsByWorkflowId: { + [snapshot.workflow.id]: snapshot, + }, }, }; } @@ -86,7 +19,7 @@ export function taskToState( task: Task, sessionId?: string | null, messages?: { items: Message[]; hasMore?: boolean; oldestCursor?: string | null }, -): Partial { +): QuerySeedInitialState { const resolvedSessionId = sessionId ?? messages?.items[0]?.session_id ?? null; return { tasks: { diff --git a/apps/web/lib/ssr/session-page-state.ts b/apps/web/lib/ssr/session-page-state.ts index 902a65f6e4..cfda4315e1 100644 --- a/apps/web/lib/ssr/session-page-state.ts +++ b/apps/web/lib/ssr/session-page-state.ts @@ -9,11 +9,11 @@ import { listTaskSessions, listWorkspaces, } from "@/lib/api"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; import { listSessionTurns } from "@/lib/api/domains/session-api"; import { fetchTerminals } from "@/lib/api/domains/user-shell-api"; import type { ListMessagesResponse, + RepositoryScript, Task, TaskSession, UserSettingsResponse, @@ -24,7 +24,13 @@ import { snapshotToState, taskToState } from "@/lib/ssr/mapper"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { prepareResultToSessionState } from "@/lib/state/slices/session-runtime/prepare-result"; import type { SessionPrepareState } from "@/lib/state/slices/session-runtime/types"; -import type { AppState } from "@/lib/state/store"; +import type { QuerySeedInitialState } from "@/lib/query/seed"; + +export type SessionPageInitialState = QuerySeedInitialState & { + repositoryScripts?: { + itemsByRepositoryId?: Record; + }; +}; function buildWorktreeState(allSessions: TaskSession[]) { const sessionsWithWorktrees = allSessions.filter((s) => s.worktree_id); @@ -88,13 +94,12 @@ function buildSessionPageState(p: BuildSessionPageStateParams) { ...buildWorktreeState(allSessions), ...buildPrepareProgressState(allSessions), settingsAgents: { items: agents.agents }, - settingsData: { agentsLoaded: true, executorsLoaded: false }, userSettings: mapUserSettingsResponse(p.userSettingsResponse), }; } function buildResourceState(p: BuildSessionPageStateParams) { - const { task, agents, repositories, workspaces, workflows } = p; + const { task, repositories, workspaces, workflows } = p; const repositoryId = task.repositories?.[0]?.repository_id; const repository = repositories.find((r) => r.id === repositoryId); const scripts = repository?.scripts ?? []; @@ -113,34 +118,19 @@ function buildResourceState(p: BuildSessionPageStateParams) { })), activeId: task.workspace_id, }, - // Don't write activeId — null means "All Workflows"; task context lives in kanban.workflowId. - workflows: { - items: workflows.map((w) => ({ - id: w.id as string, - workspaceId: w.workspace_id as string, - name: w.name, - hidden: w.hidden, - style: w.style, - })), - } as Partial["workflows"], + // Don't write activeId — null means "All Workflows"; task context lives in the seeded snapshot. + workflowLists: { + itemsByWorkspaceId: { [task.workspace_id]: workflows }, + includeHiddenByWorkspaceId: { [task.workspace_id]: true }, + }, repositories: { itemsByWorkspaceId: { [task.workspace_id]: repositories }, - loadingByWorkspaceId: { [task.workspace_id]: false }, - loadedByWorkspaceId: { [task.workspace_id]: true }, }, repositoryScripts: repositoryId ? { itemsByRepositoryId: { [repositoryId]: scripts }, - loadingByRepositoryId: { [repositoryId]: false }, - loadedByRepositoryId: { [repositoryId]: true }, } - : { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} }, - agentProfiles: { - items: agents.agents.flatMap((agent) => - agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), - ), - version: 0, - }, + : { itemsByRepositoryId: {} }, }; } @@ -189,7 +179,7 @@ function buildPrepareProgressState(allSessions: TaskSession[]) { export type FetchedSessionData = { task: Task; sessionId: string | null; - initialState: ReturnType; + initialState: SessionPageInitialState; initialTerminals: Terminal[]; }; diff --git a/apps/web/lib/state/default-state.test.ts b/apps/web/lib/state/default-state.test.ts new file mode 100644 index 0000000000..ef8290e506 --- /dev/null +++ b/apps/web/lib/state/default-state.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { mergeInitialState, type DefaultState } from "./default-state"; + +describe("mergeInitialState", () => { + it("does not retain legacy kanban server-state mirrors", () => { + const merged = mergeInitialState({ + kanban: { workflowId: "wf-1", steps: [], tasks: [] }, + kanbanMulti: { + snapshots: { + "wf-1": { workflowId: "wf-1", workflowName: "Development", steps: [], tasks: [] }, + }, + }, + workflows: { activeId: "wf-1" }, + } as Partial); + + expect("kanban" in merged).toBe(false); + expect("kanbanMulti" in merged).toBe(false); + expect(merged.workflows.activeId).toBe("wf-1"); + }); +}); diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index eb1c177641..ce55ab7d27 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -6,97 +6,42 @@ import { defaultSessionRuntimeState, defaultUIState, defaultGitHubState, - defaultGitLabState, - defaultJiraState, - defaultLinearState, defaultOfficeState, - defaultFeaturesState, - defaultAutomationsState, - defaultSystemState, } from "./slices"; import { getStoredQuickChatNames } from "@/lib/local-storage"; export const defaultState = { - kanban: defaultKanbanState.kanban, - kanbanMulti: defaultKanbanState.kanbanMulti, workflows: defaultKanbanState.workflows, tasks: defaultKanbanState.tasks, workspaces: defaultWorkspaceState.workspaces, - repositories: defaultWorkspaceState.repositories, - repositoryBranches: defaultWorkspaceState.repositoryBranches, - repositoryScripts: defaultWorkspaceState.repositoryScripts, - executors: defaultSettingsState.executors, - settingsAgents: defaultSettingsState.settingsAgents, - agentDiscovery: defaultSettingsState.agentDiscovery, - availableAgents: defaultSettingsState.availableAgents, - agentProfiles: defaultSettingsState.agentProfiles, - editors: defaultSettingsState.editors, - prompts: defaultSettingsState.prompts, - secrets: defaultSettingsState.secrets, - notificationProviders: defaultSettingsState.notificationProviders, - settingsData: defaultSettingsState.settingsData, userSettings: defaultSettingsState.userSettings, messages: defaultSessionState.messages, turns: defaultSessionState.turns, taskSessions: defaultSessionState.taskSessions, taskSessionsByTask: defaultSessionState.taskSessionsByTask, sessionAgentctl: defaultSessionState.sessionAgentctl, - worktrees: defaultSessionState.worktrees, - sessionWorktreesBySessionId: defaultSessionState.sessionWorktreesBySessionId, - pendingModel: defaultSessionState.pendingModel, activeModel: defaultSessionState.activeModel, taskPlans: defaultSessionState.taskPlans, walkthroughs: defaultSessionState.walkthroughs, - queue: defaultSessionState.queue, - terminal: defaultSessionRuntimeState.terminal, shell: defaultSessionRuntimeState.shell, processes: defaultSessionRuntimeState.processes, gitStatus: defaultSessionRuntimeState.gitStatus, environmentIdBySessionId: defaultSessionRuntimeState.environmentIdBySessionId, sessionCommits: defaultSessionRuntimeState.sessionCommits, contextWindow: defaultSessionRuntimeState.contextWindow, - agents: defaultSessionRuntimeState.agents, - availableCommands: defaultSessionRuntimeState.availableCommands, - sessionMode: defaultSessionRuntimeState.sessionMode, userShells: defaultSessionRuntimeState.userShells, prepareProgress: defaultSessionRuntimeState.prepareProgress, - sessionTodos: defaultSessionRuntimeState.sessionTodos, - agentCapabilities: defaultSessionRuntimeState.agentCapabilities, sessionModels: defaultSessionRuntimeState.sessionModels, - promptUsage: defaultSessionRuntimeState.promptUsage, - sessionPollMode: defaultSessionRuntimeState.sessionPollMode, - githubStatus: defaultGitHubState.githubStatus, - taskPRs: defaultGitHubState.taskPRs, pendingPrUrlByTaskId: defaultGitHubState.pendingPrUrlByTaskId, - prWatches: defaultGitHubState.prWatches, - reviewWatches: defaultGitHubState.reviewWatches, - issueWatches: defaultGitHubState.issueWatches, - actionPresets: defaultGitHubState.actionPresets, prFeedbackCache: defaultGitHubState.prFeedbackCache, - taskCIAutomation: defaultGitHubState.taskCIAutomation, - taskMRs: defaultGitLabState.taskMRs, - gitlabReviewWatches: defaultGitLabState.gitlabReviewWatches, - gitlabIssueWatches: defaultGitLabState.gitlabIssueWatches, - gitlabMRWatches: defaultGitLabState.gitlabMRWatches, - gitlabActionPresets: defaultGitLabState.gitlabActionPresets, - gitlabStats: defaultGitLabState.gitlabStats, - gitlabStatus: defaultGitLabState.gitlabStatus, - jiraIssueWatches: defaultJiraState.jiraIssueWatches, - linearIssueWatches: defaultLinearState.linearIssueWatches, office: defaultOfficeState.office, - features: defaultFeaturesState.features, - automations: defaultAutomationsState.automations, - automationRuns: defaultAutomationsState.automationRuns, - system: defaultSystemState.system, previewPanel: defaultUIState.previewPanel, rightPanel: defaultUIState.rightPanel, - diffs: defaultUIState.diffs, connection: defaultUIState.connection, mobileKanban: defaultUIState.mobileKanban, mobileSession: defaultUIState.mobileSession, chatInput: defaultUIState.chatInput, documentPanel: defaultUIState.documentPanel, - systemHealth: defaultUIState.systemHealth, quickChat: defaultUIState.quickChat, sessionFailureNotification: defaultUIState.sessionFailureNotification, bottomTerminal: defaultUIState.bottomTerminal, @@ -108,30 +53,6 @@ export const defaultState = { export type DefaultState = typeof defaultState; -function mergeGitLabFields( - d: DefaultState, - s: Partial, -): Pick< - DefaultState, - | "taskMRs" - | "gitlabReviewWatches" - | "gitlabIssueWatches" - | "gitlabMRWatches" - | "gitlabActionPresets" - | "gitlabStats" - | "gitlabStatus" -> { - return { - taskMRs: { ...d.taskMRs, ...s.taskMRs }, - gitlabReviewWatches: { ...d.gitlabReviewWatches, ...s.gitlabReviewWatches }, - gitlabIssueWatches: { ...d.gitlabIssueWatches, ...s.gitlabIssueWatches }, - gitlabMRWatches: { ...d.gitlabMRWatches, ...s.gitlabMRWatches }, - gitlabActionPresets: { ...d.gitlabActionPresets, ...s.gitlabActionPresets }, - gitlabStats: { ...d.gitlabStats, ...s.gitlabStats }, - gitlabStatus: { ...d.gitlabStatus, ...s.gitlabStatus }, - }; -} - function mergeQuickChatState(initialState: Partial): DefaultState["quickChat"] { const quickChat = { ...defaultState.quickChat, ...initialState.quickChat }; if (!initialState.quickChat?.sessions) return quickChat; @@ -148,95 +69,50 @@ function mergeQuickChatState(initialState: Partial): DefaultState[ export function mergeInitialState(initialState?: Partial): DefaultState { if (!initialState) return defaultState; + const knownInitialState = pickDefaultStateFields(initialState); return { ...defaultState, - ...initialState, - // Ensure nested objects are properly merged - kanban: { ...defaultState.kanban, ...initialState.kanban }, - kanbanMulti: { ...defaultState.kanbanMulti, ...initialState.kanbanMulti }, - workflows: { ...defaultState.workflows, ...initialState.workflows }, + ...knownInitialState, + workflows: { + ...defaultState.workflows, + activeId: initialState.workflows?.activeId ?? defaultState.workflows.activeId, + }, tasks: { ...defaultState.tasks, ...initialState.tasks }, - workspaces: { ...defaultState.workspaces, ...initialState.workspaces }, - repositories: { ...defaultState.repositories, ...initialState.repositories }, - repositoryBranches: { ...defaultState.repositoryBranches, ...initialState.repositoryBranches }, - repositoryScripts: { ...defaultState.repositoryScripts, ...initialState.repositoryScripts }, - executors: { ...defaultState.executors, ...initialState.executors }, - settingsAgents: { ...defaultState.settingsAgents, ...initialState.settingsAgents }, - agentDiscovery: { ...defaultState.agentDiscovery, ...initialState.agentDiscovery }, - availableAgents: { ...defaultState.availableAgents, ...initialState.availableAgents }, - agentProfiles: { ...defaultState.agentProfiles, ...initialState.agentProfiles }, - editors: { ...defaultState.editors, ...initialState.editors }, - prompts: { ...defaultState.prompts, ...initialState.prompts }, - secrets: { ...defaultState.secrets, ...initialState.secrets }, - notificationProviders: { - ...defaultState.notificationProviders, - ...initialState.notificationProviders, + workspaces: { + ...defaultState.workspaces, + activeId: initialState.workspaces?.activeId ?? defaultState.workspaces.activeId, }, - settingsData: { ...defaultState.settingsData, ...initialState.settingsData }, userSettings: { ...defaultState.userSettings, ...initialState.userSettings }, messages: { ...defaultState.messages, ...initialState.messages }, turns: { ...defaultState.turns, ...initialState.turns }, taskSessions: { ...defaultState.taskSessions, ...initialState.taskSessions }, taskSessionsByTask: { ...defaultState.taskSessionsByTask, ...initialState.taskSessionsByTask }, sessionAgentctl: { ...defaultState.sessionAgentctl, ...initialState.sessionAgentctl }, - worktrees: { ...defaultState.worktrees, ...initialState.worktrees }, - sessionWorktreesBySessionId: { - ...defaultState.sessionWorktreesBySessionId, - ...initialState.sessionWorktreesBySessionId, - }, - pendingModel: { ...defaultState.pendingModel, ...initialState.pendingModel }, activeModel: { ...defaultState.activeModel, ...initialState.activeModel }, taskPlans: { ...defaultState.taskPlans, ...initialState.taskPlans }, walkthroughs: { ...defaultState.walkthroughs, ...initialState.walkthroughs }, - queue: { ...defaultState.queue, ...initialState.queue }, - terminal: { ...defaultState.terminal, ...initialState.terminal }, shell: { ...defaultState.shell, ...initialState.shell }, processes: { ...defaultState.processes, ...initialState.processes }, gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus }, sessionCommits: { ...defaultState.sessionCommits, ...initialState.sessionCommits }, contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow }, - agents: { ...defaultState.agents, ...initialState.agents }, - sessionMode: { ...defaultState.sessionMode, ...initialState.sessionMode }, userShells: { ...defaultState.userShells, ...initialState.userShells }, prepareProgress: { ...defaultState.prepareProgress, ...initialState.prepareProgress }, - sessionTodos: { ...defaultState.sessionTodos, ...initialState.sessionTodos }, - agentCapabilities: { ...defaultState.agentCapabilities, ...initialState.agentCapabilities }, sessionModels: { ...defaultState.sessionModels, ...initialState.sessionModels }, - promptUsage: { ...defaultState.promptUsage, ...initialState.promptUsage }, - sessionPollMode: { ...defaultState.sessionPollMode, ...initialState.sessionPollMode }, - githubStatus: { ...defaultState.githubStatus, ...initialState.githubStatus }, - taskPRs: { ...defaultState.taskPRs, ...initialState.taskPRs }, pendingPrUrlByTaskId: { ...defaultState.pendingPrUrlByTaskId, ...initialState.pendingPrUrlByTaskId, }, - prWatches: { ...defaultState.prWatches, ...initialState.prWatches }, - reviewWatches: { ...defaultState.reviewWatches, ...initialState.reviewWatches }, - issueWatches: { ...defaultState.issueWatches, ...initialState.issueWatches }, - actionPresets: { ...defaultState.actionPresets, ...initialState.actionPresets }, prFeedbackCache: { ...defaultState.prFeedbackCache, ...initialState.prFeedbackCache }, - taskCIAutomation: { ...defaultState.taskCIAutomation, ...initialState.taskCIAutomation }, - ...mergeGitLabFields(defaultState, initialState), - jiraIssueWatches: { ...defaultState.jiraIssueWatches, ...initialState.jiraIssueWatches }, - linearIssueWatches: { - ...defaultState.linearIssueWatches, - ...initialState.linearIssueWatches, - }, office: { ...defaultState.office, ...initialState.office }, - features: { ...defaultState.features, ...initialState.features }, - automations: { ...defaultState.automations, ...initialState.automations }, - automationRuns: { ...defaultState.automationRuns, ...initialState.automationRuns }, - system: { ...defaultState.system, ...initialState.system }, previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel }, rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel }, - diffs: { ...defaultState.diffs, ...initialState.diffs }, connection: { ...defaultState.connection, ...initialState.connection }, mobileKanban: { ...defaultState.mobileKanban, ...initialState.mobileKanban }, mobileSession: { ...defaultState.mobileSession, ...initialState.mobileSession }, chatInput: { ...defaultState.chatInput, ...initialState.chatInput }, documentPanel: { ...defaultState.documentPanel, ...initialState.documentPanel }, - systemHealth: { ...defaultState.systemHealth, ...initialState.systemHealth }, quickChat: mergeQuickChatState(initialState), sessionFailureNotification: initialState.sessionFailureNotification ?? defaultState.sessionFailureNotification, @@ -246,3 +122,14 @@ export function mergeInitialState(initialState?: Partial): Default initialState.collapsedSubtaskParents ?? defaultState.collapsedSubtaskParents, }; } + +function pickDefaultStateFields(initialState: Partial): Partial { + const picked: Record = {}; + const source = initialState as Record; + for (const key of Object.keys(defaultState)) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + picked[key] = source[key]; + } + } + return picked as Partial; +} diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index a39c10c91a..509c8166d0 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -2,7 +2,7 @@ import type { Draft } from "immer"; import type { AppState } from "../store"; import { migrateView } from "../slices/ui/ui-slice"; import { getStoredQuickChatNames } from "@/lib/local-storage"; -import { deepMerge, mergeSessionMap, mergeLoadingState } from "./merge-strategies"; +import { deepMerge, mergeSessionMap } from "./merge-strategies"; /** * Hydration options for controlling merge behavior @@ -16,67 +16,19 @@ export type HydrationOptions = { forceMergeSessionId?: string | null; }; -/** Deep-merge a field with optional loading state preservation. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function mergeWithLoading(draft: any, source: any | undefined): void { - if (!source) return; - deepMerge(draft, source); - mergeLoadingState(draft, source); -} - -/** Merge kanban tasks by ID, keeping the version with the newer updatedAt timestamp. */ -function mergeKanbanTasks( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - draft: Draft, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - source: any[] | undefined, -): void { - if (!source || source.length === 0) return; - const draftTasks = draft.tasks as Array<{ id: string; updatedAt?: string }>; - const existingById = new Map(draftTasks.map((t) => [t.id, t])); - - for (const incoming of source) { - const existing = existingById.get(incoming.id); - if (!existing) { - draftTasks.push(incoming); - } else { - const existingTime = existing.updatedAt ? new Date(existing.updatedAt).getTime() : 0; - const incomingTime = incoming.updatedAt ? new Date(incoming.updatedAt).getTime() : 0; - if (incomingTime >= existingTime) { - const idx = draftTasks.findIndex((t) => t.id === incoming.id); - if (idx >= 0) draftTasks[idx] = incoming; - } - } +/** Hydrate navigation and workspace slices. */ +function hydrateNavigationAndWorkspace(draft: Draft, state: Partial): void { + if (state.workflows) { + draft.workflows.activeId = state.workflows.activeId ?? draft.workflows.activeId; } -} - -/** Hydrate kanban and workspace slices. */ -function hydrateKanbanAndWorkspace(draft: Draft, state: Partial): void { - if (state.kanban) { - // Merge tasks by ID with timestamp comparison to avoid overwriting fresher WS data - const { tasks, ...kanbanRest } = state.kanban; - if (Object.keys(kanbanRest).length > 0) deepMerge(draft.kanban, kanbanRest); - mergeKanbanTasks(draft.kanban, tasks); - } - if (state.kanbanMulti) deepMerge(draft.kanbanMulti, state.kanbanMulti); - if (state.workflows) deepMerge(draft.workflows, state.workflows); if (state.tasks) deepMerge(draft.tasks, state.tasks); - if (state.workspaces) deepMerge(draft.workspaces, state.workspaces); - if (state.repositories) deepMerge(draft.repositories, state.repositories); - if (state.repositoryBranches) deepMerge(draft.repositoryBranches, state.repositoryBranches); + if (state.workspaces) { + draft.workspaces.activeId = state.workspaces.activeId ?? draft.workspaces.activeId; + } } /** Hydrate settings slices, preserving loading states. */ function hydrateSettings(draft: Draft, state: Partial): void { - if (state.executors) deepMerge(draft.executors, state.executors); - if (state.settingsAgents) deepMerge(draft.settingsAgents, state.settingsAgents); - if (state.agentDiscovery) deepMerge(draft.agentDiscovery, state.agentDiscovery); - mergeWithLoading(draft.availableAgents, state.availableAgents); - if (state.agentProfiles) deepMerge(draft.agentProfiles, state.agentProfiles); - mergeWithLoading(draft.editors, state.editors); - mergeWithLoading(draft.prompts, state.prompts); - mergeWithLoading(draft.notificationProviders, state.notificationProviders); - if (state.settingsData) deepMerge(draft.settingsData, state.settingsData); if (state.userSettings && !draft.userSettings.loaded) { deepMerge(draft.userSettings, state.userSettings); bridgeSidebarViewsFromUserSettings(draft, state.userSettings); @@ -163,10 +115,6 @@ function hydrateSession( forceMergeSessionId, ); } - if (state.worktrees) deepMerge(draft.worktrees, state.worktrees); - if (state.sessionWorktreesBySessionId) - deepMerge(draft.sessionWorktreesBySessionId, state.sessionWorktreesBySessionId); - if (state.pendingModel) deepMerge(draft.pendingModel, state.pendingModel); if (state.activeModel) deepMerge(draft.activeModel, state.activeModel); } @@ -177,7 +125,6 @@ function hydrateSessionRuntime( activeSessionId: string | null, forceMergeSessionId: string | null, ): void { - if (state.terminal) deepMerge(draft.terminal, state.terminal); if (state.shell) { mergeSessionMap( draft.shell.outputs, @@ -212,7 +159,6 @@ function hydrateSessionRuntime( if (state.environmentIdBySessionId) { Object.assign(draft.environmentIdBySessionId, state.environmentIdBySessionId); } - if (state.agents) deepMerge(draft.agents, state.agents); if (state.prepareProgress) { mergeSessionMap( draft.prepareProgress.bySessionId, @@ -227,7 +173,6 @@ function hydrateSessionRuntime( export function hydrateUI(draft: Draft, state: Partial): void { if (state.previewPanel) deepMerge(draft.previewPanel, state.previewPanel); if (state.rightPanel) deepMerge(draft.rightPanel, state.rightPanel); - if (state.diffs) deepMerge(draft.diffs, state.diffs); if (state.quickChat) { // Merge quick chat sessions, preserving isOpen from client if (state.quickChat.sessions) { @@ -280,7 +225,7 @@ export function hydrateState( forceMergeSessionId = null, } = options; - hydrateKanbanAndWorkspace(draft, state); + hydrateNavigationAndWorkspace(draft, state); hydrateSettings(draft, state); hydrateSession(draft, state, activeSessionId, forceMergeSessionId); @@ -295,18 +240,12 @@ export function hydrateState( if (state.office) { Object.assign(draft.office, state.office); } - - // Feature flags — overwrite whole map. SSR is authoritative; the backend - // is the only source of truth for what's enabled in this deployment. - if (state.features) { - Object.assign(draft.features, state.features); - } } /** Hydrate GitHub slices, preserving loading states. */ function hydrateGitHub(draft: Draft, state: Partial): void { - if (state.githubStatus) mergeWithLoading(draft.githubStatus, state.githubStatus); - if (state.taskPRs) deepMerge(draft.taskPRs, state.taskPRs); - if (state.prWatches) mergeWithLoading(draft.prWatches, state.prWatches); - if (state.reviewWatches) mergeWithLoading(draft.reviewWatches, state.reviewWatches); + if (state.pendingPrUrlByTaskId) { + deepMerge(draft.pendingPrUrlByTaskId, state.pendingPrUrlByTaskId); + } + if (state.prFeedbackCache) deepMerge(draft.prFeedbackCache, state.prFeedbackCache); } diff --git a/apps/web/lib/state/slices/automations/automations-slice.ts b/apps/web/lib/state/slices/automations/automations-slice.ts deleted file mode 100644 index 37e578664d..0000000000 --- a/apps/web/lib/state/slices/automations/automations-slice.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { StateCreator } from "zustand"; -import type { AutomationsSlice, AutomationsSliceState } from "./types"; - -export const defaultAutomationsState: AutomationsSliceState = { - automations: { items: [], loaded: false, loading: false }, - automationRuns: { byAutomationId: {}, loading: {} }, -}; - -type ImmerSet = Parameters< - StateCreator ->[0]; - -function createAutomationsActions( - set: ImmerSet, -): Pick< - AutomationsSlice, - | "setAutomations" - | "setAutomationsLoading" - | "addAutomation" - | "updateAutomation" - | "removeAutomation" -> { - return { - setAutomations: (items) => - set((draft) => { - draft.automations.items = items; - draft.automations.loaded = true; - }), - setAutomationsLoading: (loading) => - set((draft) => { - draft.automations.loading = loading; - }), - addAutomation: (automation) => - set((draft) => { - draft.automations.items.unshift(automation); - }), - updateAutomation: (automation) => - set((draft) => { - const idx = draft.automations.items.findIndex((a) => a.id === automation.id); - if (idx >= 0) { - draft.automations.items[idx] = automation; - } - }), - removeAutomation: (id) => - set((draft) => { - draft.automations.items = draft.automations.items.filter((a) => a.id !== id); - }), - }; -} - -function createRunsActions( - set: ImmerSet, -): Pick< - AutomationsSlice, - | "setAutomationRuns" - | "setAutomationRunsLoading" - | "removeAutomationRun" - | "clearAutomationRuns" - | "restoreAutomationRun" -> { - return { - setAutomationRuns: (automationId, runs) => - set((draft) => { - draft.automationRuns.byAutomationId[automationId] = runs; - }), - setAutomationRunsLoading: (automationId, loading) => - set((draft) => { - draft.automationRuns.loading[automationId] = loading; - }), - removeAutomationRun: (automationId, runId) => - set((draft) => { - const runs = draft.automationRuns.byAutomationId[automationId]; - if (runs) { - draft.automationRuns.byAutomationId[automationId] = runs.filter((r) => r.id !== runId); - } - }), - clearAutomationRuns: (automationId) => - set((draft) => { - draft.automationRuns.byAutomationId[automationId] = []; - }), - // Re-inserts a single run that was optimistically removed but whose - // deletion could not be confirmed AND whose recovery refresh also - // failed — the last-resort fallback so the UI never silently drifts - // from a known state. Idempotent (no-op if already present) and keeps - // the list sorted by created_at desc, matching the server's ordering. - restoreAutomationRun: (automationId, run) => - set((draft) => { - const runs = draft.automationRuns.byAutomationId[automationId] ?? []; - if (runs.some((r) => r.id === run.id)) return; - const insertAt = runs.findIndex((r) => r.created_at < run.created_at); - const next = runs.slice(); - if (insertAt === -1) { - next.push(run); - } else { - next.splice(insertAt, 0, run); - } - draft.automationRuns.byAutomationId[automationId] = next; - }), - }; -} - -export const createAutomationsSlice: StateCreator< - AutomationsSlice, - [["zustand/immer", never]], - [], - AutomationsSlice -> = (set, _get, _api) => ({ - ...defaultAutomationsState, - ...createAutomationsActions(set), - ...createRunsActions(set), -}); diff --git a/apps/web/lib/state/slices/automations/index.ts b/apps/web/lib/state/slices/automations/index.ts deleted file mode 100644 index 8f74e4597e..0000000000 --- a/apps/web/lib/state/slices/automations/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { createAutomationsSlice, defaultAutomationsState } from "./automations-slice"; -export type { - AutomationsSlice, - AutomationsSliceState, - AutomationsSliceActions, - AutomationsState, - AutomationRunsState, -} from "./types"; diff --git a/apps/web/lib/state/slices/automations/types.ts b/apps/web/lib/state/slices/automations/types.ts deleted file mode 100644 index 737a9e6f48..0000000000 --- a/apps/web/lib/state/slices/automations/types.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Automation, AutomationRun } from "@/lib/types/automation"; - -export type AutomationsState = { - items: Automation[]; - loaded: boolean; - loading: boolean; -}; - -export type AutomationRunsState = { - byAutomationId: Record; - loading: Record; -}; - -export type AutomationsSliceState = { - automations: AutomationsState; - automationRuns: AutomationRunsState; -}; - -export type AutomationsSliceActions = { - setAutomations: (items: Automation[]) => void; - setAutomationsLoading: (loading: boolean) => void; - addAutomation: (automation: Automation) => void; - updateAutomation: (automation: Automation) => void; - removeAutomation: (id: string) => void; - setAutomationRuns: (automationId: string, runs: AutomationRun[]) => void; - setAutomationRunsLoading: (automationId: string, loading: boolean) => void; - removeAutomationRun: (automationId: string, runId: string) => void; - clearAutomationRuns: (automationId: string) => void; - restoreAutomationRun: (automationId: string, run: AutomationRun) => void; -}; - -export type AutomationsSlice = AutomationsSliceState & AutomationsSliceActions; diff --git a/apps/web/lib/state/slices/features/features-slice.test.ts b/apps/web/lib/state/slices/features/features-slice.test.ts index c4f907f973..c23ea6e335 100644 --- a/apps/web/lib/state/slices/features/features-slice.test.ts +++ b/apps/web/lib/state/slices/features/features-slice.test.ts @@ -1,33 +1,13 @@ import { describe, expect, it } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createFeaturesSlice, defaultFeaturesState } from "./features-slice"; -import type { FeaturesSlice } from "./types"; - -function makeStore() { - return create()( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - immer((...a) => ({ ...(createFeaturesSlice as any)(...a) })), - ); -} +import { defaultFeaturesState } from "./features-slice"; describe("features slice", () => { // Production-safety invariant: every flag must be false out of the box. // If this test starts failing because a new flag was added defaulting to // true, that is the bug — fix the default, not the test. it("defaults every flag to false", () => { - const store = makeStore(); for (const [name, value] of Object.entries(defaultFeaturesState.features)) { expect(value, `default of features.${name}`).toBe(false); } - expect(store.getState().features.office).toBe(false); - }); - - it("setFeatures replaces the whole flag map", () => { - const store = makeStore(); - store.getState().setFeatures({ office: true }); - expect(store.getState().features.office).toBe(true); - store.getState().setFeatures({ office: false }); - expect(store.getState().features.office).toBe(false); }); }); diff --git a/apps/web/lib/state/slices/features/features-slice.ts b/apps/web/lib/state/slices/features/features-slice.ts index 39083660a6..81ad6a968d 100644 --- a/apps/web/lib/state/slices/features/features-slice.ts +++ b/apps/web/lib/state/slices/features/features-slice.ts @@ -1,28 +1,10 @@ -import type { StateCreator } from "zustand"; -import type { FeaturesSlice, FeaturesSliceState } from "./types"; +import type { FeatureFlags } from "./types"; -export const defaultFeaturesState: FeaturesSliceState = { +export const defaultFeaturesState: { features: FeatureFlags } = { // All flags default to false. Production releases ship every flag off // until the deployment opts in via env var (e.g. KANDEV_FEATURES_OFFICE). - // The SSR layer overwrites this with whatever the backend reports. + // Query boot seeding overwrites this with whatever the backend reports. features: { office: false, }, }; - -type ImmerSet = Parameters< - StateCreator ->[0]; - -export const createFeaturesSlice: StateCreator< - FeaturesSlice, - [["zustand/immer", never]], - [], - FeaturesSlice -> = (set: ImmerSet) => ({ - ...defaultFeaturesState, - setFeatures: (features) => - set((draft) => { - draft.features = features; - }), -}); diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 39f67dd65f..a4552d2426 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -9,13 +9,3 @@ export type FeatureFlags = { }; export type FeatureName = keyof FeatureFlags; - -export type FeaturesSliceState = { - features: FeatureFlags; -}; - -export type FeaturesSliceActions = { - setFeatures: (features: FeatureFlags) => void; -}; - -export type FeaturesSlice = FeaturesSliceState & FeaturesSliceActions; diff --git a/apps/web/lib/state/slices/github/github-slice.test.ts b/apps/web/lib/state/slices/github/github-slice.test.ts index 33c4a3f08b..38b7c825da 100644 --- a/apps/web/lib/state/slices/github/github-slice.test.ts +++ b/apps/web/lib/state/slices/github/github-slice.test.ts @@ -1,264 +1,56 @@ -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { createGitHubSlice } from "./github-slice"; import type { GitHubSlice } from "./types"; -import type { GitHubStatus, TaskCIAutomationOptions, TaskPR } from "@/lib/types/github"; - -function makePR(overrides: Partial = {}): TaskPR { - return { - id: "id", - task_id: "task-1", - owner: "o", - repo: "r", - pr_number: 1, - pr_url: "", - pr_title: "Test PR", - head_branch: "feat", - base_branch: "main", - author_login: "alice", - state: "open", - review_state: "", - checks_state: "", - mergeable_state: "", - review_count: 0, - pending_review_count: 0, - comment_count: 0, - unresolved_review_threads: 0, - checks_total: 0, - checks_passing: 0, - additions: 0, - deletions: 0, - created_at: "", - merged_at: null, - closed_at: null, - last_synced_at: null, - updated_at: "", - ...overrides, - }; -} +import type { PRFeedback } from "@/lib/types/github"; function makeStore() { return create()(immer((...a) => createGitHubSlice(...a))); } -function makeCIOptions(overrides: Partial = {}): TaskCIAutomationOptions { - return { - task_id: "task-1", - auto_fix_enabled: false, - auto_merge_enabled: false, - auto_fix_prompt_override: null, - effective_auto_fix_prompt: "Default CI prompt", - using_default_prompt: true, - updated_at: "2026-06-18T10:00:00Z", - pr_states: [], - ...overrides, - }; -} - -const FUTURE_RESET = "2030-01-01T00:00:00Z"; -const NOW = "2026-05-04T12:00:00Z"; - -const baseStatus: GitHubStatus = { - authenticated: true, - username: "octocat", - auth_method: "pat", - token_configured: true, - required_scopes: ["repo"], -}; - -describe("applyGitHubRateLimitUpdate", () => { - it("merges incoming snapshots into the existing status", () => { +describe("GitHub local pending PR URLs", () => { + it("stores and clears pending URLs by task and repo key", () => { const store = makeStore(); - store.getState().setGitHubStatus({ ...baseStatus }); - - store.getState().applyGitHubRateLimitUpdate({ - trigger: "graphql", - snapshots: [ - { - resource: "graphql", - remaining: 0, - limit: 5000, - reset_at: FUTURE_RESET, - updated_at: NOW, - }, - { - resource: "core", - remaining: 4500, - limit: 5000, - reset_at: FUTURE_RESET, - updated_at: NOW, - }, - ], - }); - - const status = store.getState().githubStatus.status; - expect(status?.rate_limit?.graphql?.remaining).toBe(0); - expect(status?.rate_limit?.graphql?.limit).toBe(5000); - expect(status?.rate_limit?.core?.remaining).toBe(4500); - }); - - it("overwrites only the resources present in the update", () => { - const store = makeStore(); - store.getState().setGitHubStatus({ - ...baseStatus, - rate_limit: { - core: { - resource: "core", - remaining: 4500, - limit: 5000, - reset_at: FUTURE_RESET, - updated_at: NOW, - }, - }, - }); - - store.getState().applyGitHubRateLimitUpdate({ - trigger: "graphql", - snapshots: [ - { - resource: "graphql", - remaining: 100, - limit: 5000, - reset_at: FUTURE_RESET, - updated_at: NOW, - }, - ], - }); - - const rl = store.getState().githubStatus.status?.rate_limit; - expect(rl?.core?.remaining).toBe(4500); // untouched - expect(rl?.graphql?.remaining).toBe(100); - }); - - it("is a no-op when status has not been hydrated yet", () => { - const store = makeStore(); - expect(store.getState().githubStatus.status).toBeNull(); - - store.getState().applyGitHubRateLimitUpdate({ - trigger: "core", - snapshots: [ - { - resource: "core", - remaining: 0, - limit: 5000, - reset_at: FUTURE_RESET, - updated_at: NOW, - }, - ], - }); - - expect(store.getState().githubStatus.status).toBeNull(); - }); -}); - -describe("setTaskPR", () => { - it("appends a PR when the task has no rows yet", () => { - const store = makeStore(); - const pr = makePR({ repository_id: "repo-a" }); - - store.getState().setTaskPR("task-1", pr); - - expect(store.getState().taskPRs.byTaskId["task-1"]).toEqual([pr]); - }); - it("upserts by repository_id so multi-repo PRs coexist", () => { - const store = makeStore(); - const prA = makePR({ id: "a", repository_id: "repo-a", pr_number: 1 }); - const prB = makePR({ id: "b", repository_id: "repo-b", pr_number: 2 }); - const prAUpdated = makePR({ id: "a", repository_id: "repo-a", pr_number: 1, additions: 10 }); - - store.getState().setTaskPR("task-1", prA); - store.getState().setTaskPR("task-1", prB); - store.getState().setTaskPR("task-1", prAUpdated); - - const list = store.getState().taskPRs.byTaskId["task-1"]; - expect(list).toHaveLength(2); - expect(list.find((p) => p.repository_id === "repo-a")?.additions).toBe(10); - expect(list.find((p) => p.repository_id === "repo-b")?.id).toBe("b"); - }); - - it("keeps multi-branch PRs as siblings (same repo, different pr_number)", () => { - const store = makeStore(); - const pr1 = makePR({ id: "p1", repository_id: "repo-a", pr_number: 1221 }); - const pr2 = makePR({ id: "p2", repository_id: "repo-a", pr_number: 1222 }); - const pr1Updated = makePR({ - id: "p1", - repository_id: "repo-a", - pr_number: 1221, - additions: 99, - }); - - store.getState().setTaskPR("task-1", pr1); - store.getState().setTaskPR("task-1", pr2); - store.getState().setTaskPR("task-1", pr1Updated); - - const list = store.getState().taskPRs.byTaskId["task-1"]; - expect(list).toHaveLength(2); - expect(list.find((p) => p.pr_number === 1221)?.additions).toBe(99); - expect(list.find((p) => p.pr_number === 1222)?.id).toBe("p2"); - }); - - it("heals a corrupted non-array entry instead of throwing", () => { - // Simulates a stray payload landing in byTaskId[taskId] as something other - // than an array (e.g. a partial hydration). The next setTaskPR call must - // recover rather than propagate the bad shape, otherwise downstream - // renderers crash with `prs is not iterable`. - const store = makeStore(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - store.getState().setTaskPRs({ "task-1": {} as any }); - - const pr = makePR({ repository_id: "repo-a" }); - expect(() => store.getState().setTaskPR("task-1", pr)).not.toThrow(); - - expect(Array.isArray(store.getState().taskPRs.byTaskId["task-1"])).toBe(true); - expect(store.getState().taskPRs.byTaskId["task-1"]).toEqual([pr]); - }); -}); - -describe("setPendingPrUrlForTask", () => { - it("stores a pending PR URL until TaskPR sync clears it", () => { - const store = makeStore(); - - store - .getState() - .setPendingPrUrlForTask("task-1", "", "https://dev.azure.com/o/p/_git/r/pullrequest/1"); - expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.[""]).toBe( - "https://dev.azure.com/o/p/_git/r/pullrequest/1", + store.getState().setPendingPrUrlForTask("task-1", "repo-a", " https://example/pr/1 "); + expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.["repo-a"]).toBe( + "https://example/pr/1", ); - store.getState().setTaskPR("task-1", makePR()); + store.getState().setPendingPrUrlForTask("task-1", "repo-a", ""); expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]).toBeUndefined(); }); - it("clears only the synced repo pending URL in multi-repo tasks", () => { + it("clears only the synced PR pending URL", () => { const store = makeStore(); - const urlA = "https://dev.azure.com/o/p/_git/a/pullrequest/1"; - const urlB = "https://dev.azure.com/o/p/_git/b/pullrequest/2"; + const urlA = "https://example/pr/1"; + const urlB = "https://example/pr/2"; store.getState().setPendingPrUrlForTask("task-1", "repo-a", urlA); store.getState().setPendingPrUrlForTask("task-1", "repo-b", urlB); - store.getState().setTaskPR("task-1", makePR({ repository_id: "repo-a", pr_url: urlA })); + store + .getState() + .clearPendingPrUrlForTaskPR("task-1", { repository_id: "repo-a", pr_url: urlA }); - expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.["repo-b"]).toBe(urlB); expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.["repo-a"]).toBeUndefined(); + expect(store.getState().pendingPrUrlByTaskId.byTaskId["task-1"]?.["repo-b"]).toBe(urlB); }); }); -describe("task CI automation options", () => { - it("stores task options and per-task loading/saving/error state", () => { +describe("GitHub PR feedback cache", () => { + it("bounds cached feedback entries", () => { const store = makeStore(); - const options = makeCIOptions({ auto_fix_enabled: true }); - store.getState().setTaskCIAutomationLoading("task-1", true); - store.getState().setTaskCIAutomationSaving("task-1", true); - store.getState().setTaskCIAutomationError("task-1", "failed"); - store.getState().setTaskCIAutomationOptions("task-1", options); + for (let i = 0; i < 25; i++) { + store + .getState() + .setPRFeedbackCacheEntry(`o/r#${i}`, { pr: { pr_number: i } } as unknown as PRFeedback); + } - const state = store.getState().taskCIAutomation; - expect(state.loading["task-1"]).toBe(true); - expect(state.saving["task-1"]).toBe(true); - expect(state.errors["task-1"]).toBe("failed"); - expect(state.byTaskId["task-1"]).toEqual(options); + const keys = Object.keys(store.getState().prFeedbackCache.byKey); + expect(keys).toHaveLength(20); + expect(keys).not.toContain("o/r#0"); + expect(keys).toContain("o/r#24"); }); }); diff --git a/apps/web/lib/state/slices/github/github-slice.ts b/apps/web/lib/state/slices/github/github-slice.ts index 45bd45a0ba..b997d867ad 100644 --- a/apps/web/lib/state/slices/github/github-slice.ts +++ b/apps/web/lib/state/slices/github/github-slice.ts @@ -2,15 +2,8 @@ import type { StateCreator } from "zustand"; import type { GitHubSlice, GitHubSliceState } from "./types"; export const defaultGitHubState: GitHubSliceState = { - githubStatus: { status: null, loaded: false, loading: false }, - taskPRs: { byTaskId: {} }, pendingPrUrlByTaskId: { byTaskId: {} }, - prWatches: { items: [], loaded: false, loading: false }, - reviewWatches: { items: [], loaded: false, loading: false }, - issueWatches: { items: [], loaded: false, loading: false }, - actionPresets: { byWorkspaceId: {}, loading: {} }, prFeedbackCache: { byKey: {} }, - taskCIAutomation: { byTaskId: {}, loading: {}, saving: {}, errors: {} }, }; const PR_FEEDBACK_CACHE_LIMIT = 20; @@ -19,22 +12,6 @@ type ImmerSet = Parameters< StateCreator >[0]; -function createGitHubStatusActions( - set: ImmerSet, -): Pick { - return { - setGitHubStatus: (status) => - set((draft) => { - draft.githubStatus.status = status; - draft.githubStatus.loaded = true; - }), - setGitHubStatusLoading: (loading) => - set((draft) => { - draft.githubStatus.loading = loading; - }), - }; -} - function clearPendingPrUrlForRepo(draft: GitHubSlice, taskId: string, repoKey: string) { const pending = draft.pendingPrUrlByTaskId.byTaskId[taskId]; if (!pending) return; @@ -59,34 +36,10 @@ function clearPendingForTaskPR( } } -function createTaskPRActions( +function createPendingPrUrlActions( set: ImmerSet, -): Pick { +): Pick { return { - setTaskPRs: (prs) => - set((draft) => { - draft.taskPRs.byTaskId = prs; - }), - setTaskPR: (taskId, pr) => - set((draft) => { - // Upsert by (repository_id, pr_number) so multi-branch tasks can - // hold N PRs on the same repo as siblings. Keying on - // repository_id alone collapses every PR for that repo onto one - // slot — the second WS event silently overwrites the first and - // the UI shows only the most-recent PR. Legacy rows without a - // repository_id match on the empty key + pr_number, preserving - // prior single-PR semantics for single-repo tasks. - const current = draft.taskPRs.byTaskId[taskId]; - const existing = Array.isArray(current) ? current : []; - const repoKey = pr.repository_id ?? ""; - const idx = existing.findIndex( - (p) => (p.repository_id ?? "") === repoKey && p.pr_number === pr.pr_number, - ); - if (idx >= 0) existing[idx] = pr; - else existing.push(pr); - draft.taskPRs.byTaskId[taskId] = existing; - clearPendingForTaskPR(draft, taskId, pr); - }), setPendingPrUrlForTask: (taskId, repoKey, prUrl) => set((draft) => { const trimmed = prUrl.trim(); @@ -99,111 +52,9 @@ function createTaskPRActions( } draft.pendingPrUrlByTaskId.byTaskId[taskId][repoKey] = trimmed; }), - }; -} - -function createWatchActions( - set: ImmerSet, -): Pick< - GitHubSlice, - | "setPRWatches" - | "setPRWatchesLoading" - | "removePRWatch" - | "setReviewWatches" - | "setReviewWatchesLoading" - | "addReviewWatch" - | "updateReviewWatch" - | "removeReviewWatch" - | "setIssueWatches" - | "setIssueWatchesLoading" - | "addIssueWatch" - | "updateIssueWatch" - | "removeIssueWatch" -> { - return { - setPRWatches: (watches) => - set((draft) => { - draft.prWatches.items = watches; - draft.prWatches.loaded = true; - }), - setPRWatchesLoading: (loading) => - set((draft) => { - draft.prWatches.loading = loading; - }), - removePRWatch: (id) => - set((draft) => { - draft.prWatches.items = draft.prWatches.items.filter((w) => w.id !== id); - }), - setReviewWatches: (watches) => - set((draft) => { - draft.reviewWatches.items = watches; - draft.reviewWatches.loaded = true; - }), - setReviewWatchesLoading: (loading) => - set((draft) => { - draft.reviewWatches.loading = loading; - }), - addReviewWatch: (watch) => + clearPendingPrUrlForTaskPR: (taskId, pr) => set((draft) => { - draft.reviewWatches.items = [ - ...draft.reviewWatches.items.filter((w) => w.id !== watch.id), - watch, - ]; - draft.reviewWatches.loaded = true; - }), - updateReviewWatch: (watch) => - set((draft) => { - const idx = draft.reviewWatches.items.findIndex((w) => w.id === watch.id); - if (idx >= 0) { - draft.reviewWatches.items[idx] = watch; - } - }), - removeReviewWatch: (id) => - set((draft) => { - draft.reviewWatches.items = draft.reviewWatches.items.filter((w) => w.id !== id); - }), - setIssueWatches: (watches) => - set((draft) => { - draft.issueWatches.items = watches; - draft.issueWatches.loaded = true; - }), - setIssueWatchesLoading: (loading) => - set((draft) => { - draft.issueWatches.loading = loading; - }), - addIssueWatch: (watch) => - set((draft) => { - draft.issueWatches.items = [ - ...draft.issueWatches.items.filter((w) => w.id !== watch.id), - watch, - ]; - draft.issueWatches.loaded = true; - }), - updateIssueWatch: (watch) => - set((draft) => { - const idx = draft.issueWatches.items.findIndex((w) => w.id === watch.id); - if (idx >= 0) { - draft.issueWatches.items[idx] = watch; - } - }), - removeIssueWatch: (id) => - set((draft) => { - draft.issueWatches.items = draft.issueWatches.items.filter((w) => w.id !== id); - }), - }; -} - -function createActionPresetActions( - set: ImmerSet, -): Pick { - return { - setActionPresets: (workspaceId, presets) => - set((draft) => { - draft.actionPresets.byWorkspaceId[workspaceId] = presets; - }), - setActionPresetsLoading: (workspaceId, loading) => - set((draft) => { - draft.actionPresets.loading[workspaceId] = loading; + clearPendingForTaskPR(draft, taskId, pr); }), }; } @@ -233,53 +84,6 @@ function createPRFeedbackCacheActions( }; } -function createTaskCIAutomationActions( - set: ImmerSet, -): Pick< - GitHubSlice, - | "setTaskCIAutomationOptions" - | "setTaskCIAutomationLoading" - | "setTaskCIAutomationSaving" - | "setTaskCIAutomationError" -> { - return { - setTaskCIAutomationOptions: (taskId, options) => - set((draft) => { - draft.taskCIAutomation.byTaskId[taskId] = options; - }), - setTaskCIAutomationLoading: (taskId, loading) => - set((draft) => { - draft.taskCIAutomation.loading[taskId] = loading; - }), - setTaskCIAutomationSaving: (taskId, saving) => - set((draft) => { - draft.taskCIAutomation.saving[taskId] = saving; - }), - setTaskCIAutomationError: (taskId, error) => - set((draft) => { - draft.taskCIAutomation.errors[taskId] = error; - }), - }; -} - -function createRateLimitActions(set: ImmerSet): Pick { - return { - applyGitHubRateLimitUpdate: (update) => - set((draft) => { - const existing = draft.githubStatus.status; - if (!existing) { - // Status not yet hydrated; defer until the SSR/HTTP fetch lands. - return; - } - const rateLimit = { ...(existing.rate_limit ?? {}) }; - for (const snap of update.snapshots) { - rateLimit[snap.resource] = snap; - } - draft.githubStatus.status = { ...existing, rate_limit: rateLimit }; - }), - }; -} - export const createGitHubSlice: StateCreator< GitHubSlice, [["zustand/immer", never]], @@ -287,11 +91,6 @@ export const createGitHubSlice: StateCreator< GitHubSlice > = (set) => ({ ...defaultGitHubState, - ...createGitHubStatusActions(set), - ...createTaskPRActions(set), - ...createWatchActions(set), - ...createActionPresetActions(set), - ...createRateLimitActions(set), + ...createPendingPrUrlActions(set), ...createPRFeedbackCacheActions(set), - ...createTaskCIAutomationActions(set), }); diff --git a/apps/web/lib/state/slices/github/types.ts b/apps/web/lib/state/slices/github/types.ts index 3183b5812d..3b65e6b8f6 100644 --- a/apps/web/lib/state/slices/github/types.ts +++ b/apps/web/lib/state/slices/github/types.ts @@ -1,25 +1,4 @@ -import type { - GitHubStatus, - GitHubRateLimitUpdate, - TaskPR, - PRWatch, - ReviewWatch, - IssueWatch, - GitHubActionPresets, - PRFeedback, - TaskCIAutomationOptions, -} from "@/lib/types/github"; - -export type GitHubStatusState = { - status: GitHubStatus | null; - loaded: boolean; - loading: boolean; -}; - -export type TaskPRsState = { - /** Each task may have multiple PRs (one per repository for multi-repo tasks). */ - byTaskId: Record; -}; +import type { PRFeedback } from "@/lib/types/github"; export type PendingPrUrlsState = { /** @@ -29,29 +8,6 @@ export type PendingPrUrlsState = { byTaskId: Record>; }; -export type PRWatchesState = { - items: PRWatch[]; - loaded: boolean; - loading: boolean; -}; - -export type ReviewWatchesState = { - items: ReviewWatch[]; - loaded: boolean; - loading: boolean; -}; - -export type IssueWatchesState = { - items: IssueWatch[]; - loaded: boolean; - loading: boolean; -}; - -export type ActionPresetsState = { - byWorkspaceId: Record; - loading: Record; -}; - export type PRFeedbackCacheEntry = { feedback: PRFeedback; lastUpdatedAt: number; @@ -62,53 +18,19 @@ export type PRFeedbackCacheState = { byKey: Record; }; -export type TaskCIAutomationOptionsState = { - byTaskId: Record; - loading: Record; - saving: Record; - errors: Record; -}; - export type GitHubSliceState = { - githubStatus: GitHubStatusState; - taskPRs: TaskPRsState; pendingPrUrlByTaskId: PendingPrUrlsState; - prWatches: PRWatchesState; - reviewWatches: ReviewWatchesState; - issueWatches: IssueWatchesState; - actionPresets: ActionPresetsState; prFeedbackCache: PRFeedbackCacheState; - taskCIAutomation: TaskCIAutomationOptionsState; }; export type GitHubSliceActions = { - setGitHubStatus: (status: GitHubStatus | null) => void; - setGitHubStatusLoading: (loading: boolean) => void; - setTaskPRs: (prs: Record) => void; - setTaskPR: (taskId: string, pr: TaskPR) => void; setPendingPrUrlForTask: (taskId: string, repoKey: string, prUrl: string) => void; - setPRWatches: (watches: PRWatch[]) => void; - setPRWatchesLoading: (loading: boolean) => void; - removePRWatch: (id: string) => void; - setReviewWatches: (watches: ReviewWatch[]) => void; - setReviewWatchesLoading: (loading: boolean) => void; - addReviewWatch: (watch: ReviewWatch) => void; - updateReviewWatch: (watch: ReviewWatch) => void; - removeReviewWatch: (id: string) => void; - setIssueWatches: (watches: IssueWatch[]) => void; - setIssueWatchesLoading: (loading: boolean) => void; - addIssueWatch: (watch: IssueWatch) => void; - updateIssueWatch: (watch: IssueWatch) => void; - removeIssueWatch: (id: string) => void; - setActionPresets: (workspaceId: string, presets: GitHubActionPresets) => void; - setActionPresetsLoading: (workspaceId: string, loading: boolean) => void; - applyGitHubRateLimitUpdate: (update: GitHubRateLimitUpdate) => void; + clearPendingPrUrlForTaskPR: ( + taskId: string, + pr: { repository_id?: string; pr_url?: string }, + ) => void; setPRFeedbackCacheEntry: (key: string, feedback: PRFeedback) => void; removePRFeedbackCacheEntry: (key: string) => void; - setTaskCIAutomationOptions: (taskId: string, options: TaskCIAutomationOptions) => void; - setTaskCIAutomationLoading: (taskId: string, loading: boolean) => void; - setTaskCIAutomationSaving: (taskId: string, saving: boolean) => void; - setTaskCIAutomationError: (taskId: string, error: string | null) => void; }; export type GitHubSlice = GitHubSliceState & GitHubSliceActions; diff --git a/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts b/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts deleted file mode 100644 index 62028eac4b..0000000000 --- a/apps/web/lib/state/slices/gitlab/gitlab-slice.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createGitLabSlice } from "./gitlab-slice"; -import type { GitLabSlice } from "./types"; -import type { TaskMR } from "@/lib/types/gitlab"; - -function makeMR(overrides: Partial = {}): TaskMR { - return { - id: "id", - task_id: "task-1", - host: "https://gitlab.com", - project_path: "acme/api", - mr_iid: 1, - mr_url: "https://gitlab.com/acme/api/-/merge_requests/1", - mr_title: "Test MR", - head_branch: "feat", - base_branch: "main", - author_username: "alice", - state: "open", - approval_state: "", - pipeline_state: "", - merge_status: "", - draft: false, - approval_count: 0, - required_approvals: 0, - pipeline_jobs_total: 0, - pipeline_jobs_pass: 0, - created_at: "", - updated_at: "", - ...overrides, - }; -} - -function makeStore() { - return create()(immer((...a) => createGitLabSlice(...a))); -} - -describe("setTaskMRs", () => { - it("replaces byTaskId wholesale", () => { - const store = makeStore(); - const mrA = makeMR({ id: "a" }); - store.getState().setTaskMRs({ "task-1": [mrA] }); - expect(store.getState().taskMRs.byTaskId["task-1"]).toEqual([mrA]); - - store.getState().setTaskMRs({ "task-2": [makeMR({ id: "b" })] }); - expect(store.getState().taskMRs.byTaskId).not.toHaveProperty("task-1"); - expect(store.getState().taskMRs.byTaskId["task-2"]).toHaveLength(1); - }); -}); - -describe("setTaskMR", () => { - it("appends an MR when the task has no rows yet", () => { - const store = makeStore(); - const mr = makeMR({ repository_id: "repo-a" }); - - store.getState().setTaskMR("task-1", mr); - - expect(store.getState().taskMRs.byTaskId["task-1"]).toEqual([mr]); - }); - - it("upserts the same (repo, project, iid) key in place", () => { - const store = makeStore(); - const original = makeMR({ id: "a", repository_id: "repo-a", mr_iid: 5 }); - const updated = makeMR({ - id: "a", - repository_id: "repo-a", - mr_iid: 5, - mr_title: "renamed", - }); - - store.getState().setTaskMR("task-1", original); - store.getState().setTaskMR("task-1", updated); - - const list = store.getState().taskMRs.byTaskId["task-1"]; - expect(list).toHaveLength(1); - expect(list[0]!.mr_title).toBe("renamed"); - }); - - it("keeps multi-repo MRs distinct on (repository_id, project_path, mr_iid)", () => { - const store = makeStore(); - // Same project + iid, different repositories — must coexist. - const repoA = makeMR({ id: "a", repository_id: "repo-a", mr_iid: 1 }); - const repoB = makeMR({ id: "b", repository_id: "repo-b", mr_iid: 1 }); - // Same repo, different project — must also coexist. - const repoAOtherProject = makeMR({ - id: "c", - repository_id: "repo-a", - project_path: "acme/web", - mr_iid: 1, - }); - // Same repo + project, different iid — must coexist (multiple open MRs). - const repoASecondMR = makeMR({ id: "d", repository_id: "repo-a", mr_iid: 99 }); - - store.getState().setTaskMR("task-1", repoA); - store.getState().setTaskMR("task-1", repoB); - store.getState().setTaskMR("task-1", repoAOtherProject); - store.getState().setTaskMR("task-1", repoASecondMR); - - const list = store.getState().taskMRs.byTaskId["task-1"]; - expect(list).toHaveLength(4); - expect(list.map((m) => m.id).sort()).toEqual(["a", "b", "c", "d"]); - }); - - it("treats missing repository_id as the empty key (single-repo tasks)", () => { - // A multi-repo MR with repository_id and a single-repo MR with the same - // (project_path, mr_iid) but no repository_id must still coexist — the - // empty repo key is its own slot. - const store = makeStore(); - const noRepo = makeMR({ id: "x", mr_iid: 1 }); - const withRepo = makeMR({ id: "y", repository_id: "repo-a", mr_iid: 1 }); - - store.getState().setTaskMR("task-1", noRepo); - store.getState().setTaskMR("task-1", withRepo); - - const list = store.getState().taskMRs.byTaskId["task-1"]; - expect(list).toHaveLength(2); - expect(list.map((m) => m.id).sort()).toEqual(["x", "y"]); - }); -}); - -describe("resetTaskMRs", () => { - it("clears byTaskId so a workspace switch doesn't leak previous MRs", () => { - const store = makeStore(); - store.getState().setTaskMR("task-1", makeMR({ id: "a" })); - expect(store.getState().taskMRs.byTaskId["task-1"]).toHaveLength(1); - - store.getState().resetTaskMRs(); - - expect(store.getState().taskMRs.byTaskId).toEqual({}); - }); -}); - -describe("review watches", () => { - it("set + add + update + remove round-trip", () => { - const store = makeStore(); - const watch = { - id: "w-1", - workspace_id: "ws-1", - workflow_id: "", - workflow_step_id: "", - projects: [{ path: "group/proj" }], - agent_profile_id: "", - executor_profile_id: "", - prompt: "", - review_scope: "user", - custom_query: "", - enabled: true, - poll_interval_seconds: 300, - cleanup_policy: "auto", - created_at: "", - updated_at: "", - }; - store.getState().setGitLabReviewWatches([watch]); - expect(store.getState().gitlabReviewWatches.items).toHaveLength(1); - expect(store.getState().gitlabReviewWatches.loaded).toBe(true); - - const watch2 = { ...watch, id: "w-2" }; - store.getState().addGitLabReviewWatch(watch2); - expect(store.getState().gitlabReviewWatches.items).toHaveLength(2); - - store.getState().updateGitLabReviewWatchInStore({ ...watch, prompt: "updated" }); - expect(store.getState().gitlabReviewWatches.items.find((w) => w.id === "w-1")?.prompt).toBe( - "updated", - ); - - store.getState().removeGitLabReviewWatch("w-1"); - expect(store.getState().gitlabReviewWatches.items).toHaveLength(1); - expect(store.getState().gitlabReviewWatches.items[0].id).toBe("w-2"); - }); -}); - -describe("issue watches", () => { - it("set + add + remove round-trip", () => { - const store = makeStore(); - const watch = { - id: "iw-1", - workspace_id: "ws-1", - workflow_id: "", - workflow_step_id: "", - projects: [], - agent_profile_id: "", - executor_profile_id: "", - prompt: "", - labels: ["bug"], - custom_query: "", - enabled: true, - poll_interval_seconds: 300, - cleanup_policy: "auto", - created_at: "", - updated_at: "", - }; - store.getState().setGitLabIssueWatches([watch]); - expect(store.getState().gitlabIssueWatches.items).toHaveLength(1); - - store.getState().removeGitLabIssueWatch("iw-1"); - expect(store.getState().gitlabIssueWatches.items).toHaveLength(0); - }); -}); - -describe("action presets + stats", () => { - it("upserts presets by workspace and stores stats with loadedAt timestamp", () => { - const store = makeStore(); - const presets = { - workspace_id: "ws-1", - mr: [{ id: "x", label: "x", hint: "", icon: "", prompt_template: "" }], - issue: [], - }; - store.getState().setGitLabActionPresets("ws-1", presets); - expect(store.getState().gitlabActionPresets.byWorkspaceId["ws-1"]).toEqual(presets); - - store.getState().setGitLabStats({ - open_mrs: 5, - mrs_awaiting_my_review: 2, - open_issues_assigned_me: 3, - }); - expect(store.getState().gitlabStats.data?.open_mrs).toBe(5); - expect(store.getState().gitlabStats.loadedAt).not.toBeNull(); - }); -}); diff --git a/apps/web/lib/state/slices/gitlab/gitlab-slice.ts b/apps/web/lib/state/slices/gitlab/gitlab-slice.ts deleted file mode 100644 index aedd41c1b4..0000000000 --- a/apps/web/lib/state/slices/gitlab/gitlab-slice.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { StateCreator } from "zustand"; -import type { GitLabSlice, GitLabSliceState } from "./types"; - -export const defaultGitLabState: GitLabSliceState = { - taskMRs: { byTaskId: {} }, - gitlabReviewWatches: { items: [], loaded: false, loading: false }, - gitlabIssueWatches: { items: [], loaded: false, loading: false }, - gitlabMRWatches: { items: [], loaded: false, loading: false }, - gitlabActionPresets: { byWorkspaceId: {}, loading: false }, - gitlabStats: { data: null, loading: false, loadedAt: null }, - gitlabStatus: { data: null, loading: false, loadedAt: null }, -}; - -type ImmerSet = Parameters< - StateCreator ->[0]; - -type GitLabSliceCreator = StateCreator; - -export const createGitLabSlice: GitLabSliceCreator = (set: ImmerSet) => ({ - ...defaultGitLabState, - ...taskMRActions(set), - ...reviewWatchActions(set), - ...issueWatchActions(set), - ...mrWatchActions(set), - ...presetActions(set), - ...statsActions(set), - ...statusActions(set), -}); - -function taskMRActions(set: ImmerSet) { - return { - setTaskMRs: (mrs: Record) => - set((draft) => { - draft.taskMRs.byTaskId = mrs; - }), - setTaskMR: (taskId: string, mr: GitLabSliceState["taskMRs"]["byTaskId"][string][number]) => - set((draft) => { - const existing = draft.taskMRs.byTaskId[taskId] ?? []; - const repoKey = mr.repository_id ?? ""; - const idx = existing.findIndex( - (m) => - (m.repository_id ?? "") === repoKey && - m.project_path === mr.project_path && - m.mr_iid === mr.mr_iid, - ); - if (idx >= 0) existing[idx] = mr; - else existing.push(mr); - draft.taskMRs.byTaskId[taskId] = existing; - }), - resetTaskMRs: () => - set((draft) => { - draft.taskMRs.byTaskId = {}; - }), - }; -} - -function reviewWatchActions(set: ImmerSet) { - return { - setGitLabReviewWatches: (watches: GitLabSliceState["gitlabReviewWatches"]["items"]) => - set((draft) => { - draft.gitlabReviewWatches.items = watches; - draft.gitlabReviewWatches.loaded = true; - }), - setGitLabReviewWatchesLoading: (loading: boolean) => - set((draft) => { - draft.gitlabReviewWatches.loading = loading; - }), - addGitLabReviewWatch: (watch: GitLabSliceState["gitlabReviewWatches"]["items"][number]) => - set((draft) => { - draft.gitlabReviewWatches.items = [...draft.gitlabReviewWatches.items, watch]; - }), - updateGitLabReviewWatchInStore: ( - watch: GitLabSliceState["gitlabReviewWatches"]["items"][number], - ) => - set((draft) => { - draft.gitlabReviewWatches.items = draft.gitlabReviewWatches.items.map((w) => - w.id === watch.id ? watch : w, - ); - }), - removeGitLabReviewWatch: (id: string) => - set((draft) => { - draft.gitlabReviewWatches.items = draft.gitlabReviewWatches.items.filter( - (w) => w.id !== id, - ); - }), - }; -} - -function issueWatchActions(set: ImmerSet) { - return { - setGitLabIssueWatches: (watches: GitLabSliceState["gitlabIssueWatches"]["items"]) => - set((draft) => { - draft.gitlabIssueWatches.items = watches; - draft.gitlabIssueWatches.loaded = true; - }), - setGitLabIssueWatchesLoading: (loading: boolean) => - set((draft) => { - draft.gitlabIssueWatches.loading = loading; - }), - addGitLabIssueWatch: (watch: GitLabSliceState["gitlabIssueWatches"]["items"][number]) => - set((draft) => { - draft.gitlabIssueWatches.items = [...draft.gitlabIssueWatches.items, watch]; - }), - updateGitLabIssueWatchInStore: ( - watch: GitLabSliceState["gitlabIssueWatches"]["items"][number], - ) => - set((draft) => { - draft.gitlabIssueWatches.items = draft.gitlabIssueWatches.items.map((w) => - w.id === watch.id ? watch : w, - ); - }), - removeGitLabIssueWatch: (id: string) => - set((draft) => { - draft.gitlabIssueWatches.items = draft.gitlabIssueWatches.items.filter((w) => w.id !== id); - }), - }; -} - -function mrWatchActions(set: ImmerSet) { - return { - setGitLabMRWatches: (watches: GitLabSliceState["gitlabMRWatches"]["items"]) => - set((draft) => { - draft.gitlabMRWatches.items = watches; - draft.gitlabMRWatches.loaded = true; - }), - setGitLabMRWatchesLoading: (loading: boolean) => - set((draft) => { - draft.gitlabMRWatches.loading = loading; - }), - removeGitLabMRWatch: (id: string) => - set((draft) => { - draft.gitlabMRWatches.items = draft.gitlabMRWatches.items.filter((w) => w.id !== id); - }), - }; -} - -function presetActions(set: ImmerSet) { - return { - setGitLabActionPresets: ( - workspaceId: string, - presets: GitLabSliceState["gitlabActionPresets"]["byWorkspaceId"][string], - ) => - set((draft) => { - draft.gitlabActionPresets.byWorkspaceId[workspaceId] = presets; - }), - setGitLabActionPresetsLoading: (loading: boolean) => - set((draft) => { - draft.gitlabActionPresets.loading = loading; - }), - }; -} - -function statsActions(set: ImmerSet) { - return { - setGitLabStats: (stats: GitLabSliceState["gitlabStats"]["data"]) => - set((draft) => { - draft.gitlabStats.data = stats; - draft.gitlabStats.loadedAt = Date.now(); - }), - setGitLabStatsLoading: (loading: boolean) => - set((draft) => { - draft.gitlabStats.loading = loading; - }), - }; -} - -function statusActions(set: ImmerSet) { - return { - setGitLabStatus: (status: GitLabSliceState["gitlabStatus"]["data"]) => - set((draft) => { - draft.gitlabStatus.data = status; - draft.gitlabStatus.loadedAt = Date.now(); - }), - setGitLabStatusLoading: (loading: boolean) => - set((draft) => { - draft.gitlabStatus.loading = loading; - }), - }; -} diff --git a/apps/web/lib/state/slices/gitlab/types.ts b/apps/web/lib/state/slices/gitlab/types.ts deleted file mode 100644 index ddf0f7c380..0000000000 --- a/apps/web/lib/state/slices/gitlab/types.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { - TaskMR, - ReviewWatch, - IssueWatch, - MRWatch, - GitLabStats, - GitLabActionPresets, - GitLabStatus, -} from "@/lib/types/gitlab"; - -export type TaskMRsState = { - /** Each task may have multiple MRs (one per repository for multi-repo tasks). */ - byTaskId: Record; -}; - -export type LoadableList = { - items: T[]; - loaded: boolean; - loading: boolean; -}; - -export type GitLabReviewWatchesState = LoadableList; -export type GitLabIssueWatchesState = LoadableList; -export type GitLabMRWatchesState = LoadableList; - -export type GitLabActionPresetsState = { - byWorkspaceId: Record; - loading: boolean; -}; - -export type GitLabStatsState = { - data: GitLabStats | null; - loading: boolean; - loadedAt: number | null; -}; - -export type GitLabStatusState = { - data: GitLabStatus | null; - loading: boolean; - loadedAt: number | null; -}; - -export type GitLabSliceState = { - taskMRs: TaskMRsState; - gitlabReviewWatches: GitLabReviewWatchesState; - gitlabIssueWatches: GitLabIssueWatchesState; - gitlabMRWatches: GitLabMRWatchesState; - gitlabActionPresets: GitLabActionPresetsState; - gitlabStats: GitLabStatsState; - gitlabStatus: GitLabStatusState; -}; - -export type GitLabSliceActions = { - // TaskMR — keeping legacy names; do not collide with github slice. - setTaskMRs: (mrs: Record) => void; - setTaskMR: (taskId: string, mr: TaskMR) => void; - resetTaskMRs: () => void; - - setGitLabReviewWatches: (watches: ReviewWatch[]) => void; - setGitLabReviewWatchesLoading: (loading: boolean) => void; - addGitLabReviewWatch: (watch: ReviewWatch) => void; - updateGitLabReviewWatchInStore: (watch: ReviewWatch) => void; - removeGitLabReviewWatch: (id: string) => void; - - setGitLabIssueWatches: (watches: IssueWatch[]) => void; - setGitLabIssueWatchesLoading: (loading: boolean) => void; - addGitLabIssueWatch: (watch: IssueWatch) => void; - updateGitLabIssueWatchInStore: (watch: IssueWatch) => void; - removeGitLabIssueWatch: (id: string) => void; - - setGitLabMRWatches: (watches: MRWatch[]) => void; - setGitLabMRWatchesLoading: (loading: boolean) => void; - removeGitLabMRWatch: (id: string) => void; - - setGitLabActionPresets: (workspaceId: string, presets: GitLabActionPresets) => void; - setGitLabActionPresetsLoading: (loading: boolean) => void; - - setGitLabStats: (stats: GitLabStats | null) => void; - setGitLabStatsLoading: (loading: boolean) => void; - - setGitLabStatus: (status: GitLabStatus | null) => void; - setGitLabStatusLoading: (loading: boolean) => void; -}; - -export type GitLabSlice = GitLabSliceState & GitLabSliceActions; diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 819ef7fd53..3c9864441c 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -9,13 +9,8 @@ export { } from "./session-runtime/session-runtime-slice"; export { createUISlice, defaultUIState } from "./ui/ui-slice"; export { createGitHubSlice, defaultGitHubState } from "./github/github-slice"; -export { createGitLabSlice, defaultGitLabState } from "./gitlab/gitlab-slice"; -export { createJiraSlice, defaultJiraState } from "./jira/jira-slice"; -export { createLinearSlice, defaultLinearState } from "./linear/linear-slice"; export { createOfficeSlice, defaultOfficeState } from "./office/office-slice"; -export { createFeaturesSlice, defaultFeaturesState } from "./features/features-slice"; -export { createAutomationsSlice, defaultAutomationsState } from "./automations/automations-slice"; -export { createSystemSlice, defaultSystemState } from "./system/system-slice"; +export { defaultFeaturesState } from "./features/features-slice"; // Export types export type { KanbanSlice, KanbanSliceState, KanbanSliceActions } from "./kanban/types"; @@ -28,82 +23,21 @@ export type { SessionRuntimeSliceActions, } from "./session-runtime/types"; export type { UISlice, UISliceState, UISliceActions } from "./ui/types"; -export type { - GitHubSlice, - GitHubSliceState, - GitHubSliceActions, - TaskCIAutomationOptionsState, -} from "./github/types"; -export type { - GitLabSlice, - GitLabSliceState, - GitLabSliceActions, - TaskMRsState, -} from "./gitlab/types"; -export type { - JiraSlice, - JiraSliceState, - JiraSliceActions, - JiraIssueWatchesState, -} from "./jira/types"; -export type { - LinearSlice, - LinearSliceState, - LinearSliceActions, - LinearIssueWatchesState, -} from "./linear/types"; +export type { GitHubSlice, GitHubSliceState, GitHubSliceActions } from "./github/types"; export type { OfficeSlice, OfficeSliceState, OfficeSliceActions } from "./office/types"; -export type { - FeaturesSlice, - FeaturesSliceState, - FeaturesSliceActions, - FeatureFlags, - FeatureName, -} from "./features/types"; -export type { - AutomationsSlice, - AutomationsSliceState, - AutomationsSliceActions, - AutomationsState, - AutomationRunsState, -} from "./automations/types"; -export type { - SystemSlice, - SystemSliceState, - SystemSliceActions, - SystemBackupsState, - SystemLogsState, - SystemJobsMap, -} from "./system/types"; +export type { FeatureFlags, FeatureName } from "./features/types"; // Re-export commonly used types from each domain export type { KanbanState, KanbanMultiState, WorkflowSnapshotData, + WorkflowItem, WorkflowsState, TaskState, } from "./kanban/types"; -export type { - WorkspaceState, - RepositoriesState, - RepositoryBranchesState, - RepositoryScriptsState, -} from "./workspace/types"; -export type { - ExecutorsState, - SettingsAgentsState, - AgentDiscoveryState, - AvailableAgentsState, - AgentProfileOption, - AgentProfilesState, - EditorsState, - PromptsState, - SecretsState, - NotificationProvidersState, - SettingsDataState, - UserSettingsState, -} from "./settings/types"; +export type { WorkspaceState } from "./workspace/types"; +export type { AgentProfileOption, UserSettingsState } from "./settings/types"; export type { MessagesState, TurnsState, @@ -112,17 +46,12 @@ export type { SessionAgentctlStatus, SessionAgentctlState, Worktree, - WorktreesState, - SessionWorktreesState, - PendingModelState, ActiveModelState, TaskPlansState, QueueStatus, QueuedMessage, - QueueState, } from "./session/types"; export type { - TerminalState, ShellState, ProcessStatusEntry, ProcessState, @@ -134,9 +63,7 @@ export type { SessionCommitsState, ContextWindowEntry, ContextWindowState, - AgentState, AvailableCommand, - AvailableCommandsState, UserShellInfo, UserShellKind, UserShellState, @@ -152,22 +79,13 @@ export type { PreviewDevicePreset, PreviewPanelState, RightPanelState, - DiffState, ConnectionState, MobileKanbanState, MobileSessionPanel, MobileSessionState, ActiveDocument, DocumentPanelState, - SystemHealthState, } from "./ui/types"; -export type { - GitHubStatusState, - TaskPRsState, - PRWatchesState, - ReviewWatchesState, - IssueWatchesState, -} from "./github/types"; export type { AgentProfile, Skill, diff --git a/apps/web/lib/state/slices/jira/jira-slice.test.ts b/apps/web/lib/state/slices/jira/jira-slice.test.ts deleted file mode 100644 index 3ceb8e5438..0000000000 --- a/apps/web/lib/state/slices/jira/jira-slice.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createJiraSlice } from "./jira-slice"; -import type { JiraSlice } from "./types"; -import type { JiraIssueWatch } from "@/lib/types/jira"; - -function makeStore() { - return create()( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - immer((...a) => ({ ...(createJiraSlice as any)(...a) })), - ); -} - -function watch(id: string, overrides: Partial = {}): JiraIssueWatch { - return { - id, - workspaceId: "ws-1", - workflowId: "wf-1", - workflowStepId: "step-1", - repositoryId: "", - baseBranch: "", - jql: "project = PROJ", - agentProfileId: "", - executorProfileId: "", - prompt: "", - enabled: true, - pollIntervalSeconds: 300, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - ...overrides, - }; -} - -describe("jira issue-watches slice", () => { - it("starts empty and not loaded", () => { - const store = makeStore(); - const s = store.getState(); - expect(s.jiraIssueWatches.items).toEqual([]); - expect(s.jiraIssueWatches.loaded).toBe(false); - expect(s.jiraIssueWatches.loading).toBe(false); - }); - - it("setJiraIssueWatches replaces items and flips loaded=true", () => { - const store = makeStore(); - store.getState().setJiraIssueWatches([watch("a"), watch("b")]); - const s = store.getState(); - expect(s.jiraIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]); - expect(s.jiraIssueWatches.loaded).toBe(true); - }); - - it("addJiraIssueWatch appends a new entry", () => { - const store = makeStore(); - store.getState().setJiraIssueWatches([watch("a")]); - store.getState().addJiraIssueWatch(watch("b")); - expect(store.getState().jiraIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]); - }); - - it("updateJiraIssueWatch replaces in place by id; missing id is a no-op", () => { - const store = makeStore(); - store.getState().setJiraIssueWatches([watch("a", { jql: "OLD" }), watch("b")]); - store.getState().updateJiraIssueWatch(watch("a", { jql: "NEW" })); - expect(store.getState().jiraIssueWatches.items[0].jql).toBe("NEW"); - - store.getState().updateJiraIssueWatch(watch("ghost", { jql: "X" })); - expect(store.getState().jiraIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]); - }); - - it("removeJiraIssueWatch filters by id", () => { - const store = makeStore(); - store.getState().setJiraIssueWatches([watch("a"), watch("b"), watch("c")]); - store.getState().removeJiraIssueWatch("b"); - expect(store.getState().jiraIssueWatches.items.map((w) => w.id)).toEqual(["a", "c"]); - }); - - it("resetJiraIssueWatches clears items AND loaded so a refetch is triggered", () => { - // The whole point of this action vs. setJiraIssueWatches([]) — an empty - // setWatches keeps loaded=true and would block the fetch effect from - // re-running on workspace switch. - const store = makeStore(); - store.getState().setJiraIssueWatches([watch("a")]); - expect(store.getState().jiraIssueWatches.loaded).toBe(true); - - store.getState().resetJiraIssueWatches(); - expect(store.getState().jiraIssueWatches.items).toEqual([]); - expect(store.getState().jiraIssueWatches.loaded).toBe(false); - }); -}); diff --git a/apps/web/lib/state/slices/jira/jira-slice.ts b/apps/web/lib/state/slices/jira/jira-slice.ts deleted file mode 100644 index b3475950d1..0000000000 --- a/apps/web/lib/state/slices/jira/jira-slice.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { StateCreator } from "zustand"; -import type { JiraSlice, JiraSliceState } from "./types"; - -export const defaultJiraState: JiraSliceState = { - jiraIssueWatches: { items: [], loaded: false, loading: false }, -}; - -type ImmerSet = Parameters>[0]; - -export const createJiraSlice: StateCreator = ( - set: ImmerSet, -) => ({ - ...defaultJiraState, - setJiraIssueWatches: (watches) => - set((draft) => { - draft.jiraIssueWatches.items = watches; - draft.jiraIssueWatches.loaded = true; - }), - setJiraIssueWatchesLoading: (loading) => - set((draft) => { - draft.jiraIssueWatches.loading = loading; - }), - addJiraIssueWatch: (watch) => - set((draft) => { - draft.jiraIssueWatches.items.push(watch); - }), - updateJiraIssueWatch: (watch) => - set((draft) => { - const idx = draft.jiraIssueWatches.items.findIndex((w) => w.id === watch.id); - if (idx >= 0) { - draft.jiraIssueWatches.items[idx] = watch; - } - }), - removeJiraIssueWatch: (id) => - set((draft) => { - draft.jiraIssueWatches.items = draft.jiraIssueWatches.items.filter((w) => w.id !== id); - }), - resetJiraIssueWatches: () => - set((draft) => { - draft.jiraIssueWatches.items = []; - draft.jiraIssueWatches.loaded = false; - }), -}); diff --git a/apps/web/lib/state/slices/jira/types.ts b/apps/web/lib/state/slices/jira/types.ts deleted file mode 100644 index 1e4de2d880..0000000000 --- a/apps/web/lib/state/slices/jira/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { JiraIssueWatch } from "@/lib/types/jira"; - -export type JiraIssueWatchesState = { - items: JiraIssueWatch[]; - loaded: boolean; - loading: boolean; -}; - -export type JiraSliceState = { - jiraIssueWatches: JiraIssueWatchesState; -}; - -export type JiraSliceActions = { - setJiraIssueWatches: (watches: JiraIssueWatch[]) => void; - setJiraIssueWatchesLoading: (loading: boolean) => void; - addJiraIssueWatch: (watch: JiraIssueWatch) => void; - updateJiraIssueWatch: (watch: JiraIssueWatch) => void; - removeJiraIssueWatch: (id: string) => void; - /** - * Clears items AND `loaded` so the next fetch effect runs again. Distinct - * from `setJiraIssueWatches([])`, which marks the empty list as loaded and - * would prevent a refetch on workspace switch. - */ - resetJiraIssueWatches: () => void; -}; - -export type JiraSlice = JiraSliceState & JiraSliceActions; diff --git a/apps/web/lib/state/slices/kanban/kanban-slice.test.ts b/apps/web/lib/state/slices/kanban/kanban-slice.test.ts index 4a45884b1f..1203865663 100644 --- a/apps/web/lib/state/slices/kanban/kanban-slice.test.ts +++ b/apps/web/lib/state/slices/kanban/kanban-slice.test.ts @@ -12,6 +12,28 @@ function makeStore() { return create()(immer(createKanbanSlice)); } +describe("workflow list server-state", () => { + it("keeps only active workflow UI state in the kanban slice", () => { + const state = makeStore().getState() as unknown as Record; + const workflows = state.workflows as Record; + + expect(workflows).toEqual({ activeId: null }); + expect("setWorkflows" in state).toBe(false); + expect("reorderWorkflowItems" in state).toBe(false); + }); + + it("keeps multi-workflow snapshots without legacy loading or mutation actions", () => { + const state = makeStore().getState() as unknown as Record; + + expect("kanban" in state).toBe(false); + expect("kanbanMulti" in state).toBe(false); + expect("setWorkflowSnapshot" in state).toBe(false); + expect("setKanbanMultiLoading" in state).toBe(false); + expect("updateMultiTask" in state).toBe(false); + expect("removeMultiTask" in state).toBe(false); + }); +}); + describe("kanban slice active session selection", () => { it("updates active session state without creating a user pin", () => { const store = makeStore(); diff --git a/apps/web/lib/state/slices/kanban/kanban-slice.ts b/apps/web/lib/state/slices/kanban/kanban-slice.ts index 2b738f805c..e006af8ebb 100644 --- a/apps/web/lib/state/slices/kanban/kanban-slice.ts +++ b/apps/web/lib/state/slices/kanban/kanban-slice.ts @@ -2,9 +2,7 @@ import type { StateCreator } from "zustand"; import type { KanbanSlice, KanbanSliceState } from "./types"; export const defaultKanbanState: KanbanSliceState = { - kanban: { workflowId: null, steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - workflows: { items: [], activeId: null }, + workflows: { activeId: null }, tasks: { activeTaskId: null, activeSessionId: null, @@ -25,22 +23,6 @@ export const createKanbanSlice: StateCreator< if (draft.workflows.activeId === workflowId) return; draft.workflows.activeId = workflowId; }), - setWorkflows: (workflows) => - set((draft) => { - draft.workflows.items = workflows; - }), - reorderWorkflowItems: (workflowIds) => - set((draft) => { - const byId = new Map(draft.workflows.items.map((w) => [w.id, w])); - const reordered = workflowIds - .map((id) => byId.get(id)) - .filter((w): w is NonNullable => w != null); - // Append any items not in the reorder list (shouldn't happen, but defensive) - for (const item of draft.workflows.items) { - if (!workflowIds.includes(item.id)) reordered.push(item); - } - draft.workflows.items = reordered; - }), setActiveTask: (taskId) => set((draft) => { draft.tasks.activeTaskId = taskId; @@ -67,34 +49,4 @@ export const createKanbanSlice: StateCreator< draft.tasks.activeSessionId = null; draft.tasks.pinnedSessionId = null; }), - setWorkflowSnapshot: (workflowId, data) => - set((draft) => { - draft.kanbanMulti.snapshots[workflowId] = data; - }), - setKanbanMultiLoading: (loading) => - set((draft) => { - draft.kanbanMulti.isLoading = loading; - }), - clearKanbanMulti: () => - set((draft) => { - draft.kanbanMulti.snapshots = {}; - draft.kanbanMulti.isLoading = false; - }), - updateMultiTask: (workflowId, task) => - set((draft) => { - const snapshot = draft.kanbanMulti.snapshots[workflowId]; - if (!snapshot) return; - const idx = snapshot.tasks.findIndex((t) => t.id === task.id); - if (idx >= 0) { - snapshot.tasks[idx] = task; - } else { - snapshot.tasks.push(task); - } - }), - removeMultiTask: (workflowId, taskId) => - set((draft) => { - const snapshot = draft.kanbanMulti.snapshots[workflowId]; - if (!snapshot) return; - snapshot.tasks = snapshot.tasks.filter((t) => t.id !== taskId); - }), }); diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index 0ffcd61cc4..30c7f04aee 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -89,25 +89,25 @@ export type WorkflowSnapshotData = { export type KanbanMultiState = { snapshots: Record; - isLoading: boolean; +}; + +export type WorkflowItem = { + id: string; + workspaceId: string; + name: string; + description?: string | null; + sortOrder?: number; + agent_profile_id?: string; + hidden?: boolean; + /** + * Phase 2 (ADR-0004) UX hint. Read by `` to choose the + * right meta surface (kanban / office / multi-agent). Backend never + * branches on this field. + */ + style?: "kanban" | "office" | "custom"; }; export type WorkflowsState = { - items: Array<{ - id: string; - workspaceId: string; - name: string; - description?: string | null; - sortOrder?: number; - agent_profile_id?: string; - hidden?: boolean; - /** - * Phase 2 (ADR-0004) UX hint. Read by `` to choose the - * right meta surface (kanban / office / multi-agent). Backend never - * branches on this field. - */ - style?: "kanban" | "office" | "custom"; - }>; activeId: string | null; }; @@ -127,16 +127,12 @@ export type TaskState = { }; export type KanbanSliceState = { - kanban: KanbanState; - kanbanMulti: KanbanMultiState; workflows: WorkflowsState; tasks: TaskState; }; export type KanbanSliceActions = { setActiveWorkflow: (workflowId: string | null) => void; - setWorkflows: (workflows: WorkflowsState["items"]) => void; - reorderWorkflowItems: (workflowIds: string[]) => void; setActiveTask: (taskId: string) => void; setActiveSession: (taskId: string, sessionId: string) => void; // setActiveSessionAuto updates the active session without creating or @@ -144,11 +140,6 @@ export type KanbanSliceActions = { // it explicitly after checking no non-terminal manual pin should be preserved. setActiveSessionAuto: (taskId: string, sessionId: string) => void; clearActiveSession: () => void; - setWorkflowSnapshot: (workflowId: string, data: WorkflowSnapshotData) => void; - setKanbanMultiLoading: (loading: boolean) => void; - clearKanbanMulti: () => void; - updateMultiTask: (workflowId: string, task: KanbanState["tasks"][number]) => void; - removeMultiTask: (workflowId: string, taskId: string) => void; }; export type KanbanSlice = KanbanSliceState & KanbanSliceActions; diff --git a/apps/web/lib/state/slices/linear/linear-slice.test.ts b/apps/web/lib/state/slices/linear/linear-slice.test.ts deleted file mode 100644 index 6fd2f5dbe2..0000000000 --- a/apps/web/lib/state/slices/linear/linear-slice.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createLinearSlice } from "./linear-slice"; -import type { LinearSlice } from "./types"; -import type { LinearIssueWatch } from "@/lib/types/linear"; - -function makeStore() { - return create()( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - immer((...a) => ({ ...(createLinearSlice as any)(...a) })), - ); -} - -function watch(id: string, overrides: Partial = {}): LinearIssueWatch { - return { - id, - workspaceId: "ws-1", - workflowId: "wf-1", - workflowStepId: "step-1", - repositoryId: "", - baseBranch: "", - filter: { teamKey: "ENG" }, - agentProfileId: "", - executorProfileId: "", - prompt: "", - enabled: true, - pollIntervalSeconds: 300, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - ...overrides, - }; -} - -describe("linear issue-watches slice", () => { - it("starts empty and not loaded", () => { - const store = makeStore(); - const s = store.getState(); - expect(s.linearIssueWatches.items).toEqual([]); - expect(s.linearIssueWatches.loaded).toBe(false); - expect(s.linearIssueWatches.loading).toBe(false); - }); - - it("setLinearIssueWatches replaces items and flips loaded=true", () => { - const store = makeStore(); - store.getState().setLinearIssueWatches([watch("a"), watch("b")]); - const s = store.getState(); - expect(s.linearIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]); - expect(s.linearIssueWatches.loaded).toBe(true); - }); - - it("setLinearIssueWatchesLoading toggles loading independently", () => { - const store = makeStore(); - store.getState().setLinearIssueWatchesLoading(true); - expect(store.getState().linearIssueWatches.loading).toBe(true); - store.getState().setLinearIssueWatchesLoading(false); - expect(store.getState().linearIssueWatches.loading).toBe(false); - }); - - it("addLinearIssueWatch appends a new entry", () => { - const store = makeStore(); - store.getState().setLinearIssueWatches([watch("a")]); - store.getState().addLinearIssueWatch(watch("b")); - expect(store.getState().linearIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]); - }); - - it("updateLinearIssueWatch replaces in place by id; missing id is a no-op", () => { - const store = makeStore(); - store - .getState() - .setLinearIssueWatches([watch("a", { filter: { teamKey: "OLD" } }), watch("b")]); - store.getState().updateLinearIssueWatch(watch("a", { filter: { teamKey: "NEW" } })); - expect(store.getState().linearIssueWatches.items[0].filter.teamKey).toBe("NEW"); - - store.getState().updateLinearIssueWatch(watch("ghost", { filter: { teamKey: "X" } })); - expect(store.getState().linearIssueWatches.items.map((w) => w.id)).toEqual(["a", "b"]); - }); - - it("removeLinearIssueWatch filters by id", () => { - const store = makeStore(); - store.getState().setLinearIssueWatches([watch("a"), watch("b"), watch("c")]); - store.getState().removeLinearIssueWatch("b"); - expect(store.getState().linearIssueWatches.items.map((w) => w.id)).toEqual(["a", "c"]); - }); - - it("resetLinearIssueWatches clears items AND loaded so a refetch is triggered", () => { - // The whole point of this action vs. setLinearIssueWatches([]) — an empty - // setWatches keeps loaded=true and would block the fetch effect from - // re-running on workspace switch. - const store = makeStore(); - store.getState().setLinearIssueWatches([watch("a")]); - expect(store.getState().linearIssueWatches.loaded).toBe(true); - - store.getState().resetLinearIssueWatches(); - expect(store.getState().linearIssueWatches.items).toEqual([]); - expect(store.getState().linearIssueWatches.loaded).toBe(false); - }); -}); diff --git a/apps/web/lib/state/slices/linear/linear-slice.ts b/apps/web/lib/state/slices/linear/linear-slice.ts deleted file mode 100644 index 03d6dc1086..0000000000 --- a/apps/web/lib/state/slices/linear/linear-slice.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { StateCreator } from "zustand"; -import type { LinearSlice, LinearSliceState } from "./types"; - -export const defaultLinearState: LinearSliceState = { - linearIssueWatches: { items: [], loaded: false, loading: false }, -}; - -type ImmerSet = Parameters< - StateCreator ->[0]; - -export const createLinearSlice: StateCreator< - LinearSlice, - [["zustand/immer", never]], - [], - LinearSlice -> = (set: ImmerSet) => ({ - ...defaultLinearState, - setLinearIssueWatches: (watches) => - set((draft) => { - draft.linearIssueWatches.items = watches; - draft.linearIssueWatches.loaded = true; - }), - setLinearIssueWatchesLoading: (loading) => - set((draft) => { - draft.linearIssueWatches.loading = loading; - }), - addLinearIssueWatch: (watch) => - set((draft) => { - draft.linearIssueWatches.items.push(watch); - }), - updateLinearIssueWatch: (watch) => - set((draft) => { - const idx = draft.linearIssueWatches.items.findIndex((w) => w.id === watch.id); - if (idx >= 0) { - draft.linearIssueWatches.items[idx] = watch; - } - }), - removeLinearIssueWatch: (id) => - set((draft) => { - draft.linearIssueWatches.items = draft.linearIssueWatches.items.filter((w) => w.id !== id); - }), - resetLinearIssueWatches: () => - set((draft) => { - draft.linearIssueWatches.items = []; - draft.linearIssueWatches.loaded = false; - }), -}); diff --git a/apps/web/lib/state/slices/linear/types.ts b/apps/web/lib/state/slices/linear/types.ts deleted file mode 100644 index 4412aa27b7..0000000000 --- a/apps/web/lib/state/slices/linear/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { LinearIssueWatch } from "@/lib/types/linear"; - -export type LinearIssueWatchesState = { - items: LinearIssueWatch[]; - loaded: boolean; - loading: boolean; -}; - -export type LinearSliceState = { - linearIssueWatches: LinearIssueWatchesState; -}; - -export type LinearSliceActions = { - setLinearIssueWatches: (watches: LinearIssueWatch[]) => void; - setLinearIssueWatchesLoading: (loading: boolean) => void; - addLinearIssueWatch: (watch: LinearIssueWatch) => void; - updateLinearIssueWatch: (watch: LinearIssueWatch) => void; - removeLinearIssueWatch: (id: string) => void; - /** - * Clears items AND `loaded` so the next fetch effect runs again. Distinct - * from `setLinearIssueWatches([])`, which marks the empty list as loaded - * and would prevent a refetch on workspace switch. - */ - resetLinearIssueWatches: () => void; -}; - -export type LinearSlice = LinearSliceState & LinearSliceActions; diff --git a/apps/web/lib/state/slices/office/office-agents.test.ts b/apps/web/lib/state/slices/office/office-agents.test.ts deleted file mode 100644 index 50fb3d4245..0000000000 --- a/apps/web/lib/state/slices/office/office-agents.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createOfficeSlice } from "./office-slice"; -import type { OfficeSlice, AgentProfile } from "./types"; -import { agentProfileId as toAgentProfileId, workspaceId as toWorkspaceId } from "@/lib/types/ids"; - -function makeStore() { - return create()( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - immer((...a) => ({ ...(createOfficeSlice as any)(...a) })), - ); -} - -function makeAgent(id: string, name: string): AgentProfile { - return { - id: toAgentProfileId(id), - workspaceId: toWorkspaceId("ws-1"), - name, - role: "worker", - status: "idle", - budgetMonthlyCents: 1000, - maxConcurrentSessions: 1, - agentId: "claude", - agentDisplayName: "Claude", - model: "claude-sonnet-4-5", - allowIndexing: false, - autoApprove: false, - cliFlags: [], - cliPassthrough: false, - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }; -} - -describe("agent instance store actions", () => { - it("setOfficeAgentProfiles replaces the list", () => { - const store = makeStore(); - const agents = [makeAgent("a1", "Agent 1"), makeAgent("a2", "Agent 2")]; - store.getState().setOfficeAgentProfiles(agents); - expect(store.getState().office.agentProfiles).toHaveLength(2); - }); - - it("addOfficeAgentProfile appends to the list", () => { - const store = makeStore(); - store.getState().setOfficeAgentProfiles([makeAgent("a1", "Agent 1")]); - store.getState().addOfficeAgentProfile(makeAgent("a2", "Agent 2")); - expect(store.getState().office.agentProfiles).toHaveLength(2); - expect(store.getState().office.agentProfiles[1].name).toBe("Agent 2"); - }); - - it("updateOfficeAgentProfile patches an existing agent", () => { - const store = makeStore(); - store.getState().setOfficeAgentProfiles([makeAgent("a1", "Original")]); - store.getState().updateOfficeAgentProfile("a1", { name: "Updated", status: "working" }); - - const agent = store.getState().office.agentProfiles[0]; - expect(agent.name).toBe("Updated"); - expect(agent.status).toBe("working"); - // Other fields unchanged - expect(agent.role).toBe("worker"); - }); - - it("updateOfficeAgentProfile is a no-op for unknown id", () => { - const store = makeStore(); - store.getState().setOfficeAgentProfiles([makeAgent("a1", "Agent 1")]); - store.getState().updateOfficeAgentProfile("unknown", { name: "Ghost" }); - expect(store.getState().office.agentProfiles).toHaveLength(1); - expect(store.getState().office.agentProfiles[0].name).toBe("Agent 1"); - }); - - it("removeOfficeAgentProfile removes by id", () => { - const store = makeStore(); - store - .getState() - .setOfficeAgentProfiles([makeAgent("a1", "Agent 1"), makeAgent("a2", "Agent 2")]); - store.getState().removeOfficeAgentProfile("a1"); - expect(store.getState().office.agentProfiles).toHaveLength(1); - expect(store.getState().office.agentProfiles[0].id).toBe("a2"); - }); - - it("removeOfficeAgentProfile is a no-op for unknown id", () => { - const store = makeStore(); - store.getState().setOfficeAgentProfiles([makeAgent("a1", "Agent 1")]); - store.getState().removeOfficeAgentProfile("unknown"); - expect(store.getState().office.agentProfiles).toHaveLength(1); - }); -}); diff --git a/apps/web/lib/state/slices/office/office-projects.test.ts b/apps/web/lib/state/slices/office/office-projects.test.ts deleted file mode 100644 index bbe23bacc7..0000000000 --- a/apps/web/lib/state/slices/office/office-projects.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createOfficeSlice } from "./office-slice"; -import type { OfficeSlice, Project } from "./types"; - -function makeStore() { - return create()( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - immer((...a) => ({ ...(createOfficeSlice as any)(...a) })), - ); -} - -function makeProject(id: string, name: string): Project { - return { - id, - workspaceId: "ws-1", - name, - status: "active", - color: "#3b82f6", - repositories: ["https://github.com/team/backend"], - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - }; -} - -describe("project store actions", () => { - it("setProjects replaces the list", () => { - const store = makeStore(); - const projects = [makeProject("p1", "Project 1"), makeProject("p2", "Project 2")]; - store.getState().setProjects(projects); - expect(store.getState().office.projects).toHaveLength(2); - }); - - it("addProject appends to the list", () => { - const store = makeStore(); - store.getState().setProjects([makeProject("p1", "Project 1")]); - store.getState().addProject(makeProject("p2", "Project 2")); - expect(store.getState().office.projects).toHaveLength(2); - expect(store.getState().office.projects[1].name).toBe("Project 2"); - }); - - it("updateProject patches an existing project", () => { - const store = makeStore(); - store.getState().setProjects([makeProject("p1", "Project 1")]); - store.getState().updateProject("p1", { name: "Updated", status: "completed" }); - const project = store.getState().office.projects[0]; - expect(project.name).toBe("Updated"); - expect(project.status).toBe("completed"); - }); - - it("updateProject is no-op for missing id", () => { - const store = makeStore(); - store.getState().setProjects([makeProject("p1", "Project 1")]); - store.getState().updateProject("missing", { name: "Nope" }); - expect(store.getState().office.projects[0].name).toBe("Project 1"); - }); - - it("removeProject removes by id", () => { - const store = makeStore(); - store.getState().setProjects([makeProject("p1", "Project 1"), makeProject("p2", "Project 2")]); - store.getState().removeProject("p1"); - expect(store.getState().office.projects).toHaveLength(1); - expect(store.getState().office.projects[0].id).toBe("p2"); - }); - - it("removeProject is no-op for missing id", () => { - const store = makeStore(); - store.getState().setProjects([makeProject("p1", "Project 1")]); - store.getState().removeProject("missing"); - expect(store.getState().office.projects).toHaveLength(1); - }); - - it("setProjects overwrites existing projects", () => { - const store = makeStore(); - store.getState().setProjects([makeProject("p1", "Old")]); - store.getState().setProjects([makeProject("p2", "New")]); - expect(store.getState().office.projects).toHaveLength(1); - expect(store.getState().office.projects[0].name).toBe("New"); - }); -}); diff --git a/apps/web/lib/state/slices/office/office-routing.test.ts b/apps/web/lib/state/slices/office/office-routing.test.ts deleted file mode 100644 index e103bf6983..0000000000 --- a/apps/web/lib/state/slices/office/office-routing.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createOfficeSlice } from "./office-slice"; -import type { OfficeSlice, ProviderHealth, RouteAttempt, WorkspaceRouting } from "./types"; - -function makeStore() { - return create()( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - immer((...a) => ({ ...(createOfficeSlice as any)(...a) })), - ); -} - -const CLAUDE = "claude-acp"; -const CODEX = "codex-acp"; -const WS_1 = "ws-1"; - -function makeConfig(): WorkspaceRouting { - return { - enabled: true, - provider_order: [CLAUDE, CODEX], - default_tier: "balanced", - provider_profiles: { - [CLAUDE]: { tier_map: { balanced: "sonnet" } }, - [CODEX]: { tier_map: { balanced: "gpt-5.4" } }, - }, - }; -} - -function makeHealth(provider: string, state: ProviderHealth["state"]): ProviderHealth { - return { - provider_id: provider, - scope: "provider", - scope_value: "", - state, - backoff_step: 0, - }; -} - -function makeAttempt(seq: number, providerId: string): RouteAttempt { - return { - seq, - provider_id: providerId, - model: "m", - tier: "balanced", - outcome: "launched", - started_at: "2026-05-10T12:00:00Z", - }; -} - -describe("office routing store actions", () => { - it("setWorkspaceRouting + setKnownProviders", () => { - const store = makeStore(); - store.getState().setKnownProviders([CLAUDE, CODEX]); - store.getState().setWorkspaceRouting(WS_1, makeConfig()); - expect(store.getState().office.routing.knownProviders).toHaveLength(2); - expect(store.getState().office.routing.byWorkspace[WS_1]?.enabled).toBe(true); - }); - - it("setWorkspaceRouting(undefined) clears the cache (used by routing.settings_updated)", () => { - const store = makeStore(); - store.getState().setWorkspaceRouting(WS_1, makeConfig()); - store.getState().setWorkspaceRouting(WS_1, undefined); - expect(store.getState().office.routing.byWorkspace[WS_1]).toBeUndefined(); - }); - - it("upsertProviderHealth replaces a row matching (provider, scope, scope_value)", () => { - const store = makeStore(); - store.getState().setProviderHealth(WS_1, [makeHealth(CLAUDE, "healthy")]); - store.getState().upsertProviderHealth(WS_1, makeHealth(CLAUDE, "degraded")); - const list = store.getState().office.providerHealth.byWorkspace[WS_1]; - expect(list).toHaveLength(1); - expect(list?.[0].state).toBe("degraded"); - }); - - it("upsertProviderHealth appends new (provider) rows", () => { - const store = makeStore(); - store.getState().setProviderHealth(WS_1, [makeHealth(CLAUDE, "healthy")]); - store.getState().upsertProviderHealth(WS_1, makeHealth(CODEX, "degraded")); - expect(store.getState().office.providerHealth.byWorkspace[WS_1]).toHaveLength(2); - }); - - it("appendRunAttempt appends and dedupes by seq", () => { - const store = makeStore(); - store.getState().appendRunAttempt("run-1", makeAttempt(1, CLAUDE)); - store.getState().appendRunAttempt("run-1", makeAttempt(2, CODEX)); - store - .getState() - .appendRunAttempt("run-1", { ...makeAttempt(2, CODEX), outcome: "failed_other" }); - const list = store.getState().office.runAttempts.byRunId["run-1"]; - expect(list).toHaveLength(2); - expect(list?.[1].outcome).toBe("failed_other"); - }); - - it("setRoutingPreview overwrites the per-workspace agent list", () => { - const store = makeStore(); - store.getState().setRoutingPreview(WS_1, [ - { - agent_id: "a-1", - agent_name: "CEO", - tier_source: "inherit", - effective_tier: "balanced", - fallback_chain: [], - missing: [], - degraded: false, - }, - ]); - expect(store.getState().office.routing.preview.byWorkspace[WS_1]).toHaveLength(1); - }); -}); diff --git a/apps/web/lib/state/slices/office/office-skills.test.ts b/apps/web/lib/state/slices/office/office-skills.test.ts deleted file mode 100644 index a5ce20b290..0000000000 --- a/apps/web/lib/state/slices/office/office-skills.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createOfficeSlice } from "./office-slice"; -import type { OfficeSlice, Skill } from "./types"; - -function makeStore() { - return create()( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - immer((...a) => ({ ...(createOfficeSlice as any)(...a) })), - ); -} - -function makeSkill(overrides?: Partial): Skill { - return { - id: "skill-1", - workspaceId: "ws-1", - name: "Test Skill", - slug: "test-skill", - sourceType: "inline", - content: "# Test", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - ...overrides, - }; -} - -describe("office skill actions", () => { - it("setSkills replaces the list", () => { - const store = makeStore(); - const skills = [makeSkill(), makeSkill({ id: "skill-2", name: "Second" })]; - store.getState().setSkills(skills); - expect(store.getState().office.skills).toHaveLength(2); - }); - - it("addSkill appends to the list", () => { - const store = makeStore(); - store.getState().setSkills([makeSkill()]); - store.getState().addSkill(makeSkill({ id: "skill-2", name: "New Skill" })); - expect(store.getState().office.skills).toHaveLength(2); - expect(store.getState().office.skills[1].name).toBe("New Skill"); - }); - - it("updateSkill patches an existing skill", () => { - const store = makeStore(); - store.getState().setSkills([makeSkill()]); - store.getState().updateSkill("skill-1", { name: "Updated Name" }); - expect(store.getState().office.skills[0].name).toBe("Updated Name"); - expect(store.getState().office.skills[0].slug).toBe("test-skill"); - }); - - it("updateSkill is a no-op for unknown id", () => { - const store = makeStore(); - store.getState().setSkills([makeSkill()]); - store.getState().updateSkill("nonexistent", { name: "Nope" }); - expect(store.getState().office.skills[0].name).toBe("Test Skill"); - }); - - it("removeSkill removes by id", () => { - const store = makeStore(); - store.getState().setSkills([makeSkill(), makeSkill({ id: "skill-2" })]); - store.getState().removeSkill("skill-1"); - expect(store.getState().office.skills).toHaveLength(1); - expect(store.getState().office.skills[0].id).toBe("skill-2"); - }); - - it("removeSkill is a no-op for unknown id", () => { - const store = makeStore(); - store.getState().setSkills([makeSkill()]); - store.getState().removeSkill("nonexistent"); - expect(store.getState().office.skills).toHaveLength(1); - }); -}); diff --git a/apps/web/lib/state/slices/office/office-slice.ts b/apps/web/lib/state/slices/office/office-slice.ts index 975fe46759..b1e236f31e 100644 --- a/apps/web/lib/state/slices/office/office-slice.ts +++ b/apps/web/lib/state/slices/office/office-slice.ts @@ -11,20 +11,7 @@ export const defaultTaskFilters = { export const defaultOfficeState: OfficeSliceState = { office: { - agentProfiles: [], - skills: [], - projects: [], - approvals: [], - activity: [], - costSummary: null, - budgetPolicies: [], - routines: [], - inboxItems: [], - inboxCount: 0, - runs: [], - dashboard: null, tasks: { - items: [], filters: { statuses: [], priorities: [], @@ -37,119 +24,15 @@ export const defaultOfficeState: OfficeSliceState = { sortDir: "desc", groupBy: "none", nestingEnabled: true, - isLoading: false, }, - meta: null, - isLoading: false, - refetchTrigger: null, - routing: { - byWorkspace: {}, - knownProviders: [], - preview: { byWorkspace: {} }, - }, - providerHealth: { byWorkspace: {} }, - runAttempts: { byRunId: {} }, - agentRouting: { byAgentId: {} }, }, }; type ImmerSet = StateCreator; type SetFn = Parameters[0]; -function createAgentActions(set: SetFn) { - return { - setOfficeAgentProfiles: (agents: OfficeSlice["office"]["agentProfiles"]) => - set((draft) => { - draft.office.agentProfiles = agents; - }), - addOfficeAgentProfile: (agent: OfficeSlice["office"]["agentProfiles"][number]) => - set((draft) => { - draft.office.agentProfiles.push(agent); - }), - updateOfficeAgentProfile: ( - id: string, - patch: Partial, - ) => - set((draft) => { - const idx = draft.office.agentProfiles.findIndex((a) => a.id === id); - if (idx >= 0) Object.assign(draft.office.agentProfiles[idx], patch); - }), - removeOfficeAgentProfile: (id: string) => - set((draft) => { - draft.office.agentProfiles = draft.office.agentProfiles.filter((a) => a.id !== id); - }), - }; -} - -function createSkillActions(set: SetFn) { - return { - setSkills: (skills: OfficeSlice["office"]["skills"]) => - set((draft) => { - draft.office.skills = skills; - }), - addSkill: (skill: OfficeSlice["office"]["skills"][number]) => - set((draft) => { - draft.office.skills.push(skill); - }), - updateSkill: (id: string, patch: Partial) => - set((draft) => { - const idx = draft.office.skills.findIndex((s) => s.id === id); - if (idx >= 0) Object.assign(draft.office.skills[idx], patch); - }), - removeSkill: (id: string) => - set((draft) => { - draft.office.skills = draft.office.skills.filter((s) => s.id !== id); - }), - }; -} - -function createProjectActions(set: SetFn) { - return { - setProjects: (projects: OfficeSlice["office"]["projects"]) => - set((draft) => { - draft.office.projects = projects; - }), - addProject: (project: OfficeSlice["office"]["projects"][number]) => - set((draft) => { - draft.office.projects.push(project); - }), - updateProject: (id: string, patch: Partial) => - set((draft) => { - const idx = draft.office.projects.findIndex((p) => p.id === id); - if (idx >= 0) Object.assign(draft.office.projects[idx], patch); - }), - removeProject: (id: string) => - set((draft) => { - draft.office.projects = draft.office.projects.filter((p) => p.id !== id); - }), - }; -} - function createTaskActions(set: SetFn) { return { - setTasks: (tasks: OfficeSlice["office"]["tasks"]["items"]) => - set((draft) => { - draft.office.tasks.items = tasks; - }), - appendTasks: (tasks: OfficeSlice["office"]["tasks"]["items"]) => - set((draft) => { - // De-dupe by id so refetch / load-more overlaps don't double-render. - const existing = new Set(draft.office.tasks.items.map((t) => t.id)); - for (const t of tasks) { - if (!existing.has(t.id)) { - draft.office.tasks.items.push(t); - existing.add(t.id); - } - } - }), - patchTaskInStore: ( - taskId: string, - patch: Partial, - ) => - set((draft) => { - const idx = draft.office.tasks.items.findIndex((t) => t.id === taskId); - if (idx >= 0) Object.assign(draft.office.tasks.items[idx], patch); - }), setTaskFilters: (filters: Partial) => set((draft) => { Object.assign(draft.office.tasks.filters, filters); @@ -174,143 +57,10 @@ function createTaskActions(set: SetFn) { set((draft) => { draft.office.tasks.nestingEnabled = !draft.office.tasks.nestingEnabled; }), - setTasksLoading: (loading: boolean) => - set((draft) => { - draft.office.tasks.isLoading = loading; - }), - }; -} - -function createMiscActions(set: SetFn) { - return { - setApprovals: (approvals: OfficeSlice["office"]["approvals"]) => - set((draft) => { - draft.office.approvals = approvals; - }), - setActivity: (entries: OfficeSlice["office"]["activity"]) => - set((draft) => { - draft.office.activity = entries; - }), - setCostSummary: (summary: OfficeSlice["office"]["costSummary"]) => - set((draft) => { - draft.office.costSummary = summary; - }), - setBudgetPolicies: (policies: OfficeSlice["office"]["budgetPolicies"]) => - set((draft) => { - draft.office.budgetPolicies = policies; - }), - setRoutines: (routines: OfficeSlice["office"]["routines"]) => - set((draft) => { - draft.office.routines = routines; - }), - setInboxItems: (items: OfficeSlice["office"]["inboxItems"]) => - set((draft) => { - draft.office.inboxItems = items; - }), - setInboxCount: (count: number) => - set((draft) => { - draft.office.inboxCount = count; - }), - setRuns: (runs: OfficeSlice["office"]["runs"]) => - set((draft) => { - draft.office.runs = runs; - }), - setDashboard: (data: OfficeSlice["office"]["dashboard"]) => - set((draft) => { - draft.office.dashboard = data; - }), - setMeta: (meta: OfficeSlice["office"]["meta"]) => - set((draft) => { - draft.office.meta = meta; - }), - setOfficeLoading: (loading: boolean) => - set((draft) => { - draft.office.isLoading = loading; - }), - setOfficeRefetchTrigger: (type: string) => - set((draft) => { - draft.office.refetchTrigger = { type, timestamp: Date.now() }; - }), - }; -} - -function createRoutingActions(set: SetFn) { - return { - setWorkspaceRouting: ( - workspaceId: string, - cfg: OfficeSlice["office"]["routing"]["byWorkspace"][string], - ) => - set((draft) => { - draft.office.routing.byWorkspace[workspaceId] = cfg; - }), - setKnownProviders: (providers: string[]) => - set((draft) => { - draft.office.routing.knownProviders = providers; - }), - setRoutingPreview: ( - workspaceId: string, - agents: NonNullable, - ) => - set((draft) => { - draft.office.routing.preview.byWorkspace[workspaceId] = agents; - }), - setProviderHealth: ( - workspaceId: string, - health: OfficeSlice["office"]["providerHealth"]["byWorkspace"][string], - ) => - set((draft) => { - draft.office.providerHealth.byWorkspace[workspaceId] = health; - }), - upsertProviderHealth: ( - workspaceId: string, - row: OfficeSlice["office"]["providerHealth"]["byWorkspace"][string][number], - ) => - set((draft) => { - const list = draft.office.providerHealth.byWorkspace[workspaceId] ?? []; - const idx = list.findIndex( - (r) => - r.provider_id === row.provider_id && - r.scope === row.scope && - r.scope_value === row.scope_value, - ); - if (idx >= 0) list[idx] = row; - else list.push(row); - draft.office.providerHealth.byWorkspace[workspaceId] = list; - }), - setRunAttempts: ( - runId: string, - attempts: OfficeSlice["office"]["runAttempts"]["byRunId"][string], - ) => - set((draft) => { - draft.office.runAttempts.byRunId[runId] = attempts; - }), - appendRunAttempt: ( - runId: string, - attempt: OfficeSlice["office"]["runAttempts"]["byRunId"][string][number], - ) => - set((draft) => { - const list = draft.office.runAttempts.byRunId[runId] ?? []; - const idx = list.findIndex((a) => a.seq === attempt.seq); - if (idx >= 0) list[idx] = attempt; - else list.push(attempt); - draft.office.runAttempts.byRunId[runId] = list; - }), - setAgentRouting: ( - agentId: string, - data: OfficeSlice["office"]["agentRouting"]["byAgentId"][string], - ) => - set((draft) => { - draft.office.agentRouting.byAgentId[agentId] = data; - }), }; } export const createOfficeSlice: ImmerSet = (set) => ({ ...defaultOfficeState, - ...createAgentActions(set), - ...createSkillActions(set), - ...createProjectActions(set), ...createTaskActions(set), - ...createMiscActions(set), - ...createRoutingActions(set), }); diff --git a/apps/web/lib/state/slices/office/office-tasks.test.ts b/apps/web/lib/state/slices/office/office-tasks.test.ts deleted file mode 100644 index 14039b76db..0000000000 --- a/apps/web/lib/state/slices/office/office-tasks.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createOfficeSlice } from "./office-slice"; -import type { OfficeSlice, OfficeTask } from "./types"; - -function makeStore() { - return create()( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - immer((...a) => ({ ...(createOfficeSlice as any)(...a) })), - ); -} - -function makeTask(overrides?: Partial): OfficeTask { - return { - id: "task-1", - workspaceId: "ws-1", - identifier: "OFC-1", - title: "Test Task", - status: "todo", - priority: "medium", - createdAt: "2026-01-01T00:00:00Z", - updatedAt: "2026-01-01T00:00:00Z", - ...overrides, - }; -} - -describe("office task actions — appendTasks", () => { - it("appends to an empty store", () => { - const store = makeStore(); - store.getState().appendTasks([makeTask(), makeTask({ id: "task-2" })]); - expect(store.getState().office.tasks.items).toHaveLength(2); - expect(store.getState().office.tasks.items.map((t) => t.id)).toEqual(["task-1", "task-2"]); - }); - - it("appends new tasks alongside existing ones", () => { - const store = makeStore(); - store.getState().setTasks([makeTask({ id: "task-1" })]); - store.getState().appendTasks([makeTask({ id: "task-2" }), makeTask({ id: "task-3" })]); - expect(store.getState().office.tasks.items.map((t) => t.id)).toEqual([ - "task-1", - "task-2", - "task-3", - ]); - }); - - it("skips tasks already present in the store", () => { - const store = makeStore(); - store.getState().setTasks([makeTask({ id: "task-1", title: "Original" })]); - store - .getState() - .appendTasks([makeTask({ id: "task-1", title: "Replacement" }), makeTask({ id: "task-2" })]); - expect(store.getState().office.tasks.items).toHaveLength(2); - expect(store.getState().office.tasks.items[0].title).toBe("Original"); - expect(store.getState().office.tasks.items[1].id).toBe("task-2"); - }); - - it("skips duplicate ids within the incoming batch itself", () => { - const store = makeStore(); - store - .getState() - .appendTasks([ - makeTask({ id: "task-1", title: "First" }), - makeTask({ id: "task-1", title: "Duplicate" }), - makeTask({ id: "task-2" }), - ]); - expect(store.getState().office.tasks.items).toHaveLength(2); - expect(store.getState().office.tasks.items[0].title).toBe("First"); - }); - - it("is a no-op for an empty incoming batch", () => { - const store = makeStore(); - store.getState().setTasks([makeTask()]); - store.getState().appendTasks([]); - expect(store.getState().office.tasks.items).toHaveLength(1); - }); -}); diff --git a/apps/web/lib/state/slices/office/types.ts b/apps/web/lib/state/slices/office/types.ts index 18bb5f81f3..e25cd10536 100644 --- a/apps/web/lib/state/slices/office/types.ts +++ b/apps/web/lib/state/slices/office/types.ts @@ -18,8 +18,6 @@ export type { export type { OfficeAgentProfile as AgentProfile } from "@/lib/types/agent-profile"; -import type { OfficeAgentProfile as AgentProfile } from "@/lib/types/agent-profile"; - export type SkillSourceType = | "inline" | "local_path" @@ -293,14 +291,12 @@ export type TaskGroupBy = "status" | "priority" | "assignee" | "project" | "pare export type TaskViewMode = "list" | "board"; export type TasksState = { - items: OfficeTask[]; filters: TaskFilterState; viewMode: TaskViewMode; sortField: TaskSortField; sortDir: TaskSortDir; groupBy: TaskGroupBy; nestingEnabled: boolean; - isLoading: boolean; }; export type RunActivityDay = { @@ -637,79 +633,19 @@ export type AgentRoutingSliceState = { // --- Slice state & actions --- -export type OfficeRefetchTrigger = { - type: string; - timestamp: number; -}; - export type OfficeSliceState = { office: { - agentProfiles: AgentProfile[]; - skills: Skill[]; - projects: Project[]; - approvals: Approval[]; - activity: ActivityEntry[]; - costSummary: CostSummary | null; - budgetPolicies: BudgetPolicy[]; - routines: Routine[]; - inboxItems: InboxItem[]; - inboxCount: number; - runs: Run[]; - dashboard: DashboardData | null; tasks: TasksState; - meta: OfficeMeta | null; - isLoading: boolean; - refetchTrigger: OfficeRefetchTrigger | null; - routing: RoutingState; - providerHealth: ProviderHealthSliceState; - runAttempts: RunAttemptsState; - agentRouting: AgentRoutingSliceState; }; }; export type OfficeSliceActions = { - setOfficeAgentProfiles: (agents: AgentProfile[]) => void; - addOfficeAgentProfile: (agent: AgentProfile) => void; - updateOfficeAgentProfile: (id: string, patch: Partial) => void; - removeOfficeAgentProfile: (id: string) => void; - setSkills: (skills: Skill[]) => void; - addSkill: (skill: Skill) => void; - updateSkill: (id: string, patch: Partial) => void; - removeSkill: (id: string) => void; - setProjects: (projects: Project[]) => void; - addProject: (project: Project) => void; - updateProject: (id: string, patch: Partial) => void; - removeProject: (id: string) => void; - setApprovals: (approvals: Approval[]) => void; - setActivity: (entries: ActivityEntry[]) => void; - setCostSummary: (summary: CostSummary | null) => void; - setBudgetPolicies: (policies: BudgetPolicy[]) => void; - setRoutines: (routines: Routine[]) => void; - setInboxItems: (items: InboxItem[]) => void; - setInboxCount: (count: number) => void; - setRuns: (runs: Run[]) => void; - setDashboard: (data: DashboardData | null) => void; - setTasks: (tasks: OfficeTask[]) => void; - appendTasks: (tasks: OfficeTask[]) => void; - patchTaskInStore: (taskId: string, patch: Partial) => void; setTaskFilters: (filters: Partial) => void; setTaskViewMode: (mode: TaskViewMode) => void; setTaskSortField: (field: TaskSortField) => void; setTaskSortDir: (dir: TaskSortDir) => void; setTaskGroupBy: (groupBy: TaskGroupBy) => void; toggleNesting: () => void; - setTasksLoading: (loading: boolean) => void; - setMeta: (meta: OfficeMeta | null) => void; - setOfficeLoading: (loading: boolean) => void; - setOfficeRefetchTrigger: (type: string) => void; - setWorkspaceRouting: (workspaceId: string, cfg: WorkspaceRouting | undefined) => void; - setKnownProviders: (providers: string[]) => void; - setRoutingPreview: (workspaceId: string, agents: AgentRoutePreview[]) => void; - setProviderHealth: (workspaceId: string, health: ProviderHealth[]) => void; - upsertProviderHealth: (workspaceId: string, row: ProviderHealth) => void; - setRunAttempts: (runId: string, attempts: RouteAttempt[]) => void; - appendRunAttempt: (runId: string, attempt: RouteAttempt) => void; - setAgentRouting: (agentId: string, data: AgentRouteData | undefined) => void; }; export type OfficeSlice = OfficeSliceState & OfficeSliceActions; diff --git a/apps/web/lib/state/slices/session-runtime/output-caps.test.ts b/apps/web/lib/state/slices/session-runtime/output-caps.test.ts index 6eb319e15a..1360e9863a 100644 --- a/apps/web/lib/state/slices/session-runtime/output-caps.test.ts +++ b/apps/web/lib/state/slices/session-runtime/output-caps.test.ts @@ -10,7 +10,7 @@ function makeStore() { return create()(immer(createSessionRuntimeSlice)); } -describe("shell + terminal output caps", () => { +describe("shell output caps", () => { let store: ReturnType; beforeEach(() => { @@ -39,39 +39,4 @@ describe("shell + terminal output caps", () => { store.getState().appendShellOutput("session-1", "world"); expect(store.getState().shell.outputs["session-1"]).toBe("hello world"); }); - - it("setTerminalOutput drops oldest chunks once over the cap", () => { - const chunk = "y".repeat(512 * 1024); - for (let i = 0; i < 10; i += 1) { - store.getState().setTerminalOutput("term-1", chunk); - } - const terminal = store.getState().terminal.terminals.find((t) => t.id === "term-1"); - const total = terminal?.output.reduce((sum, c) => sum + c.length, 0) ?? 0; - expect(total).toBeLessThanOrEqual(MAX_BYTES); - // Never empties the buffer entirely while data is flowing. - expect(terminal?.output.length ?? 0).toBeGreaterThan(0); - }); - - it("setTerminalOutput keeps small buffers as separate chunks", () => { - store.getState().setTerminalOutput("term-1", "a"); - store.getState().setTerminalOutput("term-1", "b"); - const terminal = store.getState().terminal.terminals.find((t) => t.id === "term-1"); - expect(terminal?.output).toEqual(["a", "b"]); - }); - - it("setTerminalOutput clamps a single oversized chunk to the cap", () => { - // A lone chunk bigger than the cap must not slip through untrimmed. - store.getState().setTerminalOutput("term-1", "z".repeat(MAX_BYTES + 1024)); - const terminal = store.getState().terminal.terminals.find((t) => t.id === "term-1"); - const total = terminal?.output.reduce((sum, c) => sum + c.length, 0) ?? 0; - expect(total).toBe(MAX_BYTES); - }); - - it("setTerminalOutput enforces the cap on the first chunk of a new terminal", () => { - // The new-terminal branch must go through the same cap as appends. - store.getState().setTerminalOutput("fresh", "q".repeat(MAX_BYTES + 4096)); - const terminal = store.getState().terminal.terminals.find((t) => t.id === "fresh"); - const total = terminal?.output.reduce((sum, c) => sum + c.length, 0) ?? 0; - expect(total).toBe(MAX_BYTES); - }); }); diff --git a/apps/web/lib/state/slices/session-runtime/purge-session.test.ts b/apps/web/lib/state/slices/session-runtime/purge-session.test.ts index 1392b9fe07..5cebe95480 100644 --- a/apps/web/lib/state/slices/session-runtime/purge-session.test.ts +++ b/apps/web/lib/state/slices/session-runtime/purge-session.test.ts @@ -21,7 +21,6 @@ describe("purgeSessionRuntimeState", () => { s.appendShellOutput("session-1", "shell noise"); s.setShellStatus("session-1", { available: true }); s.setContextWindow("session-1", { size: 1, used: 1, remaining: 0, efficiency: 1 }); - s.setSessionTodos("session-1", [{ description: "do", status: "pending" }]); s.upsertProcessStatus({ processId: "proc-1", sessionId: "session-1", @@ -37,7 +36,6 @@ describe("purgeSessionRuntimeState", () => { const after = store.getState(); expect(after.environmentIdBySessionId["session-1"]).toBeUndefined(); expect(after.contextWindow.bySessionId["session-1"]).toBeUndefined(); - expect(after.sessionTodos.bySessionId["session-1"]).toBeUndefined(); expect(after.processes.processIdsBySessionId["session-1"]).toBeUndefined(); expect(after.processes.devProcessBySessionId["session-1"]).toBeUndefined(); expect(after.processes.processesById["proc-1"]).toBeUndefined(); @@ -47,6 +45,28 @@ describe("purgeSessionRuntimeState", () => { expect(after.shell.statuses["env-1"]).toBeUndefined(); }); + it("does not expose Query-owned todo or prompt usage mirrors", () => { + const state = store.getState() as unknown as Record; + + expect("agents" in state).toBe(false); + expect("terminal" in state).toBe(false); + expect("setTerminalOutput" in state).toBe(false); + expect("agentCapabilities" in state).toBe(false); + expect("setAgentCapabilities" in state).toBe(false); + expect("sessionPollMode" in state).toBe(false); + expect("setSessionPollMode" in state).toBe(false); + expect("sessionMode" in state).toBe(false); + expect("setSessionMode" in state).toBe(false); + expect("clearSessionMode" in state).toBe(false); + expect("sessionTodos" in state).toBe(false); + expect("setSessionTodos" in state).toBe(false); + expect("promptUsage" in state).toBe(false); + expect("setPromptUsage" in state).toBe(false); + expect("availableCommands" in state).toBe(false); + expect("setAvailableCommands" in state).toBe(false); + expect("clearAvailableCommands" in state).toBe(false); + }); + it("retains env-scoped buffers while another session shares the environment", () => { const s = store.getState(); s.registerSessionEnvironment("session-1", "env-shared"); diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index 255bbfdc0a..0a49aad66e 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -2,7 +2,6 @@ import type { StateCreator } from "zustand"; import type { SessionRuntimeSlice, SessionRuntimeSliceState, - SessionPollMode, GitStatusEntry, FileInfo, } from "./types"; @@ -11,9 +10,9 @@ import { createDebugLogger, isDebug } from "@/lib/debug/log"; const debugGit = createDebugLogger("git-status:store"); const maxProcessOutputBytes = 2 * 1024 * 1024; -// Shell + terminal streams are unbounded over a session's lifetime; cap them at -// the same 2MB tail the process buffer uses so a chatty shell can't grow the -// store without limit (the xterm view only renders the tail anyway). +// Shell streams are unbounded over a session's lifetime; cap them at the same +// 2MB tail the process buffer uses so a chatty shell can't grow the store +// without limit (the xterm view only renders the tail anyway). const maxShellOutputBytes = 2 * 1024 * 1024; /** Compute total additions/deletions across all files. */ @@ -191,30 +190,11 @@ function trimProcessOutput(value: string) { return trimTailBytes(value, maxProcessOutputBytes); } -/** Append a chunk to a terminal's output array, dropping the oldest chunks once - * the buffered total exceeds the cap so the array can't grow without bound. A - * single chunk larger than the cap is itself clamped to the tail, so the buffer - * stays bounded even on the very first (or one giant) write. */ -function appendTerminalChunk(output: string[], data: string) { - output.push(trimTailBytes(data, maxShellOutputBytes)); - let total = output.reduce((sum, chunk) => sum + chunk.length, 0); - while (output.length > 1 && total > maxShellOutputBytes) { - total -= output[0].length; - output.shift(); - } -} - /** Per-session runtime maps (keyed directly by sessionId). */ function purgePerSessionRuntime(state: SessionRuntimeSliceState, sessionId: string) { delete state.contextWindow.bySessionId[sessionId]; - delete state.availableCommands.bySessionId[sessionId]; - delete state.sessionMode.bySessionId[sessionId]; - delete state.agentCapabilities.bySessionId[sessionId]; delete state.sessionModels.bySessionId[sessionId]; - delete state.promptUsage.bySessionId[sessionId]; - delete state.sessionTodos.bySessionId[sessionId]; delete state.prepareProgress.bySessionId[sessionId]; - delete state.sessionPollMode.bySessionId[sessionId]; } /** Process status + output for every process owned by the session. */ @@ -257,7 +237,6 @@ export function purgeSessionRuntimeState(state: SessionRuntimeSliceState, sessio } export const defaultSessionRuntimeState: SessionRuntimeSliceState = { - terminal: { terminals: [] }, shell: { outputs: {}, statuses: {} }, processes: { outputsByProcessId: {}, @@ -270,35 +249,15 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = { environmentIdBySessionId: {}, sessionCommits: { byEnvironmentId: {}, loading: {}, refetchTrigger: {} }, contextWindow: { bySessionId: {} }, - agents: { agents: [] }, - availableCommands: { bySessionId: {} }, - sessionMode: { bySessionId: {} }, - agentCapabilities: { bySessionId: {} }, sessionModels: { bySessionId: {} }, - promptUsage: { bySessionId: {} }, - sessionTodos: { bySessionId: {} }, userShells: { byEnvironmentId: {}, loading: {}, loaded: {} }, prepareProgress: { bySessionId: {} }, - sessionPollMode: { bySessionId: {} }, }; type ImmerSet = Parameters[0]; function buildTerminalShellProcessActions(set: ImmerSet) { return { - setTerminalOutput: (terminalId: string, data: string) => - set((draft) => { - const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId); - if (existing) { - appendTerminalChunk(existing.output, data); - } else { - // Route the first chunk through appendTerminalChunk too so the cap is - // enforced even on the initial WS payload for a new terminal. - const output: string[] = []; - appendTerminalChunk(output, data); - draft.terminal.terminals.push({ id: terminalId, output }); - } - }), appendShellOutput: (sessionId: string, data: string) => set((draft) => { const envKey = draft.environmentIdBySessionId[sessionId] ?? sessionId; @@ -453,10 +412,6 @@ function buildUserShellActions(set: ImmerSet) { s.terminalId === terminalId ? { ...s, ...patch } : s, ); }), - setSessionPollMode: (sessionId: string, mode: SessionPollMode) => - set((draft) => { - draft.sessionPollMode.bySessionId[sessionId] = mode; - }), }; } @@ -556,41 +511,9 @@ export const createSessionRuntimeSlice: StateCreator< }), ...buildContextWindowActions(set), ...buildSessionCommitActions(set), - setAvailableCommands: (sessionId, commands) => - set((draft) => { - draft.availableCommands.bySessionId[sessionId] = commands; - }), - clearAvailableCommands: (sessionId) => - set((draft) => { - delete draft.availableCommands.bySessionId[sessionId]; - }), - setSessionMode: (sessionId, modeId, availableModes) => - set((draft) => { - const existing = draft.sessionMode.bySessionId[sessionId]; - draft.sessionMode.bySessionId[sessionId] = { - currentModeId: modeId, - availableModes: availableModes ?? existing?.availableModes ?? [], - }; - }), - clearSessionMode: (sessionId) => - set((draft) => { - delete draft.sessionMode.bySessionId[sessionId]; - }), - setAgentCapabilities: (sessionId, caps) => - set((draft) => { - draft.agentCapabilities.bySessionId[sessionId] = caps; - }), setSessionModels: (sessionId, data) => set((draft) => { draft.sessionModels.bySessionId[sessionId] = data; }), - setPromptUsage: (sessionId, usage) => - set((draft) => { - draft.promptUsage.bySessionId[sessionId] = usage; - }), - setSessionTodos: (sessionId, entries) => - set((draft) => { - draft.sessionTodos.bySessionId[sessionId] = entries; - }), ...buildUserShellActions(set), }); diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 9b9b258990..a92bbcb77c 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -1,7 +1,3 @@ -export type TerminalState = { - terminals: Array<{ id: string; output: string[] }>; -}; - export type ShellState = { /** Shell output keyed by environmentId (shared across sessions in the same environment). * Falls back to sessionId when no environment mapping exists. */ @@ -160,36 +156,18 @@ export type ContextWindowState = { bySessionId: Record; }; -export type AgentState = { - agents: Array<{ id: string; status: "idle" | "running" | "error" }>; -}; - export type AvailableCommand = { name: string; description?: string; input_hint?: string; }; -export type AvailableCommandsState = { - bySessionId: Record; -}; - export type SessionModeEntry = { id: string; name: string; description?: string; }; -export type SessionModeState = { - bySessionId: Record< - string, - { - currentModeId: string; - availableModes: SessionModeEntry[]; - } - >; -}; - export type AuthMethodEntry = { id: string; name: string; @@ -230,10 +208,6 @@ export type PromptUsageEntry = { totalTokens: number; }; -export type AgentCapabilitiesState = { - bySessionId: Record; -}; - export type SessionModelsState = { bySessionId: Record< string, @@ -245,10 +219,6 @@ export type SessionModelsState = { >; }; -export type PromptUsageState = { - bySessionId: Record; -}; - /** * User shell terminal info. Discriminated by `kind`: * - `ordinary` — a DB-backed first-class terminal. Carries seq + custom_name @@ -323,18 +293,9 @@ export type TodoEntry = { priority?: string; }; -export type SessionTodosState = { - bySessionId: Record; -}; - export type SessionPollMode = "fast" | "slow" | "paused"; -export type SessionPollModeState = { - bySessionId: Record; -}; - export type SessionRuntimeSliceState = { - terminal: TerminalState; shell: ShellState; processes: ProcessState; gitStatus: GitStatusState; @@ -342,20 +303,12 @@ export type SessionRuntimeSliceState = { environmentIdBySessionId: Record; sessionCommits: SessionCommitsState; contextWindow: ContextWindowState; - agents: AgentState; - availableCommands: AvailableCommandsState; - sessionMode: SessionModeState; - agentCapabilities: AgentCapabilitiesState; sessionModels: SessionModelsState; - promptUsage: PromptUsageState; - sessionTodos: SessionTodosState; userShells: UserShellsState; prepareProgress: PrepareProgressState; - sessionPollMode: SessionPollModeState; }; export type SessionRuntimeSliceActions = { - setTerminalOutput: (terminalId: string, data: string) => void; appendShellOutput: (sessionId: string, data: string) => void; setShellStatus: ( sessionId: string, @@ -389,14 +342,6 @@ export type SessionRuntimeSliceActions = { // Signal a refetch without clearing the visible list — see // SessionCommitsState.refetchTrigger. bumpSessionCommitsRefetch: (sessionId: string) => void; - // Available commands actions - setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void; - clearAvailableCommands: (sessionId: string) => void; - // Session mode actions - setSessionMode: (sessionId: string, modeId: string, availableModes?: SessionModeEntry[]) => void; - clearSessionMode: (sessionId: string) => void; - // Agent capabilities actions - setAgentCapabilities: (sessionId: string, caps: AgentCapabilitiesEntry) => void; // Session models actions setSessionModels: ( sessionId: string, @@ -406,10 +351,6 @@ export type SessionRuntimeSliceActions = { configOptions: ConfigOptionEntry[]; }, ) => void; - // Prompt usage actions - setPromptUsage: (sessionId: string, usage: PromptUsageEntry) => void; - // Session todos actions - setSessionTodos: (sessionId: string, entries: TodoEntry[]) => void; // User shells actions — env-scoped (sessions in the same task share one shell list) setUserShells: (environmentId: string, shells: UserShellInfo[]) => void; setUserShellsLoading: (environmentId: string, loading: boolean) => void; @@ -423,7 +364,6 @@ export type SessionRuntimeSliceActions = { // entry. `Omit` removes it from the patch surface. patch: Partial>, ) => void; - setSessionPollMode: (sessionId: string, mode: SessionPollMode) => void; }; export type SessionRuntimeSlice = SessionRuntimeSliceState & SessionRuntimeSliceActions; diff --git a/apps/web/lib/state/slices/session/message-signature.ts b/apps/web/lib/state/slices/session/message-signature.ts index eedbcbd819..f3b5db0130 100644 --- a/apps/web/lib/state/slices/session/message-signature.ts +++ b/apps/web/lib/state/slices/session/message-signature.ts @@ -61,6 +61,19 @@ export function signatureOf(message: Message): string { return signature; } +function isLocalEmptyTurnNotice(message: Message): boolean { + const metadata = message.metadata; + return ( + message.type === "status" && + message.id.startsWith("empty-turn-") && + Boolean(metadata && typeof metadata === "object" && metadata.empty_turn === true) + ); +} + +function chronological(a: Message, b: Message): number { + return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); +} + /** * Reconcile a full snapshot against the previous array, preserving object * identity for messages whose signature is unchanged and returning the prev @@ -73,11 +86,19 @@ export function reconcileMessages(prev: Message[] | undefined, next: Message[]): const prevById = new Map(); for (const message of prev) prevById.set(message.id, message); let identical = prev.length === next.length; + const nextIds = new Set(next.map((message) => message.id)); const result = next.map((message, i) => { const previous = prevById.get(message.id); const reused = previous && signatureOf(previous) === signatureOf(message) ? previous : message; if (reused !== prev[i]) identical = false; return reused; }); + for (const message of prev) { + if (!nextIds.has(message.id) && isLocalEmptyTurnNotice(message)) { + result.push(message); + identical = false; + } + } + if (result.length !== next.length) result.sort(chronological); return identical ? prev : result; } diff --git a/apps/web/lib/state/slices/session/session-slice.merge-messages.test.ts b/apps/web/lib/state/slices/session/session-slice.merge-messages.test.ts index 4c91392949..155f3da9f8 100644 --- a/apps/web/lib/state/slices/session/session-slice.merge-messages.test.ts +++ b/apps/web/lib/state/slices/session/session-slice.merge-messages.test.ts @@ -94,4 +94,25 @@ describe("mergeMessages", () => { expect(meta.hasMore).toBe(true); expect(meta.oldestCursor).toBe("a"); }); + + it("preserves local empty-turn notices across API refetch snapshots", () => { + const store = makeStore(); + const notice = makeMessage({ + id: "empty-turn-turn-1", + author_type: "agent", + content: "`/pr-fixup` ran but produced no output.", + type: "status", + metadata: { empty_turn: true }, + created_at: "2024-01-01T00:00:02Z", + }); + store.getState().mergeMessages(SESSION, [makeMessage({ id: "a", content: "/pr-fixup" })]); + store.getState().addMessage(notice); + + store.getState().mergeMessages(SESSION, [makeMessage({ id: "a", content: "/pr-fixup" })]); + + expect(store.getState().messages.bySession[SESSION].map((message) => message.id)).toEqual([ + "a", + "empty-turn-turn-1", + ]); + }); }); diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts index b326125357..709404462d 100644 --- a/apps/web/lib/state/slices/session/session-slice.ts +++ b/apps/web/lib/state/slices/session/session-slice.ts @@ -112,6 +112,7 @@ function mergeTaskSession(existing: TaskSession, incoming: TaskSession): TaskSes repository_id: incoming.repository_id ?? existing.repository_id, base_branch: incoming.base_branch ?? existing.base_branch, task_environment_id: incoming.task_environment_id ?? existing.task_environment_id, + is_passthrough: incoming.is_passthrough ?? existing.is_passthrough, }; } @@ -124,29 +125,16 @@ export const defaultSessionState: SessionSliceState = { taskSessions: { items: {} }, taskSessionsByTask: { itemsByTaskId: {}, loadingByTaskId: {}, loadedByTaskId: {} }, sessionAgentctl: { itemsBySessionId: {} }, - worktrees: { items: {} }, - sessionWorktreesBySessionId: { itemsBySessionId: {} }, - pendingModel: { bySessionId: {} }, activeModel: { bySessionId: {} }, taskPlans: { - byTaskId: {}, - loadingByTaskId: {}, - loadedByTaskId: {}, - savingByTaskId: {}, - revisionsByTaskId: {}, - revisionsLoadingByTaskId: {}, - revisionsLoadedByTaskId: {}, - revisionContentCache: {}, previewRevisionIdByTaskId: {}, comparePairByTaskId: {}, lastSeenUpdatedAtByTaskId: {}, }, walkthroughs: { - byTaskId: {}, activeStepByTaskId: {}, lastSeenUpdatedAtByTaskId: {}, }, - queue: { bySessionId: {}, metaBySessionId: {}, isLoading: {} }, }; type ImmerSet = Parameters[0]; @@ -256,132 +244,45 @@ function buildMessageActions(set: ImmerSet) { function buildTaskPlanActions(set: ImmerSet, get: ImmerGet) { return { - setTaskPlan: (taskId: string, plan: Parameters[1]) => { - const shouldHydrateLastSeen = get().taskPlans.lastSeenUpdatedAtByTaskId[taskId] === undefined; - const storedLastSeen = shouldHydrateLastSeen ? getPlanLastSeen(taskId) : null; + hydrateTaskPlanLastSeen: (taskId: string) => { + if (get().taskPlans.lastSeenUpdatedAtByTaskId[taskId] !== undefined) return; + const storedLastSeen = getPlanLastSeen(taskId); + if (storedLastSeen === null) return; set((draft) => { - draft.taskPlans.byTaskId[taskId] = plan; - draft.taskPlans.loadingByTaskId[taskId] = false; - draft.taskPlans.loadedByTaskId[taskId] = true; - if (shouldHydrateLastSeen && storedLastSeen !== null) { - draft.taskPlans.lastSeenUpdatedAtByTaskId[taskId] = storedLastSeen; - } + draft.taskPlans.lastSeenUpdatedAtByTaskId[taskId] = storedLastSeen; }); }, - setTaskPlanLoading: (taskId: string, loading: boolean) => - set((draft) => { - draft.taskPlans.loadingByTaskId[taskId] = loading; - }), - setTaskPlanSaving: (taskId: string, saving: boolean) => - set((draft) => { - draft.taskPlans.savingByTaskId[taskId] = saving; - }), - clearTaskPlan: (taskId: string) => { - setPlanLastSeen(taskId, null); - set((draft) => { - // revisionContentCache is keyed by revisionId, so pick the IDs for this - // task before deleting the revisions list and drop their cache entries. - const revs = draft.taskPlans.revisionsByTaskId[taskId]; - if (revs) { - for (const r of revs) { - delete draft.taskPlans.revisionContentCache[r.id]; - } - } - delete draft.taskPlans.byTaskId[taskId]; - delete draft.taskPlans.loadingByTaskId[taskId]; - delete draft.taskPlans.loadedByTaskId[taskId]; - delete draft.taskPlans.savingByTaskId[taskId]; - delete draft.taskPlans.revisionsByTaskId[taskId]; - delete draft.taskPlans.revisionsLoadingByTaskId[taskId]; - delete draft.taskPlans.revisionsLoadedByTaskId[taskId]; - delete draft.taskPlans.previewRevisionIdByTaskId[taskId]; - delete draft.taskPlans.comparePairByTaskId[taskId]; - delete draft.taskPlans.lastSeenUpdatedAtByTaskId[taskId]; - }); - }, - markTaskPlanSeen: (taskId: string) => { - const plan = get().taskPlans.byTaskId[taskId]; - const lastSeen = plan?.updated_at ?? ""; + markTaskPlanSeen: (taskId: string, updatedAt?: string | null) => { + const lastSeen = updatedAt ?? ""; setPlanLastSeen(taskId, lastSeen); set((draft) => { draft.taskPlans.lastSeenUpdatedAtByTaskId[taskId] = lastSeen; }); }, - setPlanRevisions: ( - taskId: string, - revisions: Parameters[1], - ) => - set((draft) => { - draft.taskPlans.revisionsByTaskId[taskId] = [...revisions].sort( - (a, b) => b.revision_number - a.revision_number, - ); - draft.taskPlans.revisionsLoadedByTaskId[taskId] = true; - draft.taskPlans.revisionsLoadingByTaskId[taskId] = false; - }), - upsertPlanRevision: ( - taskId: string, - revision: Parameters[1], - ) => - set((draft) => { - const list = draft.taskPlans.revisionsByTaskId[taskId] ?? []; - const idx = list.findIndex((r) => r.id === revision.id); - if (idx === -1) { - list.unshift(revision); - } else { - list[idx] = { ...list[idx], ...revision }; - // Coalesced writes update an existing revision's content on the - // backend, but the WS payload carries metadata only — drop any - // cached content so the next preview refetches. - delete draft.taskPlans.revisionContentCache[revision.id]; - } - list.sort((a, b) => b.revision_number - a.revision_number); - draft.taskPlans.revisionsByTaskId[taskId] = list; - }), - setPlanRevisionsLoading: (taskId: string, loading: boolean) => - set((draft) => { - draft.taskPlans.revisionsLoadingByTaskId[taskId] = loading; - }), - cachePlanRevisionContent: (revisionId: string, content: string) => - set((draft) => { - draft.taskPlans.revisionContentCache[revisionId] = content; - }), ...buildPreviewCompareActions(set), }; } function buildWalkthroughActions(set: ImmerSet, get: ImmerGet) { return { - setWalkthrough: ( - taskId: string, - walkthrough: Parameters[1], - ) => { - const shouldHydrateLastSeen = - get().walkthroughs.lastSeenUpdatedAtByTaskId[taskId] === undefined; - const storedLastSeen = shouldHydrateLastSeen ? getWalkthroughLastSeen(taskId) : null; + hydrateWalkthroughLastSeen: (taskId: string) => { + if (get().walkthroughs.lastSeenUpdatedAtByTaskId[taskId] !== undefined) return; + const storedLastSeen = getWalkthroughLastSeen(taskId); + if (storedLastSeen === null) return; set((draft) => { - const previous = draft.walkthroughs.byTaskId[taskId]; - draft.walkthroughs.byTaskId[taskId] = walkthrough; - // Clamp the active step into the new step range (defaults to 0). A - // replaced/shorter tour must not leave the pointer past the last step. - const steps = walkthrough?.steps.length ?? 0; - const isReplacement = previous?.id !== walkthrough?.id; - const current = isReplacement ? 0 : (draft.walkthroughs.activeStepByTaskId[taskId] ?? 0); - draft.walkthroughs.activeStepByTaskId[taskId] = - steps === 0 ? 0 : Math.min(current, steps - 1); - if (shouldHydrateLastSeen && storedLastSeen !== null) { - draft.walkthroughs.lastSeenUpdatedAtByTaskId[taskId] = storedLastSeen; - } + draft.walkthroughs.lastSeenUpdatedAtByTaskId[taskId] = storedLastSeen; }); }, - setWalkthroughActiveStep: (taskId: string, stepIndex: number) => + setWalkthroughActiveStep: (taskId: string, stepIndex: number, stepCount?: number) => set((draft) => { - const steps = draft.walkthroughs.byTaskId[taskId]?.steps.length ?? 0; - const clamped = steps === 0 ? 0 : Math.max(0, Math.min(stepIndex, steps - 1)); + const clamped = + stepCount === undefined || stepCount <= 0 + ? Math.max(0, stepIndex) + : Math.max(0, Math.min(stepIndex, stepCount - 1)); draft.walkthroughs.activeStepByTaskId[taskId] = clamped; }), - markWalkthroughSeen: (taskId: string) => { - const wt = get().walkthroughs.byTaskId[taskId]; - const lastSeen = wt?.updated_at ?? ""; + markWalkthroughSeen: (taskId: string, updatedAt?: string | null) => { + const lastSeen = updatedAt ?? ""; setWalkthroughLastSeen(taskId, lastSeen); set((draft) => { draft.walkthroughs.lastSeenUpdatedAtByTaskId[taskId] = lastSeen; @@ -541,51 +442,10 @@ export const createSessionSlice: StateCreator< set((draft) => { draft.sessionAgentctl.itemsBySessionId[sessionId] = status; }), - setWorktree: (worktree) => - set((draft) => { - draft.worktrees.items[worktree.id] = worktree; - }), - setSessionWorktrees: (sessionId, worktreeIds) => - set((draft) => { - draft.sessionWorktreesBySessionId.itemsBySessionId[sessionId] = worktreeIds; - }), - setPendingModel: (sessionId, modelId) => - set((draft) => { - draft.pendingModel.bySessionId[sessionId] = modelId; - }), - clearPendingModel: (sessionId) => - set((draft) => { - delete draft.pendingModel.bySessionId[sessionId]; - }), setActiveModel: (sessionId, modelId) => set((draft) => { draft.activeModel.bySessionId[sessionId] = modelId; }), ...buildTaskPlanActions(set, get), ...buildWalkthroughActions(set, get), - setQueueEntries: (sessionId, entries, meta) => - set((draft) => { - draft.queue.bySessionId[sessionId] = entries; - draft.queue.metaBySessionId[sessionId] = meta; - }), - removeQueueEntry: (sessionId, entryId) => - set((draft) => { - const list = draft.queue.bySessionId[sessionId]; - if (!list) return; - draft.queue.bySessionId[sessionId] = list.filter((entry) => entry.id !== entryId); - const meta = draft.queue.metaBySessionId[sessionId]; - if (meta) { - meta.count = draft.queue.bySessionId[sessionId].length; - } - }), - setQueueLoading: (sessionId, loading) => - set((draft) => { - draft.queue.isLoading[sessionId] = loading; - }), - clearQueueStatus: (sessionId) => - set((draft) => { - delete draft.queue.bySessionId[sessionId]; - delete draft.queue.metaBySessionId[sessionId]; - delete draft.queue.isLoading[sessionId]; - }), }); diff --git a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts index c5f2c78c5c..d44359c65d 100644 --- a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts +++ b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts @@ -3,7 +3,7 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { createSessionSlice } from "./session-slice"; import { createSessionRuntimeSlice } from "../session-runtime/session-runtime-slice"; -import type { QueuedMessage, SessionSlice } from "./types"; +import type { SessionSlice } from "./types"; import type { SessionRuntimeSlice } from "../session-runtime/types"; import { agentProfileId as toAgentProfileId, @@ -53,6 +53,14 @@ function makeSession(overrides: SessionOverrides = {}): TaskSession { } describe("upsertTaskSessionFromEvent", () => { + it("does not expose unused pending-model mirror state", () => { + const state = makeStore().getState() as unknown as Record; + + expect("pendingModel" in state).toBe(false); + expect("setPendingModel" in state).toBe(false); + expect("clearPendingModel" in state).toBe(false); + }); + it("does not flip loadedByTaskId so API hydration can still run", () => { const store = makeStore(); @@ -131,67 +139,31 @@ describe("setTaskSessionsForTask preserves WS-seeded fields", () => { expect(store.getState().environmentIdBySessionId[SESSION_ID]).toBe("env-1"); }); - it("flips loadedByTaskId to true (unlike upsertTaskSessionFromEvent)", () => { + it("merges routing fields from HTTP hydration over a WS-seeded row", () => { const store = makeStore(); - store.getState().setTaskSessionsForTask(TASK_ID, [makeSession()]); - - expect(store.getState().taskSessionsByTask.loadedByTaskId[TASK_ID]).toBe(true); - }); -}); - -function makeEntry(overrides: Partial = {}): QueuedMessage { - return { - id: "entry-1", - session_id: SESSION_ID, - task_id: TASK_ID, - content: "hello", - plan_mode: false, - queued_at: TS, - queued_by: "user", - ...overrides, - }; -} - -describe("queue actions", () => { - it("setQueueEntries stores the ordered list and capacity meta", () => { - const store = makeStore(); - const entries = [ - makeEntry({ id: "e1", content: "first" }), - makeEntry({ id: "e2", content: "second" }), - ]; - - store.getState().setQueueEntries(SESSION_ID, entries, { count: 2, max: 10 }); - - expect(store.getState().queue.bySessionId[SESSION_ID]).toEqual(entries); - expect(store.getState().queue.metaBySessionId[SESSION_ID]).toEqual({ count: 2, max: 10 }); - }); - - it("removeQueueEntry drops a single entry by id and refreshes meta.count", () => { - const store = makeStore(); - const entries = [makeEntry({ id: "e1" }), makeEntry({ id: "e2" }), makeEntry({ id: "e3" })]; - store.getState().setQueueEntries(SESSION_ID, entries, { count: 3, max: 10 }); - - store.getState().removeQueueEntry(SESSION_ID, "e2"); + store + .getState() + .upsertTaskSessionFromEvent(TASK_ID, makeSession({ task_environment_id: "env-1" })); - expect(store.getState().queue.bySessionId[SESSION_ID].map((e) => e.id)).toEqual(["e1", "e3"]); - expect(store.getState().queue.metaBySessionId[SESSION_ID].count).toBe(2); - expect(store.getState().queue.metaBySessionId[SESSION_ID].max).toBe(10); - }); + store.getState().setTaskSessionsForTask(TASK_ID, [ + makeSession({ + task_environment_id: "env-1", + is_passthrough: true, + agent_profile_snapshot: { cli_passthrough: true }, + }), + ]); - it("removeQueueEntry is a no-op when the session has no entries", () => { - const store = makeStore(); - store.getState().removeQueueEntry(SESSION_ID, "missing"); - expect(store.getState().queue.bySessionId[SESSION_ID]).toBeUndefined(); + const session = store.getState().taskSessions.items[SESSION_ID]; + expect(session.is_passthrough).toBe(true); + expect(session.agent_profile_snapshot).toEqual({ cli_passthrough: true }); }); - it("clearQueueStatus removes both entries and meta", () => { + it("flips loadedByTaskId to true (unlike upsertTaskSessionFromEvent)", () => { const store = makeStore(); - store.getState().setQueueEntries(SESSION_ID, [makeEntry()], { count: 1, max: 10 }); - store.getState().clearQueueStatus(SESSION_ID); + store.getState().setTaskSessionsForTask(TASK_ID, [makeSession()]); - expect(store.getState().queue.bySessionId[SESSION_ID]).toBeUndefined(); - expect(store.getState().queue.metaBySessionId[SESSION_ID]).toBeUndefined(); + expect(store.getState().taskSessionsByTask.loadedByTaskId[TASK_ID]).toBe(true); }); }); diff --git a/apps/web/lib/state/slices/session/task-plans.test.ts b/apps/web/lib/state/slices/session/task-plans.test.ts index e72870fa96..83f2b3fcd0 100644 --- a/apps/web/lib/state/slices/session/task-plans.test.ts +++ b/apps/web/lib/state/slices/session/task-plans.test.ts @@ -3,7 +3,6 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { createSessionSlice } from "./session-slice"; import type { SessionSlice } from "./types"; -import type { TaskPlan } from "@/lib/types/http"; const mockGetPlanLastSeen = vi.fn(); const mockSetPlanLastSeen = vi.fn(); @@ -18,22 +17,7 @@ function makeStore() { } const TASK_ID = "task-1"; -const TS_EPOCH = "2026-04-20T00:00:00Z"; const TS_LATER = "2026-04-20T01:00:00Z"; -const TS_LATEST = "2026-04-20T02:00:00Z"; - -function makePlan(overrides: Partial = {}): TaskPlan { - return { - id: "plan-1", - task_id: TASK_ID, - title: "Plan", - content: "# Plan", - created_by: "agent", - created_at: TS_EPOCH, - updated_at: TS_EPOCH, - ...overrides, - }; -} describe("task plan slice", () => { beforeEach(() => { @@ -41,11 +25,10 @@ describe("task plan slice", () => { mockGetPlanLastSeen.mockReturnValue(null); }); - it("markTaskPlanSeen writes the current plan updated_at", () => { + it("markTaskPlanSeen writes the provided plan updated_at", () => { const store = makeStore(); - store.getState().setTaskPlan(TASK_ID, makePlan({ updated_at: TS_LATER })); - store.getState().markTaskPlanSeen(TASK_ID); + store.getState().markTaskPlanSeen(TASK_ID, TS_LATER); expect(store.getState().taskPlans.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_LATER); expect(mockSetPlanLastSeen).toHaveBeenCalledWith(TASK_ID, TS_LATER); @@ -60,35 +43,44 @@ describe("task plan slice", () => { expect(mockSetPlanLastSeen).toHaveBeenCalledWith("task-missing", ""); }); - it("setTaskPlan hydrates stored lastSeenUpdatedAtByTaskId", () => { + it("hydrates stored lastSeenUpdatedAtByTaskId", () => { mockGetPlanLastSeen.mockReturnValue(TS_LATER); const store = makeStore(); - store.getState().setTaskPlan(TASK_ID, makePlan({ updated_at: TS_LATER })); + store.getState().hydrateTaskPlanLastSeen(TASK_ID); expect(store.getState().taskPlans.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_LATER); }); - it("setTaskPlan does not change lastSeenUpdatedAtByTaskId", () => { + it("does not rehydrate over explicit lastSeenUpdatedAtByTaskId", () => { const store = makeStore(); - store.getState().setTaskPlan(TASK_ID, makePlan({ updated_at: TS_EPOCH })); - store.getState().markTaskPlanSeen(TASK_ID); + store.getState().markTaskPlanSeen(TASK_ID, TS_LATER); + mockGetPlanLastSeen.mockReturnValue("2026-04-20T02:00:00Z"); - // New update arrives — seen should NOT advance automatically - store.getState().setTaskPlan(TASK_ID, makePlan({ updated_at: TS_LATEST })); + store.getState().hydrateTaskPlanLastSeen(TASK_ID); - expect(store.getState().taskPlans.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_EPOCH); + expect(store.getState().taskPlans.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_LATER); }); - it("clearTaskPlan removes the lastSeen entry", () => { - const store = makeStore(); - store.getState().setTaskPlan(TASK_ID, makePlan()); - store.getState().markTaskPlanSeen(TASK_ID); - - store.getState().clearTaskPlan(TASK_ID); - - expect(store.getState().taskPlans.lastSeenUpdatedAtByTaskId[TASK_ID]).toBeUndefined(); - expect(store.getState().taskPlans.byTaskId[TASK_ID]).toBeUndefined(); - expect(mockSetPlanLastSeen).toHaveBeenCalledWith(TASK_ID, null); + it("does not expose task-plan DTO server-state through the session slice", () => { + const state = makeStore().getState() as unknown as { + taskPlans: Record; + } & Record; + + expect("byTaskId" in state.taskPlans).toBe(false); + expect("loadingByTaskId" in state.taskPlans).toBe(false); + expect("loadedByTaskId" in state.taskPlans).toBe(false); + expect("savingByTaskId" in state.taskPlans).toBe(false); + expect("setTaskPlan" in state).toBe(false); + expect("setTaskPlanLoading" in state).toBe(false); + expect("setTaskPlanSaving" in state).toBe(false); + expect("revisionsByTaskId" in state.taskPlans).toBe(false); + expect("revisionsLoadingByTaskId" in state.taskPlans).toBe(false); + expect("revisionsLoadedByTaskId" in state.taskPlans).toBe(false); + expect("revisionContentCache" in state.taskPlans).toBe(false); + expect("setPlanRevisions" in state).toBe(false); + expect("upsertPlanRevision" in state).toBe(false); + expect("setPlanRevisionsLoading" in state).toBe(false); + expect("cachePlanRevisionContent" in state).toBe(false); }); }); diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts index b22837304f..1c23bc7b09 100644 --- a/apps/web/lib/state/slices/session/types.ts +++ b/apps/web/lib/state/slices/session/types.ts @@ -1,11 +1,4 @@ -import type { - Message, - TaskSession, - Turn, - TaskPlan, - TaskPlanRevision, - TaskWalkthrough, -} from "@/lib/types/http"; +import type { Message, TaskSession, Turn } from "@/lib/types/http"; export type MessagesState = { bySession: Record; @@ -53,18 +46,6 @@ export type Worktree = { branch?: string; }; -export type WorktreesState = { - items: Record; -}; - -export type SessionWorktreesState = { - itemsBySessionId: Record; -}; - -export type PendingModelState = { - bySessionId: Record; -}; - export type ActiveModelState = { bySessionId: Record; }; @@ -74,14 +55,6 @@ export type ActiveModelState = { export type ComparePair = [string | null, string | null]; export type TaskPlansState = { - byTaskId: Record; - loadingByTaskId: Record; - loadedByTaskId: Record; - savingByTaskId: Record; - revisionsByTaskId: Record; - revisionsLoadingByTaskId: Record; - revisionsLoadedByTaskId: Record; - revisionContentCache: Record; // revisionId -> content // Phase 6: preview + compare state previewRevisionIdByTaskId: Record; comparePairByTaskId: Record; @@ -91,8 +64,6 @@ export type TaskPlansState = { }; export type WalkthroughsState = { - /** The current walkthrough per task (null = explicitly none). */ - byTaskId: Record; /** The active step index per task (drives the popover position). */ activeStepByTaskId: Record; /** The last `updated_at` the user has opened, for the unseen-dot indicator. */ @@ -130,39 +101,21 @@ export type QueuedMessage = { queued_by?: string; }; -/** Capacity info kept alongside the entry list. */ -export type QueueMeta = { - count: number; - max: number; -}; - export type QueueStatus = { entries: QueuedMessage[]; count: number; max: number; }; -export type QueueState = { - /** Ordered list of pending entries per session (FIFO; head at index 0). */ - bySessionId: Record; - /** Per-session capacity snapshot from the latest server response. */ - metaBySessionId: Record; - isLoading: Record; -}; - export type SessionSliceState = { messages: MessagesState; turns: TurnsState; taskSessions: TaskSessionsState; taskSessionsByTask: TaskSessionsByTaskState; sessionAgentctl: SessionAgentctlState; - worktrees: WorktreesState; - sessionWorktreesBySessionId: SessionWorktreesState; - pendingModel: PendingModelState; activeModel: ActiveModelState; taskPlans: TaskPlansState; walkthroughs: WalkthroughsState; - queue: QueueState; }; export type SessionSliceActions = { @@ -209,35 +162,18 @@ export type SessionSliceActions = { upsertTaskSessionFromEvent: (taskId: string, session: TaskSession) => void; setTaskSessionsLoading: (taskId: string, loading: boolean) => void; setSessionAgentctlStatus: (sessionId: string, status: SessionAgentctlStatus) => void; - setWorktree: (worktree: Worktree) => void; - setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void; - setPendingModel: (sessionId: string, modelId: string) => void; - clearPendingModel: (sessionId: string) => void; setActiveModel: (sessionId: string, modelId: string) => void; - // Task plan actions - setTaskPlan: (taskId: string, plan: TaskPlan | null) => void; - setTaskPlanLoading: (taskId: string, loading: boolean) => void; - setTaskPlanSaving: (taskId: string, saving: boolean) => void; - clearTaskPlan: (taskId: string) => void; - markTaskPlanSeen: (taskId: string) => void; - // Revision actions - setPlanRevisions: (taskId: string, revisions: TaskPlanRevision[]) => void; - upsertPlanRevision: (taskId: string, revision: TaskPlanRevision) => void; - setPlanRevisionsLoading: (taskId: string, loading: boolean) => void; - cachePlanRevisionContent: (revisionId: string, content: string) => void; + // Task plan UI actions. The plan DTO itself is TanStack Query-owned. + hydrateTaskPlanLastSeen: (taskId: string) => void; + markTaskPlanSeen: (taskId: string, updatedAt?: string | null) => void; // Phase 6: preview + compare actions setPreviewRevision: (taskId: string, revisionId: string | null) => void; toggleComparePair: (taskId: string, revisionId: string) => void; clearComparePair: (taskId: string) => void; // Walkthrough actions - setWalkthrough: (taskId: string, walkthrough: TaskWalkthrough | null) => void; - setWalkthroughActiveStep: (taskId: string, stepIndex: number) => void; - markWalkthroughSeen: (taskId: string) => void; - // Queue actions - setQueueEntries: (sessionId: string, entries: QueuedMessage[], meta: QueueMeta) => void; - removeQueueEntry: (sessionId: string, entryId: string) => void; - setQueueLoading: (sessionId: string, loading: boolean) => void; - clearQueueStatus: (sessionId: string) => void; + hydrateWalkthroughLastSeen: (taskId: string) => void; + setWalkthroughActiveStep: (taskId: string, stepIndex: number, stepCount?: number) => void; + markWalkthroughSeen: (taskId: string, updatedAt?: string | null) => void; }; export type SessionSlice = SessionSliceState & SessionSliceActions; diff --git a/apps/web/lib/state/slices/session/walkthroughs.test.ts b/apps/web/lib/state/slices/session/walkthroughs.test.ts index df4c798443..4719b259b6 100644 --- a/apps/web/lib/state/slices/session/walkthroughs.test.ts +++ b/apps/web/lib/state/slices/session/walkthroughs.test.ts @@ -3,7 +3,6 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { createSessionSlice } from "./session-slice"; import type { SessionSlice } from "./types"; -import type { TaskWalkthrough, WalkthroughStep } from "@/lib/types/http"; const mockGetPlanLastSeen = vi.fn(); const mockSetPlanLastSeen = vi.fn(); @@ -28,27 +27,6 @@ const TASK_ID = "task-1"; const TS_EPOCH = "2026-04-20T00:00:00Z"; const TS_LATER = "2026-04-20T01:00:00Z"; -function steps(n: number): WalkthroughStep[] { - return Array.from({ length: n }, (_, i) => ({ - file: `f${i}.go`, - line: i + 1, - text: `step ${i}`, - })); -} - -function makeWalkthrough(overrides: Partial = {}): TaskWalkthrough { - return { - id: "wt-1", - task_id: TASK_ID, - title: "Walkthrough", - steps: steps(3), - created_by: "agent", - created_at: TS_EPOCH, - updated_at: TS_EPOCH, - ...overrides, - }; -} - describe("walkthrough slice", () => { beforeEach(() => { vi.clearAllMocks(); @@ -56,81 +34,55 @@ describe("walkthrough slice", () => { mockGetWalkthroughLastSeen.mockReturnValue(null); }); - it("setWalkthrough stores the tour and defaults the active step to 0", () => { + it("starts without server-owned walkthrough payload state", () => { const store = makeStore(); - store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); - expect(store.getState().walkthroughs.byTaskId[TASK_ID]?.steps).toHaveLength(3); - expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); + expect("byTaskId" in store.getState().walkthroughs).toBe(false); + expect(store.getState().walkthroughs.activeStepByTaskId).toEqual({}); }); - it("setWalkthroughActiveStep clamps within [0, len-1]", () => { + it("setWalkthroughActiveStep clamps within [0, stepCount-1]", () => { const store = makeStore(); - store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); - store.getState().setWalkthroughActiveStep(TASK_ID, 99); + store.getState().setWalkthroughActiveStep(TASK_ID, 99, 3); expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(2); - store.getState().setWalkthroughActiveStep(TASK_ID, -5); - expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); - }); - - it("replacing with a shorter tour clamps the active step back into range", () => { - const store = makeStore(); - store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); - store.getState().setWalkthroughActiveStep(TASK_ID, 2); - - store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ steps: steps(1) })); - + store.getState().setWalkthroughActiveStep(TASK_ID, -5, 3); expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); }); - it("replacing with a different tour resets the active step", () => { + it("setWalkthroughActiveStep allows non-negative values when no step count is known", () => { const store = makeStore(); - store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); - store.getState().setWalkthroughActiveStep(TASK_ID, 2); - - store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ id: "wt-2" })); - - expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); - }); - it("setWalkthrough(null) resets the active step to 0", () => { - const store = makeStore(); - store.getState().setWalkthrough(TASK_ID, makeWalkthrough()); store.getState().setWalkthroughActiveStep(TASK_ID, 2); - store.getState().setWalkthrough(TASK_ID, null); - - expect(store.getState().walkthroughs.byTaskId[TASK_ID]).toBeNull(); - expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(0); + expect(store.getState().walkthroughs.activeStepByTaskId[TASK_ID]).toBe(2); }); - it("markWalkthroughSeen records the current updated_at", () => { + it("markWalkthroughSeen records the supplied updated_at", () => { const store = makeStore(); - store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ updated_at: TS_LATER })); - store.getState().markWalkthroughSeen(TASK_ID); + store.getState().markWalkthroughSeen(TASK_ID, TS_LATER); expect(store.getState().walkthroughs.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_LATER); expect(mockSetWalkthroughLastSeen).toHaveBeenCalledWith(TASK_ID, TS_LATER); }); - it("setWalkthrough hydrates stored lastSeenUpdatedAtByTaskId", () => { + it("hydrateWalkthroughLastSeen hydrates stored lastSeenUpdatedAtByTaskId", () => { mockGetWalkthroughLastSeen.mockReturnValue(TS_LATER); const store = makeStore(); - store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ updated_at: TS_LATER })); + store.getState().hydrateWalkthroughLastSeen(TASK_ID); expect(store.getState().walkthroughs.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_LATER); }); - it("setWalkthrough does not advance lastSeenUpdatedAtByTaskId on update", () => { + it("hydrateWalkthroughLastSeen does not overwrite a locally marked timestamp", () => { const store = makeStore(); - store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ updated_at: TS_EPOCH })); - store.getState().markWalkthroughSeen(TASK_ID); + store.getState().markWalkthroughSeen(TASK_ID, TS_EPOCH); + mockGetWalkthroughLastSeen.mockReturnValue(TS_LATER); - store.getState().setWalkthrough(TASK_ID, makeWalkthrough({ updated_at: TS_LATER })); + store.getState().hydrateWalkthroughLastSeen(TASK_ID); expect(store.getState().walkthroughs.lastSeenUpdatedAtByTaskId[TASK_ID]).toBe(TS_EPOCH); }); diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index ad45b6bdeb..26167a6ee0 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -3,24 +3,6 @@ import { DEFAULT_TASKS_LIST_GROUP, DEFAULT_TASKS_LIST_SORT } from "@/lib/tasks/t import { DEFAULT_VOICE_MODE_STATE, type SettingsSlice, type SettingsSliceState } from "./types"; export const defaultSettingsState: SettingsSliceState = { - executors: { items: [] }, - settingsAgents: { items: [] }, - agentDiscovery: { items: [], loading: false, loaded: false }, - availableAgents: { items: [], tools: [], loading: false, loaded: false }, - agentProfiles: { items: [], version: 0 }, - installJobs: { byAgent: {} }, - editors: { items: [], loaded: false, loading: false }, - prompts: { items: [], loaded: false, loading: false }, - secrets: { items: [], loaded: false, loading: false }, - sprites: { status: null, instances: [], loaded: false, loading: false }, - notificationProviders: { - items: [], - events: [], - appriseAvailable: false, - loaded: false, - loading: false, - }, - settingsData: { executorsLoaded: false, agentsLoaded: false }, userSettings: { workspaceId: null, kanbanViewMode: null, @@ -72,221 +54,12 @@ type ImmerSet = Parameters< StateCreator >[0]; -// installJobStartedAtMs parses started_at to epoch ms so we compare on time, -// not lexicographically. RFC3339 strings with variable fractional seconds -// would otherwise misorder ("2026-05-11T10:00:00Z" sorts after -// "2026-05-11T10:00:00.1Z" despite being older). -function installJobStartedAtMs(job: { started_at: string }): number { - return Date.parse(job.started_at); -} - -function createInstallJobActions( - set: ImmerSet, -): Pick< - SettingsSlice, - "setInstallJobs" | "upsertInstallJob" | "appendInstallOutput" | "clearInstallJob" -> { - return { - setInstallJobs: (jobs) => - set((draft) => { - const byAgent: Record = {}; - for (const job of jobs) { - // If two jobs target the same agent (a current run + a stale - // finished snapshot in retention), prefer the newest start. - const existing = byAgent[job.agent_name]; - if (!existing || installJobStartedAtMs(job) > installJobStartedAtMs(existing)) { - byAgent[job.agent_name] = job; - } - } - draft.installJobs.byAgent = byAgent; - }), - upsertInstallJob: (job) => - set((draft) => { - const current = draft.installJobs.byAgent[job.agent_name]; - // Drop stale events from a previous job_id (e.g. after retry). - if ( - current && - current.job_id !== job.job_id && - installJobStartedAtMs(current) > installJobStartedAtMs(job) - ) { - return; - } - draft.installJobs.byAgent[job.agent_name] = job; - }), - appendInstallOutput: (agentName, chunk) => - set((draft) => { - const current = draft.installJobs.byAgent[agentName]; - if (!current) return; - const next = (current.output ?? "") + chunk; - // Cap at 64KB; drop oldest chars on overflow so the live tail stays current. - const max = 64 * 1024; - current.output = next.length > max ? next.slice(next.length - max) : next; - }), - clearInstallJob: (agentName) => - set((draft) => { - delete draft.installJobs.byAgent[agentName]; - }), - }; -} - -function createCoreActions( - set: ImmerSet, -): Pick< - SettingsSlice, - | "setExecutors" - | "setSettingsAgents" - | "setAgentDiscovery" - | "setAgentDiscoveryLoading" - | "setAvailableAgents" - | "setAvailableAgentsLoading" - | "setAgentProfiles" - | "setEditors" - | "setEditorsLoading" - | "setPrompts" - | "setPromptsLoading" - | "setSettingsData" - | "setUserSettings" - | "bumpAgentProfilesVersion" -> { +function createCoreActions(set: ImmerSet): Pick { return { - setExecutors: (executors) => - set((draft) => { - draft.executors.items = executors; - }), - setSettingsAgents: (agents) => - set((draft) => { - draft.settingsAgents.items = agents; - }), - setAgentDiscovery: (agents) => - set((draft) => { - draft.agentDiscovery.items = agents; - draft.agentDiscovery.loading = false; - draft.agentDiscovery.loaded = true; - }), - setAgentDiscoveryLoading: (loading) => - set((draft) => { - draft.agentDiscovery.loading = loading; - }), - setAvailableAgents: (agents, tools) => - set((draft) => { - draft.availableAgents.items = agents; - if (tools) draft.availableAgents.tools = tools; - draft.availableAgents.loading = false; - draft.availableAgents.loaded = true; - }), - setAvailableAgentsLoading: (loading) => - set((draft) => { - draft.availableAgents.loading = loading; - }), - setAgentProfiles: (profiles) => - set((draft) => { - draft.agentProfiles.items = profiles; - }), - setEditors: (editors) => - set((draft) => { - draft.editors.items = editors; - draft.editors.loaded = true; - }), - setEditorsLoading: (loading) => - set((draft) => { - draft.editors.loading = loading; - }), - setPrompts: (prompts) => - set((draft) => { - draft.prompts.items = prompts; - draft.prompts.loaded = true; - }), - setPromptsLoading: (loading) => - set((draft) => { - draft.prompts.loading = loading; - }), - setSettingsData: (next) => - set((draft) => { - draft.settingsData = { ...draft.settingsData, ...next }; - }), setUserSettings: (settings) => set((draft) => { draft.userSettings = settings; }), - bumpAgentProfilesVersion: () => - set((draft) => { - draft.agentProfiles.version += 1; - }), - }; -} - -function createSecretAndSpriteActions( - set: ImmerSet, -): Pick< - SettingsSlice, - | "setSecrets" - | "setSecretsLoading" - | "addSecret" - | "updateSecret" - | "removeSecret" - | "setSpritesStatus" - | "setSpritesInstances" - | "setSpritesLoading" - | "removeSpritesInstance" - | "setNotificationProviders" - | "setNotificationProvidersLoading" -> { - return { - setSecrets: (items) => - set((draft) => { - draft.secrets.items = items; - draft.secrets.loaded = true; - }), - setSecretsLoading: (loading) => - set((draft) => { - draft.secrets.loading = loading; - }), - addSecret: (item) => - set((draft) => { - draft.secrets.items = [...draft.secrets.items.filter((s) => s.id !== item.id), item]; - }), - updateSecret: (item) => - set((draft) => { - const idx = draft.secrets.items.findIndex((s) => s.id === item.id); - if (idx >= 0) draft.secrets.items[idx] = { ...draft.secrets.items[idx], ...item }; - }), - removeSecret: (id) => - set((draft) => { - draft.secrets.items = draft.secrets.items.filter((s) => s.id !== id); - }), - setSpritesStatus: (status) => - set((draft) => { - draft.sprites.status = status; - draft.sprites.loaded = true; - }), - setSpritesInstances: (instances) => - set((draft) => { - draft.sprites.instances = instances; - draft.sprites.loaded = true; - }), - setSpritesLoading: (loading) => - set((draft) => { - draft.sprites.loading = loading; - }), - removeSpritesInstance: (name) => - set((draft) => { - draft.sprites.instances = draft.sprites.instances.filter((i) => i.name !== name); - if (draft.sprites.status) { - draft.sprites.status.instance_count = draft.sprites.instances.length; - } - }), - setNotificationProviders: (state) => - set((draft) => { - draft.notificationProviders.items = state.items; - draft.notificationProviders.events = state.events; - draft.notificationProviders.appriseAvailable = state.appriseAvailable; - draft.notificationProviders.loaded = state.loaded; - draft.notificationProviders.loading = state.loading; - }), - setNotificationProvidersLoading: (loading) => - set((draft) => { - draft.notificationProviders.loading = loading; - }), }; } @@ -298,6 +71,4 @@ export const createSettingsSlice: StateCreator< > = (set) => ({ ...defaultSettingsState, ...createCoreActions(set), - ...createInstallJobActions(set), - ...createSecretAndSpriteActions(set), }); diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index cba073f37a..9a3e92a156 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -1,16 +1,4 @@ -import type { - Agent, - AgentProfile, - AvailableAgent, - AgentDiscovery, - CapabilityStatus, - CustomPrompt, - EditorOption, - Executor, - NotificationProvider, - SavedLayout, - ToolStatus, -} from "@/lib/types/http"; +import type { Agent, AgentProfile, CapabilityStatus, SavedLayout } from "@/lib/types/http"; import type { VoiceInputActivationMode, VoiceInputEngine, @@ -18,31 +6,8 @@ import type { } from "@/lib/types/http-voice"; import type { SidebarView, SidebarViewDraft } from "@/lib/state/slices/ui/sidebar-view-types"; import type { SidebarTaskPrefsState } from "@/lib/state/slices/ui/types"; -import type { SecretListItem } from "@/lib/types/http-secrets"; -import type { SpritesStatus, SpritesInstance } from "@/lib/types/http-sprites"; import type { TasksListGroup, TasksListSort } from "@/lib/tasks/tasks-list-options"; -export type ExecutorsState = { - items: Executor[]; -}; - -export type SettingsAgentsState = { - items: Agent[]; -}; - -export type AgentDiscoveryState = { - items: AgentDiscovery[]; - loading: boolean; - loaded: boolean; -}; - -export type AvailableAgentsState = { - items: AvailableAgent[]; - tools: ToolStatus[]; - loading: boolean; - loaded: boolean; -}; - export type AgentProfileOption = { id: string; label: string; @@ -74,71 +39,6 @@ export function toAgentProfileOption( }; } -export type AgentProfilesState = { - items: AgentProfileOption[]; - version: number; -}; - -export type InstallJobStatus = "queued" | "running" | "succeeded" | "failed"; - -export type InstallJob = { - job_id: string; - agent_name: string; - status: InstallJobStatus; - output?: string; - error?: string; - exit_code?: number; - started_at: string; - finished_at?: string; -}; - -/** - * Tracks active and recently-finished install jobs by agent_name. Rehydrated - * on page mount from GET /agent-install/jobs and kept live via WS events - * (agent.install.started/output/finished). - */ -export type InstallJobsState = { - byAgent: Record; -}; - -export type EditorsState = { - items: EditorOption[]; - loaded: boolean; - loading: boolean; -}; - -export type PromptsState = { - items: CustomPrompt[]; - loaded: boolean; - loading: boolean; -}; - -export type SecretsState = { - items: SecretListItem[]; - loaded: boolean; - loading: boolean; -}; - -export type SpritesState = { - status: SpritesStatus | null; - instances: SpritesInstance[]; - loaded: boolean; - loading: boolean; -}; - -export type NotificationProvidersState = { - items: NotificationProvider[]; - events: string[]; - appriseAvailable: boolean; - loaded: boolean; - loading: boolean; -}; - -export type SettingsDataState = { - executorsLoaded: boolean; - agentsLoaded: boolean; -}; - export type UserSettingsState = { workspaceId: string | null; kanbanViewMode: string | null; @@ -207,54 +107,11 @@ export const DEFAULT_VOICE_MODE_STATE: VoiceModeState = { }; export type SettingsSliceState = { - executors: ExecutorsState; - settingsAgents: SettingsAgentsState; - agentDiscovery: AgentDiscoveryState; - availableAgents: AvailableAgentsState; - agentProfiles: AgentProfilesState; - installJobs: InstallJobsState; - editors: EditorsState; - prompts: PromptsState; - secrets: SecretsState; - sprites: SpritesState; - notificationProviders: NotificationProvidersState; - settingsData: SettingsDataState; userSettings: UserSettingsState; }; export type SettingsSliceActions = { - setExecutors: (executors: ExecutorsState["items"]) => void; - setSettingsAgents: (agents: SettingsAgentsState["items"]) => void; - setAgentDiscovery: (agents: AgentDiscoveryState["items"]) => void; - setAgentDiscoveryLoading: (loading: boolean) => void; - setAvailableAgents: ( - agents: AvailableAgentsState["items"], - tools?: AvailableAgentsState["tools"], - ) => void; - setAvailableAgentsLoading: (loading: boolean) => void; - setAgentProfiles: (profiles: AgentProfilesState["items"]) => void; - setInstallJobs: (jobs: InstallJob[]) => void; - upsertInstallJob: (job: InstallJob) => void; - appendInstallOutput: (agentName: string, chunk: string) => void; - clearInstallJob: (agentName: string) => void; - setEditors: (editors: EditorsState["items"]) => void; - setEditorsLoading: (loading: boolean) => void; - setPrompts: (prompts: PromptsState["items"]) => void; - setPromptsLoading: (loading: boolean) => void; - setSecrets: (items: SecretsState["items"]) => void; - setSecretsLoading: (loading: boolean) => void; - addSecret: (item: SecretListItem) => void; - updateSecret: (item: SecretListItem) => void; - removeSecret: (id: string) => void; - setSpritesStatus: (status: SpritesStatus) => void; - setSpritesInstances: (instances: SpritesInstance[]) => void; - setSpritesLoading: (loading: boolean) => void; - removeSpritesInstance: (name: string) => void; - setNotificationProviders: (state: NotificationProvidersState) => void; - setNotificationProvidersLoading: (loading: boolean) => void; - setSettingsData: (next: Partial) => void; setUserSettings: (settings: UserSettingsState) => void; - bumpAgentProfilesVersion: () => void; }; export type SettingsSlice = SettingsSliceState & SettingsSliceActions; diff --git a/apps/web/lib/state/slices/system/index.ts b/apps/web/lib/state/slices/system/index.ts deleted file mode 100644 index eb89f95a5c..0000000000 --- a/apps/web/lib/state/slices/system/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { createSystemSlice, defaultSystemState } from "./system-slice"; -export type { - SystemSlice, - SystemSliceState, - SystemSliceActions, - SystemBackupsState, - SystemLogsState, - SystemJobsMap, -} from "./types"; diff --git a/apps/web/lib/state/slices/system/system-slice.test.ts b/apps/web/lib/state/slices/system/system-slice.test.ts deleted file mode 100644 index 45c3e7642d..0000000000 --- a/apps/web/lib/state/slices/system/system-slice.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { create } from "zustand"; -import { immer } from "zustand/middleware/immer"; -import { createSystemSlice, defaultSystemState } from "./system-slice"; -import type { SystemSlice } from "./types"; -import type { - SystemInfo, - DiskUsageResponse, - DatabaseStats, - SnapshotInfo, - LogFileInfo, - UpdatesResponse, - SystemJob, -} from "@/lib/types/system"; - -const TS = "2026-05-18T00:00:00Z"; - -function makeStore() { - return create()( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - immer((...a) => ({ ...(createSystemSlice as any)(...a) })), - ); -} - -const INFO: SystemInfo = { - version: "1.2.3", - commit: "abc1234", - build_time: "2026-01-01T00:00:00Z", - go_version: "go1.24", - os: "darwin", - arch: "arm64", - boot_id: "boot-1", - started_at: "2026-01-01T00:00:00Z", -}; - -const DISK_USAGE: DiskUsageResponse = { - data: { - data_dir: 100, - worktrees: 200, - repos: 300, - sessions: 400, - tasks: 500, - quick_chat: 600, - backups: 700, - total: 2800, - warnings: [], - computed_at: TS, - }, - computing: false, - home_dir: "/data/kandev", -}; - -const DB_STATS: DatabaseStats = { - driver: "sqlite", - path: "/data/kandev.db", - size_bytes: 12345, - wal_size_bytes: 678, - schema_version: "1.0.0", - last_backup_at: "2026-05-17T00:00:00Z", -}; - -const SNAPSHOT: SnapshotInfo = { - name: "manual-1.db", - path: "/data/backups/manual-1.db", - size_bytes: 1024, - mtime: "2026-05-17T00:00:00Z", - kind: "manual", -}; - -const LOG_FILE: LogFileInfo = { - name: "kandev.log", - size: 2048, - mtime: TS, - current: true, -}; - -const UPDATES: UpdatesResponse = { - current: "1.2.3", - latest: "1.2.4", - latest_url: "https://github.com/kdlbs/kandev/releases/1.2.4", - latest_checked_at: TS, - update_available: true, -}; - -const JOB: SystemJob = { - id: "job-1", - kind: "vacuum", - state: "running", - started_at: TS, -}; - -describe("system slice", () => { - it("starts with empty defaults", () => { - const store = makeStore(); - const s = store.getState(); - expect(s.system).toEqual(defaultSystemState.system); - expect(s.system.info).toBeNull(); - expect(s.system.diskUsage).toBeNull(); - expect(s.system.database).toBeNull(); - expect(s.system.backups).toEqual({ items: [], loaded: false }); - expect(s.system.logs).toEqual({ files: [], tail: [], tailLoaded: false }); - expect(s.system.updates).toBeNull(); - expect(s.system.jobs).toEqual({}); - }); - - it("setSystemInfo stores the payload", () => { - const store = makeStore(); - store.getState().setSystemInfo(INFO); - expect(store.getState().system.info).toEqual(INFO); - }); - - it("setSystemDiskUsage replaces the cached response", () => { - const store = makeStore(); - store.getState().setSystemDiskUsage(DISK_USAGE); - expect(store.getState().system.diskUsage).toEqual(DISK_USAGE); - - const computing: DiskUsageResponse = { data: null, computing: true, home_dir: "/data/kandev" }; - store.getState().setSystemDiskUsage(computing); - expect(store.getState().system.diskUsage).toEqual(computing); - }); - - it("setSystemDatabase stores the stats", () => { - const store = makeStore(); - store.getState().setSystemDatabase(DB_STATS); - expect(store.getState().system.database).toEqual(DB_STATS); - }); - - it("setSystemBackups marks the list as loaded", () => { - const store = makeStore(); - store.getState().setSystemBackups([SNAPSHOT]); - expect(store.getState().system.backups).toEqual({ items: [SNAPSHOT], loaded: true }); - - // Empty list also flips loaded to true. - store.getState().setSystemBackups([]); - expect(store.getState().system.backups).toEqual({ items: [], loaded: true }); - }); - - it("setSystemLogs replaces only the files (tail stays untouched)", () => { - const store = makeStore(); - store.getState().setSystemLogTail(["line 1", "line 2"]); - store.getState().setSystemLogs([LOG_FILE]); - expect(store.getState().system.logs.files).toEqual([LOG_FILE]); - expect(store.getState().system.logs.tail).toEqual(["line 1", "line 2"]); - expect(store.getState().system.logs.tailLoaded).toBe(true); - }); - - it("setSystemLogTail flips tailLoaded to true", () => { - const store = makeStore(); - expect(store.getState().system.logs.tailLoaded).toBe(false); - store.getState().setSystemLogTail(["hello"]); - expect(store.getState().system.logs.tail).toEqual(["hello"]); - expect(store.getState().system.logs.tailLoaded).toBe(true); - }); - - it("setSystemUpdates stores the response", () => { - const store = makeStore(); - store.getState().setSystemUpdates(UPDATES); - expect(store.getState().system.updates).toEqual(UPDATES); - }); - - it("upsertSystemJob inserts and updates by id", () => { - const store = makeStore(); - store.getState().upsertSystemJob(JOB); - expect(store.getState().system.jobs["job-1"]).toEqual(JOB); - - const finished: SystemJob = { ...JOB, state: "succeeded", ended_at: "2026-05-18T00:01:00Z" }; - store.getState().upsertSystemJob(finished); - expect(store.getState().system.jobs["job-1"]).toEqual(finished); - // Same id, still one entry. - expect(Object.keys(store.getState().system.jobs)).toEqual(["job-1"]); - }); - - it("clearSystemJob removes the entry", () => { - const store = makeStore(); - store.getState().upsertSystemJob(JOB); - store.getState().upsertSystemJob({ ...JOB, id: "job-2" }); - store.getState().clearSystemJob("job-1"); - expect(store.getState().system.jobs["job-1"]).toBeUndefined(); - expect(store.getState().system.jobs["job-2"]).toBeDefined(); - }); - - it("clearSystemJob is a no-op for missing ids", () => { - const store = makeStore(); - store.getState().clearSystemJob("does-not-exist"); - expect(store.getState().system.jobs).toEqual({}); - }); -}); diff --git a/apps/web/lib/state/slices/system/system-slice.ts b/apps/web/lib/state/slices/system/system-slice.ts deleted file mode 100644 index 67a9c40b0c..0000000000 --- a/apps/web/lib/state/slices/system/system-slice.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { StateCreator } from "zustand"; -import type { SystemSlice, SystemSliceState } from "./types"; - -export const defaultSystemState: SystemSliceState = { - system: { - info: null, - diskUsage: null, - database: null, - backups: { items: [], loaded: false }, - logs: { files: [], tail: [], tailLoaded: false }, - updates: null, - jobs: {}, - metrics: null, - }, -}; - -type ImmerSet = Parameters< - StateCreator ->[0]; - -export const createSystemSlice: StateCreator< - SystemSlice, - [["zustand/immer", never]], - [], - SystemSlice -> = (set: ImmerSet, _get, _api) => ({ - ...defaultSystemState, - setSystemInfo: (info) => - set((draft) => { - draft.system.info = info; - }), - setSystemDiskUsage: (usage) => - set((draft) => { - draft.system.diskUsage = usage; - }), - setSystemDatabase: (stats) => - set((draft) => { - draft.system.database = stats; - }), - setSystemBackups: (items) => - set((draft) => { - draft.system.backups = { items, loaded: true }; - }), - setSystemLogs: (files) => - set((draft) => { - draft.system.logs.files = files; - }), - setSystemLogTail: (lines) => - set((draft) => { - draft.system.logs.tail = lines; - draft.system.logs.tailLoaded = true; - }), - setSystemUpdates: (updates) => - set((draft) => { - draft.system.updates = updates; - }), - upsertSystemJob: (job) => - set((draft) => { - draft.system.jobs[job.id] = job; - }), - clearSystemJob: (jobId) => - set((draft) => { - delete draft.system.jobs[jobId]; - }), - setSystemMetricsSnapshot: (snapshot) => - set((draft) => { - draft.system.metrics = snapshot; - }), -}); diff --git a/apps/web/lib/state/slices/system/types.ts b/apps/web/lib/state/slices/system/types.ts deleted file mode 100644 index 99a073914c..0000000000 --- a/apps/web/lib/state/slices/system/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { - SystemInfo, - DiskUsageResponse, - DatabaseStats, - SnapshotInfo, - LogFileInfo, - UpdatesResponse, - SystemJob, - SystemMetricsSnapshot, -} from "@/lib/types/system"; - -export type SystemBackupsState = { - items: SnapshotInfo[]; - loaded: boolean; -}; - -export type SystemLogsState = { - files: LogFileInfo[]; - tail: string[]; - tailLoaded: boolean; -}; - -export type SystemJobsMap = Record; - -export type SystemSliceState = { - system: { - info: SystemInfo | null; - diskUsage: DiskUsageResponse | null; - database: DatabaseStats | null; - backups: SystemBackupsState; - logs: SystemLogsState; - updates: UpdatesResponse | null; - jobs: SystemJobsMap; - metrics: SystemMetricsSnapshot | null; - }; -}; - -export type SystemSliceActions = { - setSystemInfo: (info: SystemInfo) => void; - setSystemDiskUsage: (usage: DiskUsageResponse) => void; - setSystemDatabase: (stats: DatabaseStats) => void; - setSystemBackups: (items: SnapshotInfo[]) => void; - setSystemLogs: (files: LogFileInfo[]) => void; - setSystemLogTail: (lines: string[]) => void; - setSystemUpdates: (updates: UpdatesResponse) => void; - upsertSystemJob: (job: SystemJob) => void; - clearSystemJob: (jobId: string) => void; - setSystemMetricsSnapshot: (snapshot: SystemMetricsSnapshot) => void; -}; - -export type SystemSlice = SystemSliceState & SystemSliceActions; diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index 8500899899..85788ed77d 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -1,5 +1,4 @@ import type { ConnectionStatus } from "@/lib/types/connection"; -import type { HealthCheckSummary, HealthIssue, SystemHealthResponse } from "@/lib/types/health"; import type { FilterClause, GroupKey, @@ -26,10 +25,6 @@ export type RightPanelState = { activeTabBySessionId: Record; }; -export type DiffState = { - files: Array<{ path: string; status: "A" | "M" | "D"; plus: number; minus: number }>; -}; - export type ConnectionState = { status: ConnectionStatus; error: string | null; @@ -60,14 +55,6 @@ export type DocumentPanelState = { activeDocumentBySessionId: Record; }; -export type SystemHealthState = { - issues: HealthIssue[]; - checks: HealthCheckSummary[]; - healthy: boolean; - loaded: boolean; - loading: boolean; -}; - export type QuickChatState = { isOpen: boolean; sessions: Array<{ @@ -144,13 +131,11 @@ export type AppSidebarState = { export type UISliceState = { previewPanel: PreviewPanelState; rightPanel: RightPanelState; - diffs: DiffState; connection: ConnectionState; mobileKanban: MobileKanbanState; mobileSession: MobileSessionState; chatInput: ChatInputState; documentPanel: DocumentPanelState; - systemHealth: SystemHealthState; quickChat: QuickChatState; configChat: ConfigChatState; sessionFailureNotification: SessionFailureNotification | null; @@ -197,9 +182,6 @@ export type UISliceActions = { setMobileSessionTaskSwitcherOpen: (open: boolean) => void; setPlanMode: (sessionId: string, enabled: boolean) => void; setActiveDocument: (sessionId: string, doc: ActiveDocument | null) => void; - setSystemHealth: (response: SystemHealthResponse) => void; - setSystemHealthLoading: (loading: boolean) => void; - invalidateSystemHealth: () => void; openQuickChat: (sessionId: string, workspaceId: string, agentProfileId?: string) => void; closeQuickChat: () => void; closeQuickChatSession: (sessionId: string) => void; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 91e1092e34..cd28e39560 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -23,7 +23,6 @@ import { buildSidebarTaskPrefsActions } from "./sidebar-task-prefs-actions"; import { buildSidebarViewActions } from "./sidebar-view-actions"; import { DEFAULT_ACTIVE_VIEW_ID, DEFAULT_VIEW } from "./sidebar-view-builtins"; import type { SidebarView, SidebarViewDraft, SortSpec } from "./sidebar-view-types"; -import type { SystemHealthResponse } from "@/lib/types/health"; import type { ActiveDocument, UISlice, UISliceState } from "./types"; function loadSidebarState(): UISliceState["sidebarViews"] { @@ -81,13 +80,11 @@ export const defaultUIState: UISliceState = { urlDraftBySessionId: {}, }, rightPanel: { activeTabBySessionId: {} }, - diffs: { files: [] }, connection: { status: "disconnected", error: null }, mobileKanban: { activeColumnIndex: 0, isMenuOpen: false, isSearchOpen: false }, mobileSession: { activePanelBySessionId: {}, isTaskSwitcherOpen: false }, chatInput: { planModeBySessionId: {} }, documentPanel: { activeDocumentBySessionId: {} }, - systemHealth: { issues: [], checks: [], healthy: true, loaded: false, loading: false }, quickChat: { isOpen: false, sessions: [], activeSessionId: null }, configChat: { isOpen: false, sessions: [], activeSessionId: null, workspaceId: null }, sessionFailureNotification: null, @@ -205,26 +202,6 @@ function buildBottomTerminalActions(set: ImmerSet) { }; } -function buildSystemHealthActions(set: ImmerSet) { - return { - setSystemHealth: (response: SystemHealthResponse) => - set((draft) => { - draft.systemHealth.issues = response.issues; - draft.systemHealth.checks = response.checks ?? []; - draft.systemHealth.healthy = response.healthy; - draft.systemHealth.loaded = true; - }), - setSystemHealthLoading: (loading: boolean) => - set((draft) => { - draft.systemHealth.loading = loading; - }), - invalidateSystemHealth: () => - set((draft) => { - draft.systemHealth.loaded = false; - }), - }; -} - function buildCollapsedSubtaskActions(set: ImmerSet, get: () => UISlice) { return { // Tab-scoped collapse of a parent task's subtasks. Persisted via @@ -329,7 +306,6 @@ export const createUISlice: StateCreator diff --git a/apps/web/lib/state/slices/workspace/types.ts b/apps/web/lib/state/slices/workspace/types.ts index 1392848f64..0b21839a0f 100644 --- a/apps/web/lib/state/slices/workspace/types.ts +++ b/apps/web/lib/state/slices/workspace/types.ts @@ -1,67 +1,13 @@ -import type { Repository, Branch, RepositoryScript } from "@/lib/types/http"; - export type WorkspaceState = { - items: Array<{ - id: string; - name: string; - description?: string | null; - owner_id: string; - default_executor_id?: string | null; - default_environment_id?: string | null; - default_agent_profile_id?: string | null; - default_config_agent_profile_id?: string | null; - office_workflow_id?: string | null; - created_at: string; - updated_at: string; - }>; activeId: string | null; }; -export type RepositoriesState = { - itemsByWorkspaceId: Record; - loadingByWorkspaceId: Record; - loadedByWorkspaceId: Record; -}; - -export type RepositoryBranchesState = { - itemsByRepositoryId: Record; - loadingByRepositoryId: Record; - loadedByRepositoryId: Record; - // RFC3339 timestamp of the most recent successful refresh from the backend. - fetchedAtByRepositoryId: Record; - // Last fetch error for each repository, when a refresh attempt failed. - fetchErrorByRepositoryId: Record; -}; - -export type RepositoryScriptsState = { - itemsByRepositoryId: Record; - loadingByRepositoryId: Record; - loadedByRepositoryId: Record; -}; - export type WorkspaceSliceState = { workspaces: WorkspaceState; - repositories: RepositoriesState; - repositoryBranches: RepositoryBranchesState; - repositoryScripts: RepositoryScriptsState; }; export type WorkspaceSliceActions = { setActiveWorkspace: (workspaceId: string | null) => void; - setWorkspaces: (workspaces: WorkspaceState["items"]) => void; - setRepositories: (workspaceId: string, repositories: Repository[]) => void; - setRepositoriesLoading: (workspaceId: string, loading: boolean) => void; - setRepositoryBranches: ( - repositoryId: string, - branches: Branch[], - meta?: { fetchedAt?: string; fetchError?: string }, - ) => void; - setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void; - setRepositoryBranchesFetchError: (repositoryId: string, error: string | undefined) => void; - setRepositoryScripts: (repositoryId: string, scripts: RepositoryScript[]) => void; - setRepositoryScriptsLoading: (repositoryId: string, loading: boolean) => void; - clearRepositoryScripts: (repositoryId: string) => void; - invalidateRepositories: (workspaceId: string) => void; }; export type WorkspaceSlice = WorkspaceSliceState & WorkspaceSliceActions; diff --git a/apps/web/lib/state/slices/workspace/workspace-slice.test.ts b/apps/web/lib/state/slices/workspace/workspace-slice.test.ts index 80733114a1..28175e9fc5 100644 --- a/apps/web/lib/state/slices/workspace/workspace-slice.test.ts +++ b/apps/web/lib/state/slices/workspace/workspace-slice.test.ts @@ -3,7 +3,6 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { createWorkspaceSlice } from "./workspace-slice"; import type { WorkspaceSlice } from "./types"; -import type { Branch } from "@/lib/types/http"; function makeStore() { return create()( @@ -12,67 +11,30 @@ function makeStore() { ); } -const REPO = "repo-1"; -const BRANCHES: Branch[] = [{ name: "main", type: "local" }]; -const FETCHED_AT = "2026-04-30T10:00:00Z"; +describe("repository scripts", () => { + it("does not expose repository scripts server-state through the workspace slice", () => { + const state = makeStore().getState() as unknown as Record; -describe("setRepositoryBranches", () => { - it("stores branches and marks loaded without meta", () => { - const s = makeStore(); - s.getState().setRepositoryBranches(REPO, BRANCHES); - const state = s.getState().repositoryBranches; - expect(state.itemsByRepositoryId[REPO]).toEqual(BRANCHES); - expect(state.loadedByRepositoryId[REPO]).toBe(true); - expect(state.loadingByRepositoryId[REPO]).toBe(false); - expect(state.fetchedAtByRepositoryId[REPO]).toBeUndefined(); - expect(state.fetchErrorByRepositoryId[REPO]).toBeUndefined(); - }); - - it("records fetchedAt + clears prior fetchError on success", () => { - const s = makeStore(); - s.getState().setRepositoryBranches(REPO, BRANCHES, { fetchError: "boom" }); - expect(s.getState().repositoryBranches.fetchErrorByRepositoryId[REPO]).toBe("boom"); - s.getState().setRepositoryBranches(REPO, BRANCHES, { fetchedAt: FETCHED_AT }); - const state = s.getState().repositoryBranches; - expect(state.fetchedAtByRepositoryId[REPO]).toBe(FETCHED_AT); - expect(state.fetchErrorByRepositoryId[REPO]).toBeUndefined(); - }); - - it("preserves the prior fetchedAt when meta omits it", () => { - const s = makeStore(); - s.getState().setRepositoryBranches(REPO, BRANCHES, { fetchedAt: FETCHED_AT }); - s.getState().setRepositoryBranches(REPO, BRANCHES); - expect(s.getState().repositoryBranches.fetchedAtByRepositoryId[REPO]).toBe(FETCHED_AT); + expect("repositoryScripts" in state).toBe(false); + expect("setRepositoryScripts" in state).toBe(false); + expect("clearRepositoryScripts" in state).toBe(false); }); }); -describe("setRepositoryBranchesLoading", () => { - it("toggles only the loading flag", () => { - const s = makeStore(); - s.getState().setRepositoryBranchesLoading(REPO, true); - expect(s.getState().repositoryBranches.loadingByRepositoryId[REPO]).toBe(true); - s.getState().setRepositoryBranchesLoading(REPO, false); - expect(s.getState().repositoryBranches.loadingByRepositoryId[REPO]).toBe(false); +describe("repository branches", () => { + it("does not expose repository branch server-state through the workspace slice", () => { + const state = makeStore().getState() as unknown as Record; + + expect("repositoryBranches" in state).toBe(false); + expect("setRepositoryBranches" in state).toBe(false); }); }); -describe("setRepositoryBranchesFetchError", () => { - it("records the error without touching cached branches", () => { - const s = makeStore(); - s.getState().setRepositoryBranches(REPO, BRANCHES, { fetchedAt: FETCHED_AT }); - s.getState().setRepositoryBranchesFetchError(REPO, "network down"); - const state = s.getState().repositoryBranches; - expect(state.fetchErrorByRepositoryId[REPO]).toBe("network down"); - // Cached branches and fetchedAt are preserved so the dropdown stays usable - // (stale-while-revalidate semantics). - expect(state.itemsByRepositoryId[REPO]).toEqual(BRANCHES); - expect(state.fetchedAtByRepositoryId[REPO]).toBe(FETCHED_AT); - }); +describe("workspace repositories", () => { + it("does not expose repository server-state through the workspace slice", () => { + const state = makeStore().getState() as unknown as Record; - it("clears the error when called with undefined", () => { - const s = makeStore(); - s.getState().setRepositoryBranchesFetchError(REPO, "boom"); - s.getState().setRepositoryBranchesFetchError(REPO, undefined); - expect(s.getState().repositoryBranches.fetchErrorByRepositoryId[REPO]).toBeUndefined(); + expect("repositories" in state).toBe(false); + expect("setRepositories" in state).toBe(false); }); }); diff --git a/apps/web/lib/state/slices/workspace/workspace-slice.ts b/apps/web/lib/state/slices/workspace/workspace-slice.ts index f5b5ab3d7f..c5994db153 100644 --- a/apps/web/lib/state/slices/workspace/workspace-slice.ts +++ b/apps/web/lib/state/slices/workspace/workspace-slice.ts @@ -2,20 +2,7 @@ import type { StateCreator } from "zustand"; import type { WorkspaceSlice, WorkspaceSliceState } from "./types"; export const defaultWorkspaceState: WorkspaceSliceState = { - workspaces: { items: [], activeId: null }, - repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} }, - repositoryBranches: { - itemsByRepositoryId: {}, - loadingByRepositoryId: {}, - loadedByRepositoryId: {}, - fetchedAtByRepositoryId: {}, - fetchErrorByRepositoryId: {}, - }, - repositoryScripts: { - itemsByRepositoryId: {}, - loadingByRepositoryId: {}, - loadedByRepositoryId: {}, - }, + workspaces: { activeId: null }, }; export const createWorkspaceSlice: StateCreator< @@ -33,61 +20,4 @@ export const createWorkspaceSlice: StateCreator< draft.workspaces.activeId = workspaceId; }); }, - setWorkspaces: (workspaces) => - set((draft) => { - draft.workspaces.items = workspaces; - if (!draft.workspaces.activeId && workspaces.length) { - draft.workspaces.activeId = workspaces[0].id; - } - }), - setRepositories: (workspaceId, repositories) => - set((draft) => { - draft.repositories.itemsByWorkspaceId[workspaceId] = repositories; - draft.repositories.loadingByWorkspaceId[workspaceId] = false; - draft.repositories.loadedByWorkspaceId[workspaceId] = true; - }), - setRepositoriesLoading: (workspaceId, loading) => - set((draft) => { - draft.repositories.loadingByWorkspaceId[workspaceId] = loading; - }), - setRepositoryBranches: (repositoryId, branches, meta) => - set((draft) => { - draft.repositoryBranches.itemsByRepositoryId[repositoryId] = branches; - draft.repositoryBranches.loadingByRepositoryId[repositoryId] = false; - draft.repositoryBranches.loadedByRepositoryId[repositoryId] = true; - if (meta?.fetchedAt !== undefined) { - draft.repositoryBranches.fetchedAtByRepositoryId[repositoryId] = meta.fetchedAt; - } - // fetchError is replaced on every successful response (empty string clears it). - draft.repositoryBranches.fetchErrorByRepositoryId[repositoryId] = - meta?.fetchError ?? undefined; - }), - setRepositoryBranchesLoading: (repositoryId, loading) => - set((draft) => { - draft.repositoryBranches.loadingByRepositoryId[repositoryId] = loading; - }), - setRepositoryBranchesFetchError: (repositoryId, error) => - set((draft) => { - draft.repositoryBranches.fetchErrorByRepositoryId[repositoryId] = error; - }), - setRepositoryScripts: (repositoryId, scripts) => - set((draft) => { - draft.repositoryScripts.itemsByRepositoryId[repositoryId] = scripts; - draft.repositoryScripts.loadingByRepositoryId[repositoryId] = false; - draft.repositoryScripts.loadedByRepositoryId[repositoryId] = true; - }), - setRepositoryScriptsLoading: (repositoryId, loading) => - set((draft) => { - draft.repositoryScripts.loadingByRepositoryId[repositoryId] = loading; - }), - clearRepositoryScripts: (repositoryId) => - set((draft) => { - delete draft.repositoryScripts.itemsByRepositoryId[repositoryId]; - delete draft.repositoryScripts.loadingByRepositoryId[repositoryId]; - delete draft.repositoryScripts.loadedByRepositoryId[repositoryId]; - }), - invalidateRepositories: (workspaceId) => - set((draft) => { - draft.repositories.loadedByWorkspaceId[workspaceId] = false; - }), }); diff --git a/apps/web/lib/state/store-overrides.test.ts b/apps/web/lib/state/store-overrides.test.ts new file mode 100644 index 0000000000..662af5f2f9 --- /dev/null +++ b/apps/web/lib/state/store-overrides.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { createAppStore, type AppState } from "./store"; + +function commit(): AppState["sessionCommits"]["byEnvironmentId"][string][number] { + return { + id: "commit-1", + session_id: "session-1", + commit_sha: "abc123", + parent_sha: "base123", + author_name: "Agent", + author_email: "agent@example.com", + commit_message: "test", + committed_at: "2026-01-01T00:00:00Z", + files_changed: 1, + insertions: 2, + deletions: 1, + created_at: "2026-01-01T00:00:01Z", + }; +} + +describe("createAppStore initial state overrides", () => { + it("reasserts bootstrapped session runtime and plan state after slice defaults", () => { + const store = createAppStore({ + environmentIdBySessionId: { "session-1": "env-1" }, + sessionCommits: { + byEnvironmentId: { "env-1": [commit()] }, + loading: { "env-1": true }, + refetchTrigger: { "env-1": 2 }, + }, + taskPlans: { + previewRevisionIdByTaskId: { "task-1": "rev-1" }, + comparePairByTaskId: { "task-1": ["rev-1", null] }, + lastSeenUpdatedAtByTaskId: { "task-1": "2026-01-01T00:00:00Z" }, + }, + }); + + const state = store.getState(); + expect(state.environmentIdBySessionId).toEqual({ "session-1": "env-1" }); + expect(state.sessionCommits.byEnvironmentId["env-1"]).toHaveLength(1); + expect(state.sessionCommits.loading["env-1"]).toBe(true); + expect(state.sessionCommits.refetchTrigger["env-1"]).toBe(2); + expect(state.taskPlans.previewRevisionIdByTaskId["task-1"]).toBe("rev-1"); + expect(state.taskPlans.lastSeenUpdatedAtByTaskId["task-1"]).toBe("2026-01-01T00:00:00Z"); + }); +}); diff --git a/apps/web/lib/state/store-overrides.ts b/apps/web/lib/state/store-overrides.ts index 0fc361b59f..9d290fbe49 100644 --- a/apps/web/lib/state/store-overrides.ts +++ b/apps/web/lib/state/store-overrides.ts @@ -6,83 +6,41 @@ import type { DefaultState } from "./default-state"; // from sessionStorage and we want that to win. export function buildStateOverrides(m: DefaultState) { return { - kanban: m.kanban, - kanbanMulti: m.kanbanMulti, workflows: m.workflows, tasks: m.tasks, workspaces: m.workspaces, - repositories: m.repositories, - repositoryBranches: m.repositoryBranches, - repositoryScripts: m.repositoryScripts, - executors: m.executors, - settingsAgents: m.settingsAgents, - agentDiscovery: m.agentDiscovery, - availableAgents: m.availableAgents, - agentProfiles: m.agentProfiles, - editors: m.editors, - prompts: m.prompts, - secrets: m.secrets, - notificationProviders: m.notificationProviders, - settingsData: m.settingsData, userSettings: m.userSettings, messages: m.messages, turns: m.turns, taskSessions: m.taskSessions, taskSessionsByTask: m.taskSessionsByTask, sessionAgentctl: m.sessionAgentctl, - worktrees: m.worktrees, - sessionWorktreesBySessionId: m.sessionWorktreesBySessionId, - pendingModel: m.pendingModel, activeModel: m.activeModel, - queue: m.queue, - terminal: m.terminal, + taskPlans: m.taskPlans, shell: m.shell, processes: m.processes, gitStatus: m.gitStatus, + environmentIdBySessionId: m.environmentIdBySessionId, + sessionCommits: m.sessionCommits, contextWindow: m.contextWindow, - agents: m.agents, - sessionMode: m.sessionMode, userShells: m.userShells, prepareProgress: m.prepareProgress, - sessionTodos: m.sessionTodos, - agentCapabilities: m.agentCapabilities, sessionModels: m.sessionModels, - promptUsage: m.promptUsage, - sessionPollMode: m.sessionPollMode, - githubStatus: m.githubStatus, - taskPRs: m.taskPRs, pendingPrUrlByTaskId: m.pendingPrUrlByTaskId, - prWatches: m.prWatches, - reviewWatches: m.reviewWatches, - issueWatches: m.issueWatches, - actionPresets: m.actionPresets, prFeedbackCache: m.prFeedbackCache, - taskCIAutomation: m.taskCIAutomation, - taskMRs: m.taskMRs, - gitlabReviewWatches: m.gitlabReviewWatches, - gitlabIssueWatches: m.gitlabIssueWatches, - gitlabMRWatches: m.gitlabMRWatches, - gitlabActionPresets: m.gitlabActionPresets, - gitlabStats: m.gitlabStats, - gitlabStatus: m.gitlabStatus, - jiraIssueWatches: m.jiraIssueWatches, - linearIssueWatches: m.linearIssueWatches, office: m.office, - features: m.features, - automations: m.automations, - automationRuns: m.automationRuns, - system: m.system, previewPanel: m.previewPanel, rightPanel: m.rightPanel, - diffs: m.diffs, connection: m.connection, mobileKanban: m.mobileKanban, mobileSession: m.mobileSession, chatInput: m.chatInput, documentPanel: m.documentPanel, - systemHealth: m.systemHealth, quickChat: m.quickChat, sessionFailureNotification: m.sessionFailureNotification, bottomTerminal: m.bottomTerminal, + sidebarViews: m.sidebarViews, + kanbanPreviewedTaskId: m.kanbanPreviewedTaskId, + sidebarTaskPrefs: m.sidebarTaskPrefs, }; } diff --git a/apps/web/lib/state/store-reexports.ts b/apps/web/lib/state/store-reexports.ts index bbe2883a53..af81838856 100644 --- a/apps/web/lib/state/store-reexports.ts +++ b/apps/web/lib/state/store-reexports.ts @@ -4,23 +4,11 @@ export type { KanbanState, KanbanMultiState, WorkflowSnapshotData, + WorkflowItem, WorkflowsState, TaskState, WorkspaceState, - RepositoriesState, - RepositoryBranchesState, - RepositoryScriptsState, - ExecutorsState, - SettingsAgentsState, - AgentDiscoveryState, - AvailableAgentsState, AgentProfileOption, - AgentProfilesState, - EditorsState, - PromptsState, - SecretsState, - NotificationProvidersState, - SettingsDataState, UserSettingsState, MessagesState, TurnsState, @@ -29,11 +17,7 @@ export type { SessionAgentctlStatus, SessionAgentctlState, Worktree, - WorktreesState, - SessionWorktreesState, - PendingModelState, ActiveModelState, - TerminalState, ShellState, ProcessStatusEntry, ProcessState, @@ -42,7 +26,6 @@ export type { GitStatusState, ContextWindowEntry, ContextWindowState, - AgentState, UserShellInfo, UserShellsState, PreviewStage, @@ -50,23 +33,9 @@ export type { PreviewDevicePreset, PreviewPanelState, RightPanelState, - DiffState, ConnectionState, MobileKanbanState, GitHubSlice, GitHubSliceState, GitHubSliceActions, - GitHubStatusState, - TaskPRsState, - PRWatchesState, - ReviewWatchesState, - IssueWatchesState, - JiraSlice, - JiraSliceState, - JiraSliceActions, - JiraIssueWatchesState, - LinearSlice, - LinearSliceState, - LinearSliceActions, - LinearIssueWatchesState, } from "./slices"; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 5a6ba4cc35..31ec97b082 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -1,16 +1,7 @@ import { createStore } from "zustand/vanilla"; import { immer } from "zustand/middleware/immer"; import { hydrateState, type HydrationOptions } from "./hydration/hydrator"; -import type { - Repository, - Branch, - RepositoryScript, - Message, - Turn, - TaskSession, - TaskWalkthrough, -} from "@/lib/types/http"; -import type { SystemHealthResponse } from "@/lib/types/health"; +import type { Message, Turn, TaskSession } from "@/lib/types/http"; import type { UISliceActions as UIA } from "./slices/ui/types"; import type * as UISliceTypes from "./slices/ui/types"; import { mergeInitialState } from "./default-state"; @@ -23,13 +14,7 @@ import { createSessionRuntimeSlice, createUISlice, createGitHubSlice, - createGitLabSlice, - createJiraSlice, - createLinearSlice, createOfficeSlice, - createFeaturesSlice, - createAutomationsSlice, - createSystemSlice, defaultKanbanState, defaultWorkspaceState, defaultSettingsState, @@ -37,28 +22,9 @@ import { defaultSessionRuntimeState, defaultUIState, defaultGitHubState, - defaultGitLabState, - defaultJiraState, - defaultLinearState, defaultOfficeState, - defaultFeaturesState, - defaultAutomationsState, - defaultSystemState, - type WorkspaceState, - type WorkflowsState, - type ExecutorsState, - type SettingsAgentsState, - type AgentDiscoveryState, - type AvailableAgentsState, - type AgentProfilesState, - type EditorsState, - type PromptsState, - type SecretsState, - type NotificationProvidersState, - type SettingsDataState, type UserSettingsState, type ProcessStatusEntry, - type Worktree, type GitStatusEntry, type SessionCommit, type ContextWindowEntry, @@ -67,82 +33,34 @@ import { type PreviewViewMode, type PreviewDevicePreset, type ConnectionState, - type SystemSliceActions, - type AutomationsSliceActions, - type FeaturesSliceActions, type GitHubSliceActions, } from "./slices"; import type { - AvailableCommand, - SessionModeEntry, - AgentCapabilitiesEntry, SessionModelEntry, ConfigOptionEntry, - PromptUsageEntry, - SessionPollMode, - TodoEntry, UserShellInfo, } from "./slices/session-runtime/types"; // Re-export all types from slices for backwards compatibility. export type * from "./store-reexports"; -import type { TaskMR } from "@/lib/types/gitlab"; -import type { GitLabSliceActions } from "./slices/gitlab/types"; -import type { JiraIssueWatch } from "@/lib/types/jira"; -import type { LinearIssueWatch } from "@/lib/types/linear"; import type { - AgentProfile, - AgentRoutePreview, - AgentRouteData, - Skill, - Project, - Approval, - ActivityEntry, - CostSummary, - BudgetPolicy, - Routine, - InboxItem, - OfficeMeta, - ProviderHealth, - RouteAttempt, - Run, - DashboardData, - OfficeTask, TaskFilterState, TaskViewMode, TaskSortField, TaskSortDir, TaskGroupBy, - WorkspaceRouting, } from "./slices/office/types"; // Combined AppState type export type AppState = { // Kanban slice - kanban: (typeof defaultKanbanState)["kanban"]; - kanbanMulti: (typeof defaultKanbanState)["kanbanMulti"]; workflows: (typeof defaultKanbanState)["workflows"]; tasks: (typeof defaultKanbanState)["tasks"]; // Workspace slice workspaces: (typeof defaultWorkspaceState)["workspaces"]; - repositories: (typeof defaultWorkspaceState)["repositories"]; - repositoryBranches: (typeof defaultWorkspaceState)["repositoryBranches"]; - repositoryScripts: (typeof defaultWorkspaceState)["repositoryScripts"]; // Settings slice - executors: (typeof defaultSettingsState)["executors"]; - settingsAgents: (typeof defaultSettingsState)["settingsAgents"]; - agentDiscovery: (typeof defaultSettingsState)["agentDiscovery"]; - availableAgents: (typeof defaultSettingsState)["availableAgents"]; - agentProfiles: (typeof defaultSettingsState)["agentProfiles"]; - installJobs: (typeof defaultSettingsState)["installJobs"]; - editors: (typeof defaultSettingsState)["editors"]; - prompts: (typeof defaultSettingsState)["prompts"]; - secrets: (typeof defaultSettingsState)["secrets"]; - sprites: (typeof defaultSettingsState)["sprites"]; - notificationProviders: (typeof defaultSettingsState)["notificationProviders"]; - settingsData: (typeof defaultSettingsState)["settingsData"]; userSettings: (typeof defaultSettingsState)["userSettings"]; // Session slice @@ -151,82 +69,36 @@ export type AppState = { taskSessions: (typeof defaultSessionState)["taskSessions"]; taskSessionsByTask: (typeof defaultSessionState)["taskSessionsByTask"]; sessionAgentctl: (typeof defaultSessionState)["sessionAgentctl"]; - worktrees: (typeof defaultSessionState)["worktrees"]; - sessionWorktreesBySessionId: (typeof defaultSessionState)["sessionWorktreesBySessionId"]; - pendingModel: (typeof defaultSessionState)["pendingModel"]; activeModel: (typeof defaultSessionState)["activeModel"]; taskPlans: (typeof defaultSessionState)["taskPlans"]; walkthroughs: (typeof defaultSessionState)["walkthroughs"]; - queue: (typeof defaultSessionState)["queue"]; // Session Runtime slice - terminal: (typeof defaultSessionRuntimeState)["terminal"]; shell: (typeof defaultSessionRuntimeState)["shell"]; processes: (typeof defaultSessionRuntimeState)["processes"]; gitStatus: (typeof defaultSessionRuntimeState)["gitStatus"]; environmentIdBySessionId: (typeof defaultSessionRuntimeState)["environmentIdBySessionId"]; sessionCommits: (typeof defaultSessionRuntimeState)["sessionCommits"]; contextWindow: (typeof defaultSessionRuntimeState)["contextWindow"]; - agents: (typeof defaultSessionRuntimeState)["agents"]; - availableCommands: (typeof defaultSessionRuntimeState)["availableCommands"]; - sessionMode: (typeof defaultSessionRuntimeState)["sessionMode"]; userShells: (typeof defaultSessionRuntimeState)["userShells"]; prepareProgress: (typeof defaultSessionRuntimeState)["prepareProgress"]; - sessionTodos: (typeof defaultSessionRuntimeState)["sessionTodos"]; - agentCapabilities: (typeof defaultSessionRuntimeState)["agentCapabilities"]; sessionModels: (typeof defaultSessionRuntimeState)["sessionModels"]; - promptUsage: (typeof defaultSessionRuntimeState)["promptUsage"]; - sessionPollMode: (typeof defaultSessionRuntimeState)["sessionPollMode"]; // GitHub slice - githubStatus: (typeof defaultGitHubState)["githubStatus"]; - taskPRs: (typeof defaultGitHubState)["taskPRs"]; pendingPrUrlByTaskId: (typeof defaultGitHubState)["pendingPrUrlByTaskId"]; - prWatches: (typeof defaultGitHubState)["prWatches"]; - reviewWatches: (typeof defaultGitHubState)["reviewWatches"]; - issueWatches: (typeof defaultGitHubState)["issueWatches"]; - actionPresets: (typeof defaultGitHubState)["actionPresets"]; prFeedbackCache: (typeof defaultGitHubState)["prFeedbackCache"]; - taskCIAutomation: (typeof defaultGitHubState)["taskCIAutomation"]; - - // GitLab slice - taskMRs: (typeof defaultGitLabState)["taskMRs"]; - gitlabReviewWatches: (typeof defaultGitLabState)["gitlabReviewWatches"]; - gitlabIssueWatches: (typeof defaultGitLabState)["gitlabIssueWatches"]; - gitlabMRWatches: (typeof defaultGitLabState)["gitlabMRWatches"]; - gitlabActionPresets: (typeof defaultGitLabState)["gitlabActionPresets"]; - gitlabStats: (typeof defaultGitLabState)["gitlabStats"]; - gitlabStatus: (typeof defaultGitLabState)["gitlabStatus"]; - - // JIRA slice - jiraIssueWatches: (typeof defaultJiraState)["jiraIssueWatches"]; - - // Linear slice - linearIssueWatches: (typeof defaultLinearState)["linearIssueWatches"]; // Office slice office: (typeof defaultOfficeState)["office"]; - // Feature flags slice - features: (typeof defaultFeaturesState)["features"]; - - // Automations slice - automations: (typeof defaultAutomationsState)["automations"]; - automationRuns: (typeof defaultAutomationsState)["automationRuns"]; - - // System slice (actions merged via SystemSliceActions intersection on AppState) - system: (typeof defaultSystemState)["system"]; - // UI slice previewPanel: (typeof defaultUIState)["previewPanel"]; rightPanel: (typeof defaultUIState)["rightPanel"]; - diffs: (typeof defaultUIState)["diffs"]; connection: (typeof defaultUIState)["connection"]; mobileKanban: (typeof defaultUIState)["mobileKanban"]; mobileSession: (typeof defaultUIState)["mobileSession"]; chatInput: (typeof defaultUIState)["chatInput"]; documentPanel: (typeof defaultUIState)["documentPanel"]; - systemHealth: (typeof defaultUIState)["systemHealth"]; quickChat: (typeof defaultUIState)["quickChat"]; configChat: (typeof defaultUIState)["configChat"]; sessionFailureNotification: (typeof defaultUIState)["sessionFailureNotification"]; @@ -240,109 +112,11 @@ export type AppState = { acknowledgedAgentErrors: (typeof defaultUIState)["acknowledgedAgentErrors"]; dismissedAgentErrors: (typeof defaultUIState)["dismissedAgentErrors"]; - // GitLab actions - setTaskMRs: (mrs: Record) => void; - setTaskMR: (taskId: string, mr: TaskMR) => void; - resetTaskMRs: () => void; - setGitLabReviewWatches: GitLabSliceActions["setGitLabReviewWatches"]; - setGitLabReviewWatchesLoading: GitLabSliceActions["setGitLabReviewWatchesLoading"]; - addGitLabReviewWatch: GitLabSliceActions["addGitLabReviewWatch"]; - updateGitLabReviewWatchInStore: GitLabSliceActions["updateGitLabReviewWatchInStore"]; - removeGitLabReviewWatch: GitLabSliceActions["removeGitLabReviewWatch"]; - setGitLabIssueWatches: GitLabSliceActions["setGitLabIssueWatches"]; - setGitLabIssueWatchesLoading: GitLabSliceActions["setGitLabIssueWatchesLoading"]; - addGitLabIssueWatch: GitLabSliceActions["addGitLabIssueWatch"]; - updateGitLabIssueWatchInStore: GitLabSliceActions["updateGitLabIssueWatchInStore"]; - removeGitLabIssueWatch: GitLabSliceActions["removeGitLabIssueWatch"]; - setGitLabMRWatches: GitLabSliceActions["setGitLabMRWatches"]; - setGitLabMRWatchesLoading: GitLabSliceActions["setGitLabMRWatchesLoading"]; - removeGitLabMRWatch: GitLabSliceActions["removeGitLabMRWatch"]; - setGitLabActionPresets: GitLabSliceActions["setGitLabActionPresets"]; - setGitLabActionPresetsLoading: GitLabSliceActions["setGitLabActionPresetsLoading"]; - setGitLabStats: GitLabSliceActions["setGitLabStats"]; - setGitLabStatsLoading: GitLabSliceActions["setGitLabStatsLoading"]; - setGitLabStatus: GitLabSliceActions["setGitLabStatus"]; - setGitLabStatusLoading: GitLabSliceActions["setGitLabStatusLoading"]; - - // JIRA actions - setJiraIssueWatches: (watches: JiraIssueWatch[]) => void; - setJiraIssueWatchesLoading: (loading: boolean) => void; - addJiraIssueWatch: (watch: JiraIssueWatch) => void; - updateJiraIssueWatch: (watch: JiraIssueWatch) => void; - removeJiraIssueWatch: (id: string) => void; - resetJiraIssueWatches: () => void; - - // Linear actions - setLinearIssueWatches: (watches: LinearIssueWatch[]) => void; - setLinearIssueWatchesLoading: (loading: boolean) => void; - addLinearIssueWatch: (watch: LinearIssueWatch) => void; - updateLinearIssueWatch: (watch: LinearIssueWatch) => void; - removeLinearIssueWatch: (id: string) => void; - resetLinearIssueWatches: () => void; - // Actions from all slices hydrate: (state: Partial, options?: HydrationOptions) => void; setActiveWorkspace: (workspaceId: string | null) => void; - setWorkspaces: (workspaces: WorkspaceState["items"]) => void; setActiveWorkflow: (workflowId: string | null) => void; - setWorkflows: (workflows: WorkflowsState["items"]) => void; - reorderWorkflowItems: (workflowIds: string[]) => void; - setWorkflowSnapshot: ( - workflowId: string, - data: import("./slices/kanban/types").WorkflowSnapshotData, - ) => void; - setKanbanMultiLoading: (loading: boolean) => void; - clearKanbanMulti: () => void; - updateMultiTask: ( - workflowId: string, - task: import("./slices/kanban/types").KanbanState["tasks"][number], - ) => void; - removeMultiTask: (workflowId: string, taskId: string) => void; - setExecutors: (executors: ExecutorsState["items"]) => void; - setSettingsAgents: (agents: SettingsAgentsState["items"]) => void; - setAgentDiscovery: (agents: AgentDiscoveryState["items"]) => void; - setAgentDiscoveryLoading: (loading: boolean) => void; - setAvailableAgents: ( - agents: AvailableAgentsState["items"], - tools?: AvailableAgentsState["tools"], - ) => void; - setAvailableAgentsLoading: (loading: boolean) => void; - setAgentProfiles: (profiles: AgentProfilesState["items"]) => void; - setInstallJobs: (jobs: import("@/lib/state/slices/settings/types").InstallJob[]) => void; - upsertInstallJob: (job: import("@/lib/state/slices/settings/types").InstallJob) => void; - appendInstallOutput: (agentName: string, chunk: string) => void; - clearInstallJob: (agentName: string) => void; - setRepositories: (workspaceId: string, repositories: Repository[]) => void; - setRepositoriesLoading: (workspaceId: string, loading: boolean) => void; - setRepositoryBranches: ( - repositoryId: string, - branches: Branch[], - meta?: { fetchedAt?: string; fetchError?: string }, - ) => void; - setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void; - setRepositoryBranchesFetchError: (repositoryId: string, error: string | undefined) => void; - setRepositoryScripts: (repositoryId: string, scripts: RepositoryScript[]) => void; - setRepositoryScriptsLoading: (repositoryId: string, loading: boolean) => void; - clearRepositoryScripts: (repositoryId: string) => void; - invalidateRepositories: (workspaceId: string) => void; - setSettingsData: (next: Partial) => void; - setEditors: (editors: EditorsState["items"]) => void; - setEditorsLoading: (loading: boolean) => void; - setPrompts: (prompts: PromptsState["items"]) => void; - setPromptsLoading: (loading: boolean) => void; - setSecrets: (items: SecretsState["items"]) => void; - setSecretsLoading: (loading: boolean) => void; - addSecret: (item: import("@/lib/types/http-secrets").SecretListItem) => void; - updateSecret: (item: import("@/lib/types/http-secrets").SecretListItem) => void; - removeSecret: (id: string) => void; - setSpritesStatus: (status: import("@/lib/types/http-sprites").SpritesStatus) => void; - setSpritesInstances: (instances: import("@/lib/types/http-sprites").SpritesInstance[]) => void; - setSpritesLoading: (loading: boolean) => void; - removeSpritesInstance: (name: string) => void; - setNotificationProviders: (state: NotificationProvidersState) => void; - setNotificationProvidersLoading: (loading: boolean) => void; setUserSettings: (settings: UserSettingsState) => void; - setTerminalOutput: (terminalId: string, data: string) => void; appendShellOutput: (sessionId: string, data: string) => void; setShellStatus: ( sessionId: string, @@ -369,9 +143,6 @@ export type AppState = { setMobileSessionTaskSwitcherOpen: (open: boolean) => void; setPlanMode: (sessionId: string, enabled: boolean) => void; setActiveDocument: (sessionId: string, doc: UISliceTypes.ActiveDocument | null) => void; - setSystemHealth: (response: SystemHealthResponse) => void; - setSystemHealthLoading: (loading: boolean) => void; - invalidateSystemHealth: () => void; openQuickChat: (sessionId: string, workspaceId: string, agentProfileId?: string) => void; closeQuickChat: () => void; closeQuickChatSession: (sessionId: string) => void; @@ -429,8 +200,6 @@ export type AppState = { upsertTaskSessionFromEvent: (taskId: string, session: TaskSession) => void; setTaskSessionsLoading: (taskId: string, loading: boolean) => void; setSessionAgentctlStatus: (sessionId: string, status: SessionAgentctlStatus) => void; - setWorktree: (worktree: Worktree) => void; - setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void; setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => boolean; clearGitStatus: (sessionId: string) => void; clearLegacyGitStatusEntry: (sessionId: string) => void; @@ -446,52 +215,18 @@ export type AppState = { bumpSessionCommitsRefetch: (sessionId: string) => void; setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void; clearContextWindow: (sessionId: string) => void; - bumpAgentProfilesVersion: () => void; - setPendingModel: (sessionId: string, modelId: string) => void; - clearPendingModel: (sessionId: string) => void; setActiveModel: (sessionId: string, modelId: string) => void; // Task plan actions - setTaskPlan: (taskId: string, plan: import("@/lib/types/http").TaskPlan | null) => void; - setTaskPlanLoading: (taskId: string, loading: boolean) => void; - setTaskPlanSaving: (taskId: string, saving: boolean) => void; - clearTaskPlan: (taskId: string) => void; - markTaskPlanSeen: (taskId: string) => void; - // Plan revision actions - setPlanRevisions: ( - taskId: string, - revisions: import("@/lib/types/http").TaskPlanRevision[], - ) => void; - upsertPlanRevision: ( - taskId: string, - revision: import("@/lib/types/http").TaskPlanRevision, - ) => void; - setPlanRevisionsLoading: (taskId: string, loading: boolean) => void; - cachePlanRevisionContent: (revisionId: string, content: string) => void; + hydrateTaskPlanLastSeen: (taskId: string) => void; + markTaskPlanSeen: (taskId: string, updatedAt?: string | null) => void; // Plan revision preview + compare actions setPreviewRevision: (taskId: string, revisionId: string | null) => void; toggleComparePair: (taskId: string, revisionId: string) => void; clearComparePair: (taskId: string) => void; // Walkthrough actions - setWalkthrough: (taskId: string, walkthrough: TaskWalkthrough | null) => void; - setWalkthroughActiveStep: (taskId: string, stepIndex: number) => void; - markWalkthroughSeen: (taskId: string) => void; - // Queue actions - setQueueEntries: ( - sessionId: string, - entries: import("./slices/session/types").QueuedMessage[], - meta: import("./slices/session/types").QueueMeta, - ) => void; - removeQueueEntry: (sessionId: string, entryId: string) => void; - setQueueLoading: (sessionId: string, loading: boolean) => void; - clearQueueStatus: (sessionId: string) => void; - // Available commands actions - setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void; - clearAvailableCommands: (sessionId: string) => void; - // Session mode actions - setSessionMode: (sessionId: string, modeId: string, availableModes?: SessionModeEntry[]) => void; - clearSessionMode: (sessionId: string) => void; - // Agent capabilities actions - setAgentCapabilities: (sessionId: string, caps: AgentCapabilitiesEntry) => void; + hydrateWalkthroughLastSeen: (taskId: string) => void; + setWalkthroughActiveStep: (taskId: string, stepIndex: number, stepCount?: number) => void; + markWalkthroughSeen: (taskId: string, updatedAt?: string | null) => void; // Session models actions setSessionModels: ( sessionId: string, @@ -501,10 +236,6 @@ export type AppState = { configOptions: ConfigOptionEntry[]; }, ) => void; - // Prompt usage actions - setPromptUsage: (sessionId: string, usage: PromptUsageEntry) => void; - // Session todos actions - setSessionTodos: (sessionId: string, entries: TodoEntry[]) => void; // User shells actions setUserShells: (sessionId: string, shells: UserShellInfo[]) => void; setUserShellsLoading: (sessionId: string, loading: boolean) => void; @@ -515,7 +246,6 @@ export type AppState = { terminalId: string, patch: Partial>, ) => void; - setSessionPollMode: (sessionId: string, mode: SessionPollMode) => void; /* prettier-ignore */ setSidebarActiveView: UIA["setSidebarActiveView"]; updateSidebarDraft: UIA["updateSidebarDraft"]; saveSidebarDraftAs: UIA["saveSidebarDraftAs"]; @@ -545,52 +275,13 @@ export type AppState = { acknowledgeAgentErrors: UIA["acknowledgeAgentErrors"]; dismissAgentError: UIA["dismissAgentError"]; // Office actions - setOfficeAgentProfiles: (agents: AgentProfile[]) => void; - addOfficeAgentProfile: (agent: AgentProfile) => void; - updateOfficeAgentProfile: (id: string, patch: Partial) => void; - removeOfficeAgentProfile: (id: string) => void; - setSkills: (skills: Skill[]) => void; - addSkill: (skill: Skill) => void; - updateSkill: (id: string, patch: Partial) => void; - removeSkill: (id: string) => void; - setProjects: (projects: Project[]) => void; - addProject: (project: Project) => void; - updateProject: (id: string, patch: Partial) => void; - removeProject: (id: string) => void; - setApprovals: (approvals: Approval[]) => void; - setActivity: (entries: ActivityEntry[]) => void; - setCostSummary: (summary: CostSummary | null) => void; - setBudgetPolicies: (policies: BudgetPolicy[]) => void; - setRoutines: (routines: Routine[]) => void; - setInboxItems: (items: InboxItem[]) => void; - setInboxCount: (count: number) => void; - setRuns: (runs: Run[]) => void; - setDashboard: (data: DashboardData | null) => void; - setTasks: (tasks: OfficeTask[]) => void; - appendTasks: (tasks: OfficeTask[]) => void; - patchTaskInStore: (taskId: string, patch: Partial) => void; setTaskFilters: (filters: Partial) => void; setTaskViewMode: (mode: TaskViewMode) => void; setTaskSortField: (field: TaskSortField) => void; setTaskSortDir: (dir: TaskSortDir) => void; setTaskGroupBy: (groupBy: TaskGroupBy) => void; toggleNesting: () => void; - setTasksLoading: (loading: boolean) => void; - setMeta: (meta: OfficeMeta | null) => void; - setOfficeLoading: (loading: boolean) => void; - setOfficeRefetchTrigger: (type: string) => void; - setWorkspaceRouting: (workspaceId: string, cfg: WorkspaceRouting | undefined) => void; - setKnownProviders: (providers: string[]) => void; - setRoutingPreview: (workspaceId: string, agents: AgentRoutePreview[]) => void; - setProviderHealth: (workspaceId: string, health: ProviderHealth[]) => void; - upsertProviderHealth: (workspaceId: string, row: ProviderHealth) => void; - setRunAttempts: (runId: string, attempts: RouteAttempt[]) => void; - appendRunAttempt: (runId: string, attempt: RouteAttempt) => void; - setAgentRouting: (agentId: string, data: AgentRouteData | undefined) => void; -} & GitHubSliceActions & - SystemSliceActions & - FeaturesSliceActions & - AutomationsSliceActions; +} & GitHubSliceActions; export function createAppStore(initialState?: Partial) { const merged = mergeInitialState(initialState); @@ -611,21 +302,9 @@ export function createAppStore(initialState?: Partial) { // eslint-disable-next-line @typescript-eslint/no-explicit-any ...createGitHubSlice(set as any, get as any, api as any), // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...createGitLabSlice(set as any, get as any, api as any), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...createJiraSlice(set as any, get as any, api as any), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...createLinearSlice(set as any, get as any, api as any), - // eslint-disable-next-line @typescript-eslint/no-explicit-any ...createOfficeSlice(set as any, get as any, api as any), // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...createFeaturesSlice(set as any, get as any, api as any), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...createSystemSlice(set as any, get as any, api as any), - // eslint-disable-next-line @typescript-eslint/no-explicit-any ...createUISlice(set as any, get as any, api as any), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...createAutomationsSlice(set as any, get as any, api as any), // Re-assert merged initial state so caller-supplied values win over slice defaults. ...buildStateOverrides(merged), // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/web/lib/state/store.ts.backup b/apps/web/lib/state/store.ts.backup deleted file mode 100644 index cf13c1ec20..0000000000 --- a/apps/web/lib/state/store.ts.backup +++ /dev/null @@ -1,1202 +0,0 @@ -import type { ReactNode } from 'react'; -import type { - Agent, - AvailableAgent, - AgentDiscovery, - Branch, - CustomPrompt, - Environment, - EditorOption, - Executor, - Message, - NotificationProvider, - Repository, - TaskState as TaskStatus, - TaskSession, - Turn, -} from '@/lib/types/http'; -import { createStore } from 'zustand/vanilla'; -import { immer } from 'zustand/middleware/immer'; -import { setLocalStorage } from '@/lib/local-storage'; - -const maxProcessOutputBytes = 2 * 1024 * 1024; - -function trimProcessOutput(value: string) { - if (value.length <= maxProcessOutputBytes) { - return value; - } - return value.slice(value.length - maxProcessOutputBytes); -} - -export type KanbanState = { - boardId: string | null; - columns: Array<{ id: string; title: string; color: string; position: number }>; - tasks: Array<{ - id: string; - columnId: string; - title: string; - description?: string; - position: number; - state?: TaskStatus; - repositoryId?: string; - }>; -}; - -export type WorkspaceState = { - items: Array<{ - id: string; - name: string; - description?: string | null; - owner_id: string; - default_executor_id?: string | null; - default_environment_id?: string | null; - default_agent_profile_id?: string | null; - created_at: string; - updated_at: string; - }>; - activeId: string | null; -}; - -export type AgentProfileOption = { - id: string; - label: string; - agent_id: string; -}; - -export type ExecutorsState = { - items: Executor[]; -}; - -export type EnvironmentsState = { - items: Environment[]; -}; - -export type SettingsAgentsState = { - items: Agent[]; -}; - -export type AgentDiscoveryState = { - items: AgentDiscovery[]; -}; - -export type EditorsState = { - items: EditorOption[]; - loaded: boolean; - loading: boolean; -}; - -export type PromptsState = { - items: CustomPrompt[]; - loaded: boolean; - loading: boolean; -}; - -export type NotificationProvidersState = { - items: NotificationProvider[]; - events: string[]; - appriseAvailable: boolean; - loaded: boolean; - loading: boolean; -}; - -export type AvailableAgentsState = { - items: AvailableAgent[]; - loading: boolean; - loaded: boolean; -}; - -export type BoardState = { - items: Array<{ id: string; workspaceId: string; name: string }>; - activeId: string | null; -}; - -export type TaskState = { - activeTaskId: string | null; - activeSessionId: string | null; -}; - -export type AgentState = { - agents: Array<{ id: string; status: 'idle' | 'running' | 'error' }>; -}; - -export type AgentProfilesState = { - items: AgentProfileOption[]; - version: number; -}; - -export type RepositoriesState = { - itemsByWorkspaceId: Record; - loadingByWorkspaceId: Record; - loadedByWorkspaceId: Record; -}; - -export type RepositoryBranchesState = { - itemsByRepositoryId: Record; - loadingByRepositoryId: Record; - loadedByRepositoryId: Record; -}; - -export type SettingsDataState = { - executorsLoaded: boolean; - environmentsLoaded: boolean; - agentsLoaded: boolean; -}; - -export type UserSettingsState = { - workspaceId: string | null; - boardId: string | null; - repositoryIds: string[]; - preferredShell: string | null; - shellOptions: Array<{ value: string; label: string }>; - defaultEditorId: string | null; - enablePreviewOnClick: boolean; - loaded: boolean; -}; - -export type TerminalState = { - terminals: Array<{ id: string; output: string[] }>; -}; - -export type ShellState = { - // Map of sessionId to shell output buffer (raw bytes as string) - outputs: Record; - // Map of sessionId to shell status - statuses: Record< - string, - { - available: boolean; - running?: boolean; - shell?: string; - cwd?: string; - } - >; -}; - -export type ProcessStatusEntry = { - processId: string; - sessionId: string; - kind: string; - scriptName?: string; - status: string; - command?: string; - workingDir?: string; - exitCode?: number | null; - startedAt?: string; - updatedAt?: string; -}; - -export type ProcessState = { - outputsByProcessId: Record; - processesById: Record; - processIdsBySessionId: Record; - activeProcessBySessionId: Record; - devProcessBySessionId: Record; -}; - -export type PreviewStage = 'closed' | 'logs' | 'preview'; -export type PreviewViewMode = 'preview' | 'output'; -export type PreviewDevicePreset = 'desktop' | 'tablet' | 'mobile'; - -export type PreviewPanelState = { - openBySessionId: Record; - viewBySessionId: Record; - deviceBySessionId: Record; - stageBySessionId: Record; - urlBySessionId: Record; - urlDraftBySessionId: Record; -}; - -export type RightPanelState = { - activeTabBySessionId: Record; -}; - - -export type FileInfo = { - path: string; - status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'; - staged: boolean; - additions?: number; - deletions?: number; - old_path?: string; - diff?: string; -}; - -export type GitStatusEntry = { - branch: string | null; - remote_branch: string | null; - modified: string[]; - added: string[]; - deleted: string[]; - untracked: string[]; - renamed: string[]; - ahead: number; - behind: number; - files: Record; - timestamp: string | null; -}; - -export type GitStatusState = { - bySessionId: Record; -}; - -export type ContextWindowEntry = { - size: number; - used: number; - remaining: number; - efficiency: number; - timestamp?: string; -}; - -export type ContextWindowState = { - bySessionId: Record; -}; - -export type Worktree = { - id: string; - sessionId: string; - repositoryId?: string; - path?: string; - branch?: string; -}; - -export type WorktreesState = { - items: Record; -}; - -export type SessionWorktreesState = { - itemsBySessionId: Record; -}; - -export type DiffState = { - files: Array<{ path: string; status: 'A' | 'M' | 'D'; plus: number; minus: number }>; -}; - -export type ConnectionState = { - status: 'disconnected' | 'connecting' | 'connected' | 'error' | 'reconnecting'; - error: string | null; -}; - -export type MessagesState = { - bySession: Record; - metaBySession: Record< - string, - { - isLoading: boolean; - hasMore: boolean; - oldestCursor: string | null; - } - >; -}; - -export type TurnsState = { - bySession: Record; - activeBySession: Record; // sessionId -> active turnId -}; - -export type TaskSessionsState = { - items: Record; -}; - -export type TaskSessionsByTaskState = { - itemsByTaskId: Record; - loadingByTaskId: Record; - loadedByTaskId: Record; -}; - -export type SessionAgentctlStatus = { - status: 'starting' | 'ready' | 'error'; - errorMessage?: string; - agentExecutionId?: string; - updatedAt?: string; -}; - -export type SessionAgentctlState = { - itemsBySessionId: Record; -}; - -export type PendingModelState = { - bySessionId: Record; -}; - -export type ActiveModelState = { - bySessionId: Record; -}; - -export type AppState = { - kanban: KanbanState; - workspaces: WorkspaceState; - boards: BoardState; - executors: ExecutorsState; - environments: EnvironmentsState; - settingsAgents: SettingsAgentsState; - agentDiscovery: AgentDiscoveryState; - availableAgents: AvailableAgentsState; - repositories: RepositoriesState; - repositoryBranches: RepositoryBranchesState; - settingsData: SettingsDataState; - editors: EditorsState; - prompts: PromptsState; - notificationProviders: NotificationProvidersState; - tasks: TaskState; - agents: AgentState; - agentProfiles: AgentProfilesState; - userSettings: UserSettingsState; - terminal: TerminalState; - shell: ShellState; - processes: ProcessState; - previewPanel: PreviewPanelState; - rightPanel: RightPanelState; - diffs: DiffState; - gitStatus: GitStatusState; - contextWindow: ContextWindowState; - connection: ConnectionState; - messages: MessagesState; - turns: TurnsState; - taskSessions: TaskSessionsState; - taskSessionsByTask: TaskSessionsByTaskState; - sessionAgentctl: SessionAgentctlState; - worktrees: WorktreesState; - sessionWorktreesBySessionId: SessionWorktreesState; - pendingModel: PendingModelState; - activeModel: ActiveModelState; - hydrate: (state: Partial) => void; - setActiveWorkspace: (workspaceId: string | null) => void; - setWorkspaces: (workspaces: WorkspaceState['items']) => void; - setActiveBoard: (boardId: string | null) => void; - setBoards: (boards: BoardState['items']) => void; - setExecutors: (executors: ExecutorsState['items']) => void; - setEnvironments: (environments: EnvironmentsState['items']) => void; - setSettingsAgents: (agents: SettingsAgentsState['items']) => void; - setAgentDiscovery: (agents: AgentDiscoveryState['items']) => void; - setAvailableAgents: (agents: AvailableAgentsState['items']) => void; - setAvailableAgentsLoading: (loading: boolean) => void; - setAgentProfiles: (profiles: AgentProfilesState['items']) => void; - setRepositories: (workspaceId: string, repositories: Repository[]) => void; - setRepositoriesLoading: (workspaceId: string, loading: boolean) => void; - setRepositoryBranches: (repositoryId: string, branches: Branch[]) => void; - setRepositoryBranchesLoading: (repositoryId: string, loading: boolean) => void; - setSettingsData: (next: Partial) => void; - setEditors: (editors: EditorsState['items']) => void; - setEditorsLoading: (loading: boolean) => void; - setPrompts: (prompts: PromptsState['items']) => void; - setPromptsLoading: (loading: boolean) => void; - setNotificationProviders: (state: NotificationProvidersState) => void; - setNotificationProvidersLoading: (loading: boolean) => void; - setUserSettings: (settings: UserSettingsState) => void; - setTerminalOutput: (terminalId: string, data: string) => void; - appendShellOutput: (sessionId: string, data: string) => void; - setShellStatus: ( - sessionId: string, - status: { available: boolean; running?: boolean; shell?: string; cwd?: string } - ) => void; - clearShellOutput: (sessionId: string) => void; - appendProcessOutput: (processId: string, data: string) => void; - upsertProcessStatus: (status: ProcessStatusEntry) => void; - clearProcessOutput: (processId: string) => void; - setActiveProcess: (sessionId: string, processId: string) => void; - setPreviewOpen: (sessionId: string, open: boolean) => void; - togglePreviewOpen: (sessionId: string) => void; - setPreviewView: (sessionId: string, view: PreviewViewMode) => void; - setPreviewDevice: (sessionId: string, device: PreviewDevicePreset) => void; - setPreviewStage: (sessionId: string, stage: PreviewStage) => void; - setPreviewUrl: (sessionId: string, url: string) => void; - setPreviewUrlDraft: (sessionId: string, url: string) => void; - setRightPanelActiveTab: (sessionId: string, tab: string) => void; - setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void; - setMessages: ( - sessionId: string, - messages: Message[], - meta?: { hasMore?: boolean; oldestCursor?: string | null } - ) => void; - addMessage: (message: Message) => void; - addTurn: (turn: Turn) => void; - completeTurn: (sessionId: string, turnId: string, completedAt: string) => void; - setActiveTurn: (sessionId: string, turnId: string | null) => void; - updateMessage: (message: Message) => void; - prependMessages: ( - sessionId: string, - messages: Message[], - meta?: { hasMore?: boolean; oldestCursor?: string | null } - ) => void; - setMessagesMetadata: ( - sessionId: string, - meta: { hasMore?: boolean; isLoading?: boolean; oldestCursor?: string | null } - ) => void; - setMessagesLoading: (sessionId: string, loading: boolean) => void; - setActiveSession: (taskId: string, sessionId: string) => void; - setActiveTask: (taskId: string) => void; - clearActiveSession: () => void; - setTaskSession: (session: TaskSession) => void; - setTaskSessionsForTask: (taskId: string, sessions: TaskSession[]) => void; - setTaskSessionsLoading: (taskId: string, loading: boolean) => void; - setSessionAgentctlStatus: (sessionId: string, status: SessionAgentctlStatus) => void; - setWorktree: (worktree: Worktree) => void; - setSessionWorktrees: (sessionId: string, worktreeIds: string[]) => void; - setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => void; - clearGitStatus: (sessionId: string) => void; - setContextWindow: (sessionId: string, contextWindow: ContextWindowEntry) => void; - bumpAgentProfilesVersion: () => void; - setPendingModel: (sessionId: string, modelId: string) => void; - clearPendingModel: (sessionId: string) => void; - setActiveModel: (sessionId: string, modelId: string) => void; -}; - -export type AppStore = ReturnType; - -const defaultState: AppState = { - kanban: { boardId: null, columns: [], tasks: [] }, - workspaces: { items: [], activeId: null }, - boards: { items: [], activeId: null }, - executors: { items: [] }, - environments: { items: [] }, - settingsAgents: { items: [] }, - agentDiscovery: { items: [] }, - availableAgents: { items: [], loading: false, loaded: false }, - repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} }, - repositoryBranches: { itemsByRepositoryId: {}, loadingByRepositoryId: {}, loadedByRepositoryId: {} }, - settingsData: { executorsLoaded: false, environmentsLoaded: false, agentsLoaded: false }, - editors: { items: [], loaded: false, loading: false }, - prompts: { items: [], loaded: false, loading: false }, - notificationProviders: { - items: [], - events: [], - appriseAvailable: false, - loaded: false, - loading: false, - }, - tasks: { activeTaskId: null, activeSessionId: null }, - agents: { agents: [] }, - agentProfiles: { items: [], version: 0 }, - userSettings: { - workspaceId: null, - boardId: null, - repositoryIds: [], - preferredShell: null, - shellOptions: [], - defaultEditorId: null, - enablePreviewOnClick: false, - loaded: false, - }, - terminal: { terminals: [] }, - shell: { outputs: {}, statuses: {} }, - processes: { - outputsByProcessId: {}, - processesById: {}, - processIdsBySessionId: {}, - activeProcessBySessionId: {}, - devProcessBySessionId: {}, - }, - previewPanel: { - openBySessionId: {}, - viewBySessionId: {}, - deviceBySessionId: {}, - stageBySessionId: {}, - urlBySessionId: {}, - urlDraftBySessionId: {}, - }, - rightPanel: { activeTabBySessionId: {} }, - diffs: { files: [] }, - gitStatus: { bySessionId: {} }, - contextWindow: { bySessionId: {} }, - connection: { status: 'disconnected', error: null }, - messages: { bySession: {}, metaBySession: {} }, - turns: { - bySession: {}, - activeBySession: {}, - }, - taskSessions: { items: {} }, - taskSessionsByTask: { itemsByTaskId: {}, loadingByTaskId: {}, loadedByTaskId: {} }, - sessionAgentctl: { itemsBySessionId: {} }, - worktrees: { items: {} }, - sessionWorktreesBySessionId: { itemsBySessionId: {} }, - pendingModel: { bySessionId: {} }, - activeModel: { bySessionId: {} }, - hydrate: () => undefined, - setActiveWorkspace: () => undefined, - setWorkspaces: () => undefined, - setActiveBoard: () => undefined, - setBoards: () => undefined, - setExecutors: () => undefined, - setEnvironments: () => undefined, - setSettingsAgents: () => undefined, - setAgentDiscovery: () => undefined, - setAvailableAgents: () => undefined, - setAvailableAgentsLoading: () => undefined, - setAgentProfiles: () => undefined, - setRepositories: () => undefined, - setRepositoriesLoading: () => undefined, - setRepositoryBranches: () => undefined, - setRepositoryBranchesLoading: () => undefined, - setSettingsData: () => undefined, - setEditors: () => undefined, - setEditorsLoading: () => undefined, - setPrompts: () => undefined, - setPromptsLoading: () => undefined, - setNotificationProviders: () => undefined, - setNotificationProvidersLoading: () => undefined, - setUserSettings: () => undefined, - setTerminalOutput: () => undefined, - appendShellOutput: () => undefined, - setShellStatus: () => undefined, - clearShellOutput: () => undefined, - appendProcessOutput: () => undefined, - upsertProcessStatus: () => undefined, - clearProcessOutput: () => undefined, - setActiveProcess: () => undefined, - setPreviewOpen: () => undefined, - togglePreviewOpen: () => undefined, - setPreviewView: () => undefined, - setPreviewDevice: () => undefined, - setPreviewStage: () => undefined, - setPreviewUrl: () => undefined, - setPreviewUrlDraft: () => undefined, - setRightPanelActiveTab: () => undefined, - setConnectionStatus: () => undefined, - setMessages: () => undefined, - addMessage: () => undefined, - addTurn: () => undefined, - completeTurn: () => undefined, - setActiveTurn: () => undefined, - updateMessage: () => undefined, - prependMessages: () => undefined, - setMessagesMetadata: () => undefined, - setMessagesLoading: () => undefined, - setActiveSession: () => undefined, - setActiveTask: () => undefined, - clearActiveSession: () => undefined, - setTaskSession: () => undefined, - setTaskSessionsForTask: () => undefined, - setTaskSessionsLoading: () => undefined, - setSessionAgentctlStatus: () => undefined, - setWorktree: () => undefined, - setSessionWorktrees: () => undefined, - setGitStatus: () => undefined, - clearGitStatus: () => undefined, - setContextWindow: () => undefined, - bumpAgentProfilesVersion: () => undefined, - setPendingModel: () => undefined, - clearPendingModel: () => undefined, - setActiveModel: () => undefined, -}; - -function mergeInitialState( - initialState?: Partial -): Omit< - AppState, - | 'hydrate' - | 'setActiveWorkspace' - | 'setWorkspaces' - | 'setActiveBoard' - | 'setBoards' - | 'setExecutors' - | 'setEnvironments' - | 'setSettingsAgents' - | 'setAgentDiscovery' - | 'setAvailableAgents' - | 'setAvailableAgentsLoading' - | 'setAgentProfiles' - | 'setRepositories' - | 'setRepositoriesLoading' - | 'setRepositoryBranches' - | 'setRepositoryBranchesLoading' - | 'setSettingsData' - | 'setEditors' - | 'setEditorsLoading' - | 'setPrompts' - | 'setPromptsLoading' - | 'setNotificationProviders' - | 'setNotificationProvidersLoading' - | 'setUserSettings' - | 'setTerminalOutput' - | 'appendShellOutput' - | 'setShellStatus' - | 'clearShellOutput' - | 'appendProcessOutput' - | 'upsertProcessStatus' - | 'clearProcessOutput' - | 'setActiveProcess' - | 'setPreviewOpen' - | 'togglePreviewOpen' - | 'setPreviewView' - | 'setPreviewDevice' - | 'setPreviewStage' - | 'setPreviewUrl' - | 'setPreviewUrlDraft' - | 'setRightPanelActiveTab' - | 'setConnectionStatus' - | 'setMessages' - | 'addMessage' - | 'addTurn' - | 'completeTurn' - | 'setActiveTurn' - | 'updateMessage' - | 'prependMessages' - | 'setMessagesMetadata' - | 'setMessagesLoading' - | 'setActiveSession' - | 'setActiveTask' - | 'clearActiveSession' - | 'setTaskSession' - | 'setTaskSessionsForTask' - | 'setTaskSessionsLoading' - | 'setSessionAgentctlStatus' - | 'setWorktree' - | 'setSessionWorktrees' - | 'setGitStatus' - | 'clearGitStatus' - | 'setContextWindow' - | 'bumpAgentProfilesVersion' - | 'setPendingModel' - | 'clearPendingModel' - | 'setActiveModel' -> { - if (!initialState) return defaultState; - return { - workspaces: { ...defaultState.workspaces, ...initialState.workspaces }, - boards: { ...defaultState.boards, ...initialState.boards }, - executors: { ...defaultState.executors, ...initialState.executors }, - environments: { ...defaultState.environments, ...initialState.environments }, - settingsAgents: { ...defaultState.settingsAgents, ...initialState.settingsAgents }, - agentDiscovery: { ...defaultState.agentDiscovery, ...initialState.agentDiscovery }, - availableAgents: { ...defaultState.availableAgents, ...initialState.availableAgents }, - repositories: { ...defaultState.repositories, ...initialState.repositories }, - repositoryBranches: { ...defaultState.repositoryBranches, ...initialState.repositoryBranches }, - settingsData: { ...defaultState.settingsData, ...initialState.settingsData }, - editors: { ...defaultState.editors, ...initialState.editors }, - prompts: { ...defaultState.prompts, ...initialState.prompts }, - notificationProviders: { - ...defaultState.notificationProviders, - ...initialState.notificationProviders, - }, - kanban: { ...defaultState.kanban, ...initialState.kanban }, - tasks: { ...defaultState.tasks, ...initialState.tasks }, - agents: { ...defaultState.agents, ...initialState.agents }, - agentProfiles: { ...defaultState.agentProfiles, ...initialState.agentProfiles }, - userSettings: { ...defaultState.userSettings, ...initialState.userSettings }, - terminal: { ...defaultState.terminal, ...initialState.terminal }, - shell: { ...defaultState.shell, ...initialState.shell }, - processes: { ...defaultState.processes, ...initialState.processes }, - previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel }, - rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel }, - diffs: { ...defaultState.diffs, ...initialState.diffs }, - gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus }, - contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow }, - connection: { ...defaultState.connection, ...initialState.connection }, - messages: { ...defaultState.messages, ...initialState.messages }, - turns: { ...defaultState.turns, ...initialState.turns }, - taskSessions: { ...defaultState.taskSessions, ...initialState.taskSessions }, - taskSessionsByTask: { ...defaultState.taskSessionsByTask, ...initialState.taskSessionsByTask }, - sessionAgentctl: { ...defaultState.sessionAgentctl, ...initialState.sessionAgentctl }, - worktrees: { ...defaultState.worktrees, ...initialState.worktrees }, - sessionWorktreesBySessionId: { - ...defaultState.sessionWorktreesBySessionId, - ...initialState.sessionWorktreesBySessionId, - }, - pendingModel: { ...defaultState.pendingModel, ...initialState.pendingModel }, - activeModel: { ...defaultState.activeModel, ...initialState.activeModel }, - }; -} - -export function createAppStore(initialState?: Partial) { - const merged = mergeInitialState(initialState); - return createStore()( - immer((set, get) => ({ - ...merged, - hydrate: (state) => - set((draft) => { - // Deep merge to avoid overwriting nested objects with undefined - if (state.workspaces) Object.assign(draft.workspaces, state.workspaces); - if (state.boards) Object.assign(draft.boards, state.boards); - if (state.executors) Object.assign(draft.executors, state.executors); - if (state.environments) Object.assign(draft.environments, state.environments); - if (state.settingsAgents) Object.assign(draft.settingsAgents, state.settingsAgents); - if (state.agentDiscovery) Object.assign(draft.agentDiscovery, state.agentDiscovery); - if (state.availableAgents) Object.assign(draft.availableAgents, state.availableAgents); - if (state.repositories) Object.assign(draft.repositories, state.repositories); - if (state.repositoryBranches) Object.assign(draft.repositoryBranches, state.repositoryBranches); - if (state.settingsData) Object.assign(draft.settingsData, state.settingsData); - if (state.editors) Object.assign(draft.editors, state.editors); - if (state.prompts) Object.assign(draft.prompts, state.prompts); - if (state.notificationProviders) { - Object.assign(draft.notificationProviders, state.notificationProviders); - } - if (state.kanban) Object.assign(draft.kanban, state.kanban); - if (state.tasks) Object.assign(draft.tasks, state.tasks); - if (state.agents) Object.assign(draft.agents, state.agents); - if (state.agentProfiles) Object.assign(draft.agentProfiles, state.agentProfiles); - if (state.userSettings) Object.assign(draft.userSettings, state.userSettings); - if (state.terminal) Object.assign(draft.terminal, state.terminal); - if (state.shell) Object.assign(draft.shell, state.shell); - if (state.processes) Object.assign(draft.processes, state.processes); - if (state.previewPanel) Object.assign(draft.previewPanel, state.previewPanel); - if (state.rightPanel) Object.assign(draft.rightPanel, state.rightPanel); - if (state.diffs) Object.assign(draft.diffs, state.diffs); - if (state.gitStatus) Object.assign(draft.gitStatus, state.gitStatus); - if (state.connection) Object.assign(draft.connection, state.connection); - if (state.messages) Object.assign(draft.messages, state.messages); - if (state.turns) Object.assign(draft.turns, state.turns); - if (state.taskSessions) Object.assign(draft.taskSessions, state.taskSessions); - if (state.taskSessionsByTask) { - Object.assign(draft.taskSessionsByTask, state.taskSessionsByTask); - } - if (state.sessionAgentctl) { - Object.assign(draft.sessionAgentctl, state.sessionAgentctl); - } - if (state.worktrees) Object.assign(draft.worktrees, state.worktrees); - if (state.sessionWorktreesBySessionId) { - Object.assign(draft.sessionWorktreesBySessionId, state.sessionWorktreesBySessionId); - } - }), - setActiveWorkspace: (workspaceId) => { - if (get().workspaces.activeId === workspaceId) { - return; - } - set((draft) => { - draft.workspaces.activeId = workspaceId; - }); - }, - setWorkspaces: (workspaces) => - set((draft) => { - draft.workspaces.items = workspaces; - if (!draft.workspaces.activeId && workspaces.length) { - draft.workspaces.activeId = workspaces[0].id; - } - }), - setBoards: (boards) => - set((draft) => { - draft.boards.items = boards; - if (!draft.boards.activeId && boards.length) { - draft.boards.activeId = boards[0].id; - } - }), - setActiveBoard: (boardId) => { - if (get().boards.activeId === boardId) { - return; - } - set((draft) => { - draft.boards.activeId = boardId; - }); - }, - setExecutors: (executors) => - set((draft) => { - draft.executors.items = executors; - }), - setEnvironments: (environments) => - set((draft) => { - draft.environments.items = environments; - }), - setSettingsAgents: (agents) => - set((draft) => { - draft.settingsAgents.items = agents; - }), - setAgentDiscovery: (agents) => - set((draft) => { - draft.agentDiscovery.items = agents; - }), - setAvailableAgents: (agents) => - set((draft) => { - draft.availableAgents.items = agents; - draft.availableAgents.loading = false; - draft.availableAgents.loaded = true; - }), - setAvailableAgentsLoading: (loading) => - set((draft) => { - draft.availableAgents.loading = loading; - }), - setAgentProfiles: (profiles) => - set((draft) => { - draft.agentProfiles.items = profiles; - }), - setRepositories: (workspaceId, repositories) => - set((draft) => { - draft.repositories.itemsByWorkspaceId[workspaceId] = repositories; - draft.repositories.loadingByWorkspaceId[workspaceId] = false; - draft.repositories.loadedByWorkspaceId[workspaceId] = true; - }), - setRepositoriesLoading: (workspaceId, loading) => - set((draft) => { - draft.repositories.loadingByWorkspaceId[workspaceId] = loading; - }), - setRepositoryBranches: (repositoryId, branches) => - set((draft) => { - draft.repositoryBranches.itemsByRepositoryId[repositoryId] = branches; - draft.repositoryBranches.loadingByRepositoryId[repositoryId] = false; - draft.repositoryBranches.loadedByRepositoryId[repositoryId] = true; - }), - setRepositoryBranchesLoading: (repositoryId, loading) => - set((draft) => { - draft.repositoryBranches.loadingByRepositoryId[repositoryId] = loading; - }), - setSettingsData: (next) => - set((draft) => { - draft.settingsData = { ...draft.settingsData, ...next }; - }), - setEditors: (editors) => - set((draft) => { - draft.editors.items = editors; - draft.editors.loaded = true; - }), - setEditorsLoading: (loading) => - set((draft) => { - draft.editors.loading = loading; - }), - setPrompts: (prompts) => - set((draft) => { - draft.prompts.items = prompts; - draft.prompts.loaded = true; - }), - setPromptsLoading: (loading) => - set((draft) => { - draft.prompts.loading = loading; - }), - setNotificationProviders: (state) => - set((draft) => { - draft.notificationProviders.items = state.items; - draft.notificationProviders.events = state.events; - draft.notificationProviders.appriseAvailable = state.appriseAvailable; - draft.notificationProviders.loaded = state.loaded; - draft.notificationProviders.loading = state.loading; - }), - setNotificationProvidersLoading: (loading) => - set((draft) => { - draft.notificationProviders.loading = loading; - }), - setUserSettings: (settings) => - set((draft) => { - draft.userSettings = settings; - }), - setTerminalOutput: (terminalId, data) => - set((draft) => { - const existing = draft.terminal.terminals.find((terminal) => terminal.id === terminalId); - if (existing) { - existing.output.push(data); - } else { - draft.terminal.terminals.push({ id: terminalId, output: [data] }); - } - }), - appendShellOutput: (sessionId, data) => - set((draft) => { - draft.shell.outputs[sessionId] = (draft.shell.outputs[sessionId] || '') + data; - }), - setShellStatus: (sessionId, status) => - set((draft) => { - draft.shell.statuses[sessionId] = status; - }), - clearShellOutput: (sessionId) => - set((draft) => { - draft.shell.outputs[sessionId] = ''; - }), - appendProcessOutput: (processId, data) => - set((draft) => { - const next = - (draft.processes.outputsByProcessId[processId] || '') + data; - draft.processes.outputsByProcessId[processId] = trimProcessOutput(next); - }), - upsertProcessStatus: (status) => - set((draft) => { - draft.processes.processesById[status.processId] = status; - const list = draft.processes.processIdsBySessionId[status.sessionId] || []; - if (!list.includes(status.processId)) { - list.push(status.processId); - draft.processes.processIdsBySessionId[status.sessionId] = list; - } - if (!draft.processes.activeProcessBySessionId[status.sessionId]) { - draft.processes.activeProcessBySessionId[status.sessionId] = status.processId; - } - if (status.kind === 'dev') { - draft.processes.devProcessBySessionId[status.sessionId] = status.processId; - } - }), - clearProcessOutput: (processId) => - set((draft) => { - draft.processes.outputsByProcessId[processId] = ''; - }), - setActiveProcess: (sessionId, processId) => - set((draft) => { - draft.processes.activeProcessBySessionId[sessionId] = processId; - }), - setPreviewOpen: (sessionId, open) => - set((draft) => { - draft.previewPanel.openBySessionId[sessionId] = open; - if (!draft.previewPanel.viewBySessionId[sessionId]) { - draft.previewPanel.viewBySessionId[sessionId] = 'preview'; - } - if (!draft.previewPanel.deviceBySessionId[sessionId]) { - draft.previewPanel.deviceBySessionId[sessionId] = 'desktop'; - } - }), - togglePreviewOpen: (sessionId) => - set((draft) => { - const current = draft.previewPanel.openBySessionId[sessionId] ?? false; - draft.previewPanel.openBySessionId[sessionId] = !current; - if (!draft.previewPanel.viewBySessionId[sessionId]) { - draft.previewPanel.viewBySessionId[sessionId] = 'preview'; - } - if (!draft.previewPanel.deviceBySessionId[sessionId]) { - draft.previewPanel.deviceBySessionId[sessionId] = 'desktop'; - } - }), - setPreviewView: (sessionId, view) => - set((draft) => { - draft.previewPanel.viewBySessionId[sessionId] = view; - // Persist to localStorage - const key = `preview-view:${sessionId}`; - setLocalStorage(key, view); - }), - setPreviewDevice: (sessionId, device) => - set((draft) => { - draft.previewPanel.deviceBySessionId[sessionId] = device; - }), - setPreviewStage: (sessionId, stage) => - set((draft) => { - draft.previewPanel.stageBySessionId[sessionId] = stage; - // layout changes handled by layout-store - }), - setPreviewUrl: (sessionId, url) => - set((draft) => { - draft.previewPanel.urlBySessionId[sessionId] = url; - }), - setPreviewUrlDraft: (sessionId, url) => - set((draft) => { - draft.previewPanel.urlDraftBySessionId[sessionId] = url; - }), - setRightPanelActiveTab: (sessionId, tab) => - set((draft) => { - draft.rightPanel.activeTabBySessionId[sessionId] = tab; - }), - setConnectionStatus: (status, error = null) => - set((draft) => { - draft.connection.status = status; - draft.connection.error = error; - }), - setMessages: (sessionId, messages, meta) => - set((draft) => { - draft.messages.bySession[sessionId] = messages; - const existingMeta = draft.messages.metaBySession[sessionId]; - draft.messages.metaBySession[sessionId] = { - isLoading: false, - hasMore: meta?.hasMore ?? existingMeta?.hasMore ?? false, - oldestCursor: - meta?.oldestCursor ?? (messages.length ? messages[0].id : existingMeta?.oldestCursor ?? null), - }; - }), - addMessage: (message) => - set((draft) => { - if (!message.session_id) { - return; - } - const sessionId = message.session_id; - const list = draft.messages.bySession[sessionId] ?? []; - const exists = list.some((item) => item.id === message.id); - if (exists) { - return; - } - draft.messages.bySession[sessionId] = [...list, message]; - const meta = draft.messages.metaBySession[sessionId] ?? { - isLoading: false, - hasMore: false, - oldestCursor: null, - }; - if (!meta.oldestCursor) { - meta.oldestCursor = message.id; - } - draft.messages.metaBySession[sessionId] = meta; - }), - addTurn: (turn) => - set((state) => { - const sessionId = turn.session_id; - if (!state.turns.bySession[sessionId]) { - state.turns.bySession[sessionId] = []; - } - // Add turn if not already present - const existing = state.turns.bySession[sessionId].find((t) => t.id === turn.id); - if (!existing) { - state.turns.bySession[sessionId].push(turn); - } - // Set as active turn if not completed - if (!turn.completed_at) { - state.turns.activeBySession[sessionId] = turn.id; - } - }), - completeTurn: (sessionId, turnId, completedAt) => - set((state) => { - const turns = state.turns.bySession[sessionId]; - if (turns) { - const turn = turns.find((t) => t.id === turnId); - if (turn) { - turn.completed_at = completedAt; - turn.updated_at = completedAt; - } - } - // Clear active turn if it matches - if (state.turns.activeBySession[sessionId] === turnId) { - state.turns.activeBySession[sessionId] = null; - } - }), - setActiveTurn: (sessionId, turnId) => - set((state) => { - state.turns.activeBySession[sessionId] = turnId; - }), - updateMessage: (message) => - set((draft) => { - if (!message.session_id) { - return; - } - const sessionId = message.session_id; - const list = draft.messages.bySession[sessionId]; - if (!list) return; - const index = list.findIndex((item) => item.id === message.id); - if (index !== -1) { - list[index] = message; - } - }), - prependMessages: (sessionId, messages, meta) => - set((draft) => { - const existing = draft.messages.bySession[sessionId] ?? []; - const existingIds = new Set(existing.map((item) => item.id)); - const incoming = messages.filter((item) => !existingIds.has(item.id)); - const next = incoming.length ? [...incoming, ...existing] : existing; - draft.messages.bySession[sessionId] = next; - const currentMeta = draft.messages.metaBySession[sessionId] ?? { - isLoading: false, - hasMore: false, - oldestCursor: null, - }; - draft.messages.metaBySession[sessionId] = { - isLoading: false, - hasMore: meta?.hasMore ?? currentMeta.hasMore, - oldestCursor: - meta?.oldestCursor ?? (next.length ? next[0].id : currentMeta.oldestCursor ?? null), - }; - }), - setMessagesMetadata: (sessionId, meta) => - set((draft) => { - const currentMeta = draft.messages.metaBySession[sessionId] ?? { - isLoading: false, - hasMore: false, - oldestCursor: null, - }; - draft.messages.metaBySession[sessionId] = { - isLoading: meta.isLoading ?? currentMeta.isLoading, - hasMore: meta.hasMore ?? currentMeta.hasMore, - oldestCursor: meta.oldestCursor ?? currentMeta.oldestCursor, - }; - }), - setMessagesLoading: (sessionId, loading) => - set((draft) => { - const currentMeta = draft.messages.metaBySession[sessionId] ?? { - isLoading: false, - hasMore: false, - oldestCursor: null, - }; - currentMeta.isLoading = loading; - draft.messages.metaBySession[sessionId] = currentMeta; - }), - setActiveSession: (taskId, sessionId) => - set((draft) => { - draft.tasks.activeTaskId = taskId; - draft.tasks.activeSessionId = sessionId; - }), - setActiveTask: (taskId) => - set((draft) => { - draft.tasks.activeTaskId = taskId; - draft.tasks.activeSessionId = null; - }), - clearActiveSession: () => - set((draft) => { - draft.tasks.activeTaskId = null; - draft.tasks.activeSessionId = null; - }), - setTaskSession: (session) => - set((draft) => { - // Merge with existing session data to preserve fields like agent_profile_id - const existingSession = draft.taskSessions.items[session.id]; - draft.taskSessions.items[session.id] = existingSession - ? { ...existingSession, ...session } - : session; - if (session.worktree_id) { - draft.worktrees.items[session.worktree_id] = { - id: session.worktree_id, - sessionId: session.id, - repositoryId: session.repository_id ?? undefined, - path: session.worktree_path ?? undefined, - branch: session.worktree_branch ?? undefined, - }; - draft.sessionWorktreesBySessionId.itemsBySessionId[session.id] = [session.worktree_id]; - } - }), - setTaskSessionsForTask: (taskId, sessions) => - set((draft) => { - sessions.forEach((session) => { - draft.taskSessions.items[session.id] = session; - if (session.worktree_id) { - draft.worktrees.items[session.worktree_id] = { - id: session.worktree_id, - sessionId: session.id, - repositoryId: session.repository_id ?? undefined, - path: session.worktree_path ?? undefined, - branch: session.worktree_branch ?? undefined, - }; - draft.sessionWorktreesBySessionId.itemsBySessionId[session.id] = [session.worktree_id]; - } - }); - draft.taskSessionsByTask.itemsByTaskId[taskId] = sessions; - draft.taskSessionsByTask.loadedByTaskId[taskId] = true; - draft.taskSessionsByTask.loadingByTaskId[taskId] = false; - }), - setTaskSessionsLoading: (taskId, loading) => - set((draft) => { - draft.taskSessionsByTask.loadingByTaskId[taskId] = loading; - }), - setSessionAgentctlStatus: (sessionId, status) => - set((draft) => { - draft.sessionAgentctl.itemsBySessionId[sessionId] = status; - }), - setWorktree: (worktree) => - set((draft) => { - draft.worktrees.items[worktree.id] = worktree; - const existing = draft.sessionWorktreesBySessionId.itemsBySessionId[worktree.sessionId] ?? []; - if (!existing.includes(worktree.id)) { - draft.sessionWorktreesBySessionId.itemsBySessionId[worktree.sessionId] = [...existing, worktree.id]; - } - }), - setSessionWorktrees: (sessionId, worktreeIds) => - set((draft) => { - draft.sessionWorktreesBySessionId.itemsBySessionId[sessionId] = worktreeIds; - }), - setGitStatus: (sessionId, gitStatus) => - set((draft) => { - draft.gitStatus.bySessionId[sessionId] = gitStatus; - }), - clearGitStatus: (sessionId) => - set((draft) => { - delete draft.gitStatus.bySessionId[sessionId]; - }), - setContextWindow: (sessionId, contextWindow) => - set((draft) => { - draft.contextWindow.bySessionId[sessionId] = contextWindow; - }), - bumpAgentProfilesVersion: () => - set((draft) => { - draft.agentProfiles.version += 1; - }), - setPendingModel: (sessionId, modelId) => - set((draft) => { - draft.pendingModel.bySessionId[sessionId] = modelId; - }), - clearPendingModel: (sessionId) => - set((draft) => { - delete draft.pendingModel.bySessionId[sessionId]; - }), - setActiveModel: (sessionId, modelId) => - set((draft) => { - draft.activeModel.bySessionId[sessionId] = modelId; - }), - })) - ); -} - -export type StoreProviderProps = { - children: ReactNode; - initialState?: Partial; -}; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 8e4a3b1ee6..4b892013b3 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -90,6 +90,7 @@ export type TaskEventPayload = { review_status?: "pending" | "approved" | "changes_requested" | "rejected" | null; archived_at?: string | null; updated_at?: string; + parent_id?: string | null; is_ephemeral: boolean; /** Deletion reason on task.deleted (e.g. "pr_approved_by_user"). Absent otherwise. */ reason?: string; @@ -157,6 +158,16 @@ export type WorkspacePayload = { updated_at?: string; }; +export type RepositoryPayload = Record & { + id: string; + workspace_id?: string; +}; + +export type RepositoryScriptPayload = Record & { + id: string; + repository_id?: string; +}; + export type WorkflowPayload = { id: string; workspace_id: string; @@ -475,6 +486,21 @@ export type BackendMessageMap = OfficeBackendMessageMap & "workflow.step.created": BackendMessage<"workflow.step.created", WorkflowStepEventPayload>; "workflow.step.updated": BackendMessage<"workflow.step.updated", WorkflowStepEventPayload>; "workflow.step.deleted": BackendMessage<"workflow.step.deleted", WorkflowStepEventPayload>; + "repository.created": BackendMessage<"repository.created", RepositoryPayload>; + "repository.updated": BackendMessage<"repository.updated", RepositoryPayload>; + "repository.deleted": BackendMessage<"repository.deleted", RepositoryPayload>; + "repository.script.created": BackendMessage< + "repository.script.created", + RepositoryScriptPayload + >; + "repository.script.updated": BackendMessage< + "repository.script.updated", + RepositoryScriptPayload + >; + "repository.script.deleted": BackendMessage< + "repository.script.deleted", + RepositoryScriptPayload + >; "session.message.added": BackendMessage<"session.message.added", MessageAddedPayload>; "session.message.updated": BackendMessage<"session.message.updated", MessageAddedPayload>; "session.message.deleted": BackendMessage<"session.message.deleted", MessageAddedPayload>; diff --git a/apps/web/lib/ws/client.test.ts b/apps/web/lib/ws/client.test.ts new file mode 100644 index 0000000000..43b26d75f0 --- /dev/null +++ b/apps/web/lib/ws/client.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocketClient } from "./client"; + +type MockMessageHandler = (event: MessageEvent) => void; + +class MockWebSocket { + static instances: MockWebSocket[] = []; + + onclose: ((event: CloseEvent) => void) | null = null; + onerror: (() => void) | null = null; + onmessage: MockMessageHandler | null = null; + onopen: (() => void) | null = null; + send = vi.fn(); + + constructor(readonly url: string) { + MockWebSocket.instances.push(this); + } + + close() { + this.onclose?.({ code: 1000, reason: "test" } as CloseEvent); + } + + emit(data: unknown) { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); + } +} + +describe("WebSocketClient envelope subscriptions", () => { + beforeEach(() => { + MockWebSocket.instances = []; + vi.stubGlobal("WebSocket", MockWebSocket); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("notifies envelope subscribers for response and notification frames", () => { + const client = new WebSocketClient("ws://example.test/socket", undefined, { + enabled: false, + }); + const seen: string[] = []; + + const unsubscribe = client.onEnvelope((message) => { + seen.push(message.action); + }); + client.connect(); + const socket = MockWebSocket.instances[0]; + + socket.emit({ + id: "response-1", + type: "response", + action: "session.subscribe", + payload: { session_id: "session-1" }, + }); + socket.emit({ + type: "notification", + action: "task.updated", + payload: { + task_id: "task-1", + workflow_id: "workflow-1", + workflow_step_id: "step-1", + title: "Updated", + is_ephemeral: false, + }, + }); + + unsubscribe(); + socket.emit({ + type: "notification", + action: "workspace.updated", + payload: { id: "workspace-1", name: "Workspace" }, + }); + + expect(seen).toEqual(["session.subscribe", "task.updated"]); + }); +}); diff --git a/apps/web/lib/ws/client.ts b/apps/web/lib/ws/client.ts index ccda042cc1..935f249dc9 100644 --- a/apps/web/lib/ws/client.ts +++ b/apps/web/lib/ws/client.ts @@ -2,9 +2,12 @@ import type { BackendMessageMap, BackendMessageType } from "@/lib/types/backend" import type { ConnectionStatus } from "@/lib/types/connection"; import { generateUUID } from "@/lib/utils"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { installWsAccountGlobalsForE2E, recordParsedWsEnvelope } from "@/lib/ws/ws-account"; const debugDispatch = createDebugLogger("ws:dispatch"); +installWsAccountGlobalsForE2E(); + // High-frequency notification types we skip in the dispatch log to avoid // drowning the console during agent streams. Filter [ws:dispatch] to see // everything else; if you need the streaming traffic, comment this out. @@ -17,6 +20,7 @@ const DISPATCH_LOG_DENYLIST = new Set([ ]); type MessageHandler = (message: BackendMessageMap[T]) => void; +export type EnvelopeHandler = (message: BackendMessageMap[BackendMessageType]) => void; // Internal alias kept for readability — the WS client emits the same vocabulary // the UI consumes. `disconnected` covers both "never connected" (formerly @@ -43,6 +47,7 @@ export class WebSocketClient { private socket: WebSocket | null = null; private status: WebSocketStatus = "disconnected"; private handlers = new Map>>(); + private envelopeHandlers = new Set(); private pendingRequests = new Map< string, { @@ -406,6 +411,13 @@ export class WebSocketClient { } } + onEnvelope(handler: EnvelopeHandler) { + this.envelopeHandlers.add(handler); + return () => { + this.envelopeHandlers.delete(handler); + }; + } + private debugNotification(action: BackendMessageType, payload: unknown, handlerCount: number) { if (!isDebug() || DISPATCH_LOG_DENYLIST.has(action)) return; const payloadSessionId = (payload as { session_id?: string } | undefined)?.session_id; @@ -417,6 +429,9 @@ export class WebSocketClient { } private handleParsedMessage(message: BackendMessageMap[BackendMessageType]) { + recordParsedWsEnvelope(message); + this.notifyEnvelopeHandlers(message); + const msgWithId = message as { id?: string; type: string }; if (msgWithId.type === "response" && msgWithId.id) { @@ -440,6 +455,16 @@ export class WebSocketClient { } } + private notifyEnvelopeHandlers(message: BackendMessageMap[BackendMessageType]) { + this.envelopeHandlers.forEach((handler) => { + try { + handler(message); + } catch (error) { + console.warn("WebSocket envelope listener failed", error); + } + }); + } + private resolvePendingRequest(msgId: string, payload: unknown) { const pending = this.pendingRequests.get(msgId); if (!pending) return; diff --git a/apps/web/lib/ws/connection.test.ts b/apps/web/lib/ws/connection.test.ts new file mode 100644 index 0000000000..a606fb3a35 --- /dev/null +++ b/apps/web/lib/ws/connection.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { WebSocketClient } from "./client"; +import { getWebSocketClient, setWebSocketClient, subscribeWebSocketClient } from "./connection"; + +function fakeClient(id: string): WebSocketClient { + return { id } as unknown as WebSocketClient; +} + +describe("WebSocket connection registry", () => { + beforeEach(() => { + setWebSocketClient(null); + }); + + it("notifies subscribers immediately and when the active client changes", () => { + const first = fakeClient("first"); + const second = fakeClient("second"); + const listener = vi.fn(); + + setWebSocketClient(first); + const unsubscribe = subscribeWebSocketClient(listener); + setWebSocketClient(second); + setWebSocketClient(null); + + expect(listener).toHaveBeenNthCalledWith(1, first); + expect(listener).toHaveBeenNthCalledWith(2, second); + expect(listener).toHaveBeenNthCalledWith(3, null); + expect(getWebSocketClient()).toBeNull(); + + unsubscribe(); + }); + + it("stops notifying after unsubscribe", () => { + const listener = vi.fn(); + const unsubscribe = subscribeWebSocketClient(listener); + + unsubscribe(); + setWebSocketClient(fakeClient("next")); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith(null); + }); +}); diff --git a/apps/web/lib/ws/connection.ts b/apps/web/lib/ws/connection.ts index 533716b41a..272450b75a 100644 --- a/apps/web/lib/ws/connection.ts +++ b/apps/web/lib/ws/connection.ts @@ -1,11 +1,26 @@ import type { WebSocketClient } from "@/lib/ws/client"; +export type WebSocketClientListener = (client: WebSocketClient | null) => void; + let activeClient: WebSocketClient | null = null; +const listeners = new Set(); export function setWebSocketClient(client: WebSocketClient | null) { + if (activeClient === client) return; activeClient = client; + for (const listener of Array.from(listeners)) { + listener(activeClient); + } } export function getWebSocketClient() { return activeClient; } + +export function subscribeWebSocketClient(listener: WebSocketClientListener) { + listeners.add(listener); + listener(activeClient); + return () => { + listeners.delete(listener); + }; +} diff --git a/apps/web/lib/ws/handlers/agent-capabilities.ts b/apps/web/lib/ws/handlers/agent-capabilities.ts deleted file mode 100644 index 1ec152cb2a..0000000000 --- a/apps/web/lib/ws/handlers/agent-capabilities.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { AgentCapabilitiesPayload } from "@/lib/types/backend"; - -export function registerAgentCapabilitiesHandlers(store: StoreApi): WsHandlers { - return { - "session.agent_capabilities": (message) => { - const payload = message.payload as AgentCapabilitiesPayload | undefined; - if (!payload?.session_id) { - return; - } - store.getState().setAgentCapabilities(payload.session_id, { - supportsImage: payload.supports_image, - supportsAudio: payload.supports_audio, - supportsEmbeddedContext: payload.supports_embedded_context, - authMethods: (payload.auth_methods ?? []).map((m) => ({ - id: m.id, - name: m.name, - description: m.description, - terminalAuth: m.terminal_auth - ? { - command: m.terminal_auth.command, - args: m.terminal_auth.args, - label: m.terminal_auth.label, - } - : undefined, - meta: m.meta, - })), - }); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/agent-session-diagnostics.ts b/apps/web/lib/ws/handlers/agent-session-diagnostics.ts deleted file mode 100644 index 0448ba1d53..0000000000 --- a/apps/web/lib/ws/handlers/agent-session-diagnostics.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { createDebugLogger, isDebug } from "@/lib/debug/log"; -import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; -import type { AppState } from "@/lib/state/store"; -import type { TaskSessionState } from "@/lib/types/http"; - -type KanbanTask = KanbanState["tasks"][number]; - -const lifecycleDebug = createDebugLogger("task-lifecycle:ws"); - -function findSnapshotTask( - snapshots: Record, - workflowId: string, - taskId: string, - sessionId: string, -): KanbanTask | undefined { - return snapshots[workflowId]?.tasks.find( - (task) => task.id === taskId && task.primarySessionId === sessionId, - ); -} - -function didPatchTaskPrimaryState( - beforeTask: KanbanTask | undefined, - afterTask: KanbanTask | undefined, - newState: TaskSessionState, -): boolean { - return ( - beforeTask?.primarySessionState !== newState && afterTask?.primarySessionState === newState - ); -} - -export function buildKanbanPrimarySessionSyncLog(params: { - beforeState: AppState; - afterState: AppState; - taskId: string; - sessionId: string; - newState: TaskSessionState; -}): { - beforeTask: KanbanTask | undefined; - patchedKanban: boolean; - patchedSnapshotIds: string[]; -} { - const beforeTask = - params.beforeState.kanban.tasks.find((task) => task.id === params.taskId) ?? - Object.values(params.beforeState.kanbanMulti.snapshots) - .flatMap((snapshot) => snapshot.tasks) - .find((task) => task.id === params.taskId); - const beforeKanbanTask = params.beforeState.kanban.tasks.find( - (task) => task.id === params.taskId && task.primarySessionId === params.sessionId, - ); - const afterKanbanTask = params.afterState.kanban.tasks.find( - (task) => task.id === params.taskId && task.primarySessionId === params.sessionId, - ); - const patchedSnapshotIds = Object.keys(params.afterState.kanbanMulti.snapshots).filter( - (workflowId) => { - const beforeSnapshotTask = findSnapshotTask( - params.beforeState.kanbanMulti.snapshots, - workflowId, - params.taskId, - params.sessionId, - ); - const afterSnapshotTask = findSnapshotTask( - params.afterState.kanbanMulti.snapshots, - workflowId, - params.taskId, - params.sessionId, - ); - return didPatchTaskPrimaryState(beforeSnapshotTask, afterSnapshotTask, params.newState); - }, - ); - return { - beforeTask, - patchedKanban: didPatchTaskPrimaryState(beforeKanbanTask, afterKanbanTask, params.newState), - patchedSnapshotIds, - }; -} - -export function logKanbanPrimarySessionSync(params: { - taskId: string; - sessionId: string; - newState: TaskSessionState; - beforeTask: KanbanTask | undefined; - patchedKanban: boolean; - patchedSnapshotIds: string[]; -}): void { - if (!isDebug()) return; - lifecycleDebug("session.state_changed kanban sync", { - task_id: params.taskId, - sessionId: params.sessionId, - newState: params.newState, - beforeTaskPrimarySessionId: params.beforeTask?.primarySessionId ?? "-", - beforeTaskPrimaryState: params.beforeTask?.primarySessionState ?? "-", - patchedKanban: params.patchedKanban, - patchedSnapshots: params.patchedSnapshotIds.join(",") || "-", - }); -} diff --git a/apps/web/lib/ws/handlers/agent-session-kanban-sync.ts b/apps/web/lib/ws/handlers/agent-session-kanban-sync.ts deleted file mode 100644 index 3a2d0e08b5..0000000000 --- a/apps/web/lib/ws/handlers/agent-session-kanban-sync.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { StoreApi } from "zustand"; -import { isDebug } from "@/lib/debug/log"; -import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; -import type { AppState } from "@/lib/state/store"; -import type { SessionId, TaskId, TaskSessionState } from "@/lib/types/http"; -import { - buildKanbanPrimarySessionSyncLog, - logKanbanPrimarySessionSync, -} from "@/lib/ws/handlers/agent-session-diagnostics"; - -function snapshotKanbanStateForLog(state: AppState): AppState { - return { - ...state, - kanban: { ...state.kanban }, - kanbanMulti: { - ...state.kanbanMulti, - snapshots: { ...state.kanbanMulti.snapshots }, - }, - }; -} - -function patchTaskPrimarySessionState( - tasks: KanbanState["tasks"], - taskId: string, - sessionId: string, - newState: TaskSessionState, -): KanbanState["tasks"] { - let changed = false; - const nextTasks = tasks.map((task) => { - if (task.id !== taskId || task.primarySessionId !== sessionId) return task; - const nextPendingAction = - newState === "WAITING_FOR_INPUT" ? task.primarySessionPendingAction : undefined; - if ( - task.primarySessionState === newState && - task.primarySessionPendingAction === nextPendingAction - ) { - return task; - } - changed = true; - return { - ...task, - primarySessionState: newState, - primarySessionPendingAction: nextPendingAction, - }; - }); - return changed ? nextTasks : tasks; -} - -function patchSnapshotPrimarySessionState( - snapshot: WorkflowSnapshotData, - taskId: string, - sessionId: string, - newState: TaskSessionState, -): WorkflowSnapshotData { - const tasks = patchTaskPrimarySessionState(snapshot.tasks, taskId, sessionId, newState); - return tasks === snapshot.tasks ? snapshot : { ...snapshot, tasks }; -} - -function patchWorkflowSnapshotPrimarySessionStates( - snapshots: Record, - taskId: string, - sessionId: string, - newState: TaskSessionState, -): { - nextSnapshots: Record; - snapshotsChanged: boolean; -} { - let snapshotsChanged = false; - const nextSnapshots = Object.fromEntries( - Object.entries(snapshots).map(([workflowId, snapshot]) => { - const nextSnapshot = patchSnapshotPrimarySessionState(snapshot, taskId, sessionId, newState); - if (nextSnapshot !== snapshot) snapshotsChanged = true; - return [workflowId, nextSnapshot]; - }), - ); - return { nextSnapshots, snapshotsChanged }; -} - -export function syncKanbanPrimarySessionState( - store: StoreApi, - taskId: TaskId, - sessionId: SessionId, - newState: TaskSessionState | undefined, -): void { - if (!newState) return; - - const state = store.getState(); - if (!state.kanban?.tasks || !state.kanbanMulti?.snapshots) return; - - const debugMode = isDebug(); - const beforeState = debugMode ? snapshotKanbanStateForLog(state) : state; - - store.setState((currentState) => { - if (!currentState.kanban?.tasks || !currentState.kanbanMulti?.snapshots) return currentState; - const nextKanbanTasks = patchTaskPrimarySessionState( - currentState.kanban.tasks, - taskId, - sessionId, - newState, - ); - const { nextSnapshots, snapshotsChanged } = patchWorkflowSnapshotPrimarySessionStates( - currentState.kanbanMulti.snapshots, - taskId, - sessionId, - newState, - ); - - if (nextKanbanTasks === currentState.kanban.tasks && !snapshotsChanged) return currentState; - - return { - ...currentState, - kanban: - nextKanbanTasks === currentState.kanban.tasks - ? currentState.kanban - : { ...currentState.kanban, tasks: nextKanbanTasks }, - kanbanMulti: snapshotsChanged - ? { - ...currentState.kanbanMulti, - snapshots: nextSnapshots, - } - : currentState.kanbanMulti, - }; - }); - - if (!debugMode) return; - const syncLog = buildKanbanPrimarySessionSyncLog({ - beforeState, - afterState: store.getState(), - taskId, - sessionId, - newState, - }); - logKanbanPrimarySessionSync({ - taskId, - sessionId, - newState, - beforeTask: syncLog.beforeTask, - patchedKanban: syncLog.patchedKanban, - patchedSnapshotIds: syncLog.patchedSnapshotIds, - }); -} diff --git a/apps/web/lib/ws/handlers/agent-session-kanban.test.ts b/apps/web/lib/ws/handlers/agent-session-kanban.test.ts index ef03021eaa..c48d72f9ba 100644 --- a/apps/web/lib/ws/handlers/agent-session-kanban.test.ts +++ b/apps/web/lib/ws/handlers/agent-session-kanban.test.ts @@ -3,18 +3,12 @@ import type { StoreApi } from "zustand"; import { registerTaskSessionHandlers } from "./agent-session"; import type { AppState } from "@/lib/state/store"; import type { TaskSessionStateChangedPayload } from "@/lib/types/backend"; -import { sessionId, taskId, type TaskSessionState } from "@/lib/types/http"; +import { sessionId, taskId } from "@/lib/types/http"; const STATE_CHANGED_EVENT = "session.state_changed"; -const TASK_TITLE = "Running task"; -function makeStore( - overrides: Partial = {}, - beforeApplyState?: (state: AppState) => void, -) { +function makeStore(overrides: Partial = {}) { const state = { - kanban: { workflowId: null, steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, tasks: { activeTaskId: null, activeSessionId: null, @@ -31,7 +25,6 @@ function makeStore( ...overrides, } as unknown as AppState; const setState = vi.fn((updater: unknown) => { - beforeApplyState?.(state); const next = typeof updater === "function" ? updater(state) : updater; if (next && next !== state) Object.assign(state, next); }); @@ -53,189 +46,8 @@ function makeMessage(payload: TaskSessionStateChangedPayload) { }; } -function makeKanbanTask(primarySessionId: string) { - return { - id: "t-1", - workflowStepId: "step-1", - title: TASK_TITLE, - position: 0, - primarySessionId, - primarySessionState: "WAITING_FOR_INPUT", - }; -} - -function makeTaskSession(id: string) { - return { - id: sessionId(id), - task_id: taskId("t-1"), - state: "WAITING_FOR_INPUT" as TaskSessionState, - started_at: "", - updated_at: "", - }; -} - -function makeSnapshotTaskStore(primarySessionId: string) { - const task = makeKanbanTask(primarySessionId); - return { - isLoading: false, - snapshots: { - "wf-1": { - workflowId: "wf-1", - workflowName: "Development", - steps: [], - tasks: [task], - }, - }, - }; -} - -describe("session.state_changed -> primary kanban card state", () => { - it("updates kanban card primary session state in workflow snapshots", () => { - const task = makeKanbanTask("s-1"); - const store = makeStore({ - kanban: { - workflowId: "wf-1", - steps: [], - tasks: [task], - }, - kanbanMulti: makeSnapshotTaskStore("s-1"), - taskSessions: { - items: { - "s-1": makeTaskSession("s-1"), - }, - }, - }); - const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; - - handler(makeMessage({ task_id: "t-1", session_id: "s-1", new_state: "RUNNING" })); - - expect(store.getState().kanban.tasks[0]?.primarySessionState).toBe("RUNNING"); - expect(store.getState().kanbanMulti.snapshots["wf-1"]?.tasks[0]?.primarySessionState).toBe( - "RUNNING", - ); - }); - - it("logs the previous primary state when the task only exists in a workflow snapshot", () => { - const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => undefined); - const store = makeStore({ - kanban: { - workflowId: "wf-other", - steps: [], - tasks: [], - }, - kanbanMulti: makeSnapshotTaskStore("s-1"), - taskSessions: { - items: { - "s-1": makeTaskSession("s-1"), - }, - }, - }); - const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; - - handler(makeMessage({ task_id: "t-1", session_id: "s-1", new_state: "RUNNING" })); - - expect( - debugSpy.mock.calls.some((call) => - String(call[0]).includes("beforeTaskPrimaryState=WAITING_FOR_INPUT"), - ), - ).toBe(true); - debugSpy.mockRestore(); - }); - - it("preserves concurrent store changes while syncing kanban session state", () => { - const task = makeKanbanTask("s-1"); - const store = makeStore( - { - kanban: { - workflowId: "wf-1", - steps: [], - tasks: [task], - }, - kanbanMulti: makeSnapshotTaskStore("s-1"), - taskSessions: { - items: { - "s-1": makeTaskSession("s-1"), - }, - }, - }, - (state) => { - state.tasks = { - ...state.tasks, - activeTaskId: taskId("concurrent-task"), - }; - }, - ); - const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; - - handler(makeMessage({ task_id: "t-1", session_id: "s-1", new_state: "RUNNING" })); - - expect(store.getState().kanban.tasks[0]?.primarySessionState).toBe("RUNNING"); - expect(store.getState().tasks.activeTaskId).toBe(taskId("concurrent-task")); - }); -}); - -describe("session.state_changed -> non-primary kanban card state", () => { - it("does not update kanban cards for non-primary session transitions", () => { - const store = makeStore({ - kanban: { - workflowId: "wf-1", - steps: [], - tasks: [ - { - id: "t-1", - workflowStepId: "step-1", - title: TASK_TITLE, - position: 0, - primarySessionId: "s-primary", - primarySessionState: "WAITING_FOR_INPUT", - }, - ], - }, - kanbanMulti: { - isLoading: false, - snapshots: { - "wf-1": { - workflowId: "wf-1", - workflowName: "Development", - steps: [], - tasks: [ - { - id: "t-1", - workflowStepId: "step-1", - title: TASK_TITLE, - position: 0, - primarySessionId: "s-primary", - primarySessionState: "WAITING_FOR_INPUT", - }, - ], - }, - }, - }, - taskSessions: { - items: { - "s-secondary": { - id: sessionId("s-secondary"), - task_id: taskId("t-1"), - state: "WAITING_FOR_INPUT", - started_at: "", - updated_at: "", - }, - }, - }, - }); - const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; - - handler(makeMessage({ task_id: "t-1", session_id: "s-secondary", new_state: "RUNNING" })); - - expect(store.getState().kanban.tasks[0]?.primarySessionState).toBe("WAITING_FOR_INPUT"); - expect(store.getState().kanbanMulti.snapshots["wf-1"]?.tasks[0]?.primarySessionState).toBe( - "WAITING_FOR_INPUT", - ); - }); -}); - -describe("session.state_changed -> kanban sync guards", () => { - it("does not write kanban state when the event has no new state", () => { +describe("session.state_changed legacy kanban mirrors", () => { + it("does not expose kanban or kanbanMulti; Query bridge owns primary card state", () => { const store = makeStore({ taskSessions: { items: { @@ -251,81 +63,9 @@ describe("session.state_changed -> kanban sync guards", () => { }); const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; - handler(makeMessage({ task_id: "t-1", session_id: "s-1" })); - - expect(store.setState).not.toHaveBeenCalled(); - }); - - it("keeps task arrays by reference when the primary state is already current", () => { - const kanbanTasks = [ - { - id: "t-1", - workflowStepId: "step-1", - title: TASK_TITLE, - position: 0, - primarySessionId: "s-1", - primarySessionState: "RUNNING", - }, - ]; - const snapshotTasks = [...kanbanTasks]; - const store = makeStore({ - kanban: { workflowId: "wf-1", steps: [], tasks: kanbanTasks }, - kanbanMulti: { - isLoading: false, - snapshots: { - "wf-1": { - workflowId: "wf-1", - workflowName: "Development", - steps: [], - tasks: snapshotTasks, - }, - }, - }, - taskSessions: { - items: { - "s-1": { - id: sessionId("s-1"), - task_id: taskId("t-1"), - state: "RUNNING", - started_at: "", - updated_at: "", - }, - }, - }, - }); - const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; - handler(makeMessage({ task_id: "t-1", session_id: "s-1", new_state: "RUNNING" })); - expect(store.getState().kanban.tasks).toBe(kanbanTasks); - expect(store.getState().kanbanMulti.snapshots["wf-1"]?.tasks).toBe(snapshotTasks); - }); - - it("does not write kanban state for stale session events", () => { - const store = makeStore({ - taskSessions: { - items: { - "s-1": { - id: sessionId("s-1"), - task_id: taskId("t-1"), - state: "WAITING_FOR_INPUT", - started_at: "", - updated_at: "2026-06-18T12:00:00.000Z", - }, - }, - }, - }); - const handler = registerTaskSessionHandlers(store)[STATE_CHANGED_EVENT]!; - - handler( - makeMessage({ - task_id: "t-1", - session_id: "s-1", - new_state: "RUNNING", - updated_at: "2026-06-18T11:00:00.000Z", - }), - ); - - expect(store.setState).not.toHaveBeenCalled(); + expect("kanban" in store.getState()).toBe(false); + expect("kanbanMulti" in store.getState()).toBe(false); }); }); diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts index 717bee7e16..70c3e98d48 100644 --- a/apps/web/lib/ws/handlers/agent-session.test.ts +++ b/apps/web/lib/ws/handlers/agent-session.test.ts @@ -653,9 +653,6 @@ describe("session.state_changed → agentctl ready fallback", () => { sessionAgentctl: { itemsBySessionId: {} }, setSessionAgentctlStatus: vi.fn(), upsertTaskSessionFromEvent, - setWorktree: vi.fn(), - sessionWorktreesBySessionId: { itemsBySessionId: {} }, - setSessionWorktrees: vi.fn(), }); const handler = registerTaskSessionHandlers(store)["session.agentctl_ready"]!; diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index e4b5ad8c8d..bdf17c1bea 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -9,8 +9,6 @@ import { type TaskId, type TaskSessionState, } from "@/lib/types/http"; -import type { QueuedMessage } from "@/lib/state/slices/session/types"; -import { syncKanbanPrimarySessionState } from "@/lib/ws/handlers/agent-session-kanban-sync"; const debug = createDebugLogger("session:state"); @@ -207,23 +205,6 @@ function upsertTaskSessionList( }); } -// Fan out the office refetch trigger only when the session's state -// actually changed. The WS layer fires `session.state_changed` for -// several adjacent reasons (agentctl status, context window, model -// updates) where `new_state` is undefined or unchanged; without this -// gate every one of those storms the dashboard-card re-render path. -function maybeFanOutOfficeRefetch( - store: StoreApi, - newState: TaskSessionState | undefined, - prevState: TaskSessionState | undefined, -): void { - if (!newState || newState === prevState) return; - const setOfficeTrigger = store.getState().setOfficeRefetchTrigger; - if (!setOfficeTrigger) return; - setOfficeTrigger("dashboard"); - setOfficeTrigger("agents"); -} - /** Extract context window data from payload metadata and store it. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function extractContextWindow(store: StoreApi, sessionId: string, payload: any): void { @@ -356,28 +337,6 @@ function buildAgentctlReadySessionUpdate( return update; } -/** Adds the materialized worktree to the worktrees map + the per-session list. */ -function recordAgentctlReadyWorktree( - store: StoreApi, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - payload: any, - existingSession: { repository_id?: string; worktree_path?: string; worktree_branch?: string }, -): void { - if (!payload.worktree_id) return; - store.getState().setWorktree({ - id: payload.worktree_id, - sessionId: payload.session_id, - repositoryId: existingSession.repository_id ?? undefined, - path: payload.worktree_path ?? existingSession.worktree_path ?? undefined, - branch: payload.worktree_branch ?? existingSession.worktree_branch ?? undefined, - }); - const existing = - store.getState().sessionWorktreesBySessionId.itemsBySessionId[payload.session_id] ?? []; - if (!existing.includes(payload.worktree_id)) { - store.getState().setSessionWorktrees(payload.session_id, [...existing, payload.worktree_id]); - } -} - /** Handle the agentctl_ready event: update session worktree info. * * Two shapes share this event: @@ -407,8 +366,6 @@ function handleAgentctlReady(store: StoreApi, payload: any): void { store.getState().setTaskSession({ ...existingSession, ...sessionUpdate }); } - recordAgentctlReadyWorktree(store, payload, existingSession); - if (isSibling) { // Drop the pre-multi-repo git-status snapshot — the backend just // transitioned this session from single-repo to multi-repo and the legacy @@ -455,18 +412,6 @@ function maybeNotifySessionFailure(store: StoreApi, ctx: SessionFailur export function registerTaskSessionHandlers(store: StoreApi): WsHandlers { return { - "message.queue.status_changed": (message) => { - const payload = message.payload; - if (!payload?.session_id) { - console.warn("[Queue] Missing session_id in queue status change event"); - return; - } - const sessionId = payload.session_id; - const entries = (payload.entries as QueuedMessage[] | null | undefined) ?? []; - const count = typeof payload.count === "number" ? payload.count : entries.length; - const max = typeof payload.max === "number" ? payload.max : 0; - store.getState().setQueueEntries(sessionId, entries, { count, max }); - }, "session.state_changed": (message) => { const payload = message.payload; if (!payload?.task_id) return; @@ -503,7 +448,6 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle }); upsertTaskSessionList(store, taskId, sessionId, payload, sessionUpdate); - syncKanbanPrimarySessionState(store, taskId, sessionId, newState); extractContextWindow(store, sessionId, payload); maybePromoteAgentctlReady(store, sessionId, newState, message.timestamp); @@ -523,8 +467,6 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle payload, previousState: existingSession?.state, }); - - maybeFanOutOfficeRefetch(store, newState, existingSession?.state); }, "session.agentctl_starting": (message) => { const payload = message.payload; diff --git a/apps/web/lib/ws/handlers/agents.ts b/apps/web/lib/ws/handlers/agents.ts deleted file mode 100644 index 6275e9b82c..0000000000 --- a/apps/web/lib/ws/handlers/agents.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; -import { normalizeAgentProfile } from "@/lib/api/domains/agent-profile-normalize"; -import type { AgentProfile } from "@/lib/types/agent-profile"; - -function buildProfileEntry(profile: unknown): AgentProfile { - return normalizeAgentProfile(profile); -} - -function getAgentId(raw: unknown): string { - const obj = (raw ?? {}) as Record; - const value = obj.agentId ?? obj.agent_id; - return typeof value === "string" ? value : ""; -} - -function handleProfileCreated(state: AppState, profile: unknown): Partial { - const normalized = normalizeAgentProfile(profile); - const agentId = getAgentId(profile); - const agent = state.settingsAgents.items.find((a) => a.id === agentId); - const agentStub = { id: agentId, name: agent?.name ?? "" }; - const nextProfiles = [ - ...state.agentProfiles.items.filter((p) => p.id !== normalized.id), - toAgentProfileOption(agentStub, normalized), - ]; - const nextAgents = state.settingsAgents.items.map((item) => - item.id === agentId - ? { - ...item, - profiles: [ - ...item.profiles.filter((p) => p.id !== normalized.id), - buildProfileEntry(profile), - ], - } - : item, - ); - return { - agentProfiles: { ...state.agentProfiles, items: nextProfiles }, - settingsAgents: { items: nextAgents }, - }; -} - -function handleProfileUpdated(state: AppState, profile: unknown): Partial { - const normalized = normalizeAgentProfile(profile); - const agentId = getAgentId(profile); - const agent = state.settingsAgents.items.find((a) => a.id === agentId); - const agentStub = { id: agentId, name: agent?.name ?? "" }; - const nextProfiles = state.agentProfiles.items.map((p) => - p.id === normalized.id ? toAgentProfileOption(agentStub, normalized) : p, - ); - const nextAgents = state.settingsAgents.items.map((item) => - item.id === agentId - ? { - ...item, - profiles: item.profiles.map((p) => (p.id === normalized.id ? normalized : p)), - } - : item, - ); - return { - agentProfiles: { ...state.agentProfiles, items: nextProfiles }, - settingsAgents: { items: nextAgents }, - }; -} - -export function registerAgentsHandlers(store: StoreApi): WsHandlers { - return { - "agent.available.updated": (message) => { - store.setState((state) => ({ - ...state, - availableAgents: { - items: message.payload.agents ?? [], - tools: message.payload.tools ?? state.availableAgents.tools, - loaded: true, - loading: false, - }, - })); - }, - "agent.install.started": (message) => { - // Payload is the full job snapshot (queued → running transitions both emit this). - store.getState().upsertInstallJob(message.payload); - }, - "agent.install.output": (message) => { - const { agent_name, chunk } = message.payload as { - agent_name: string; - chunk: string; - }; - store.getState().appendInstallOutput(agent_name, chunk); - }, - "agent.install.finished": (message) => { - store.getState().upsertInstallJob(message.payload); - }, - "agent.updated": (message) => { - store.setState((state) => ({ - ...state, - agents: { - agents: state.agents.agents.some((a) => a.id === message.payload.agentId) - ? state.agents.agents.map((a) => - a.id === message.payload.agentId ? { ...a, status: message.payload.status } : a, - ) - : [ - ...state.agents.agents, - { id: message.payload.agentId, status: message.payload.status }, - ], - }, - })); - }, - "agent.profile.created": (message) => { - store.setState((state) => ({ - ...state, - ...handleProfileCreated(state, message.payload.profile), - })); - }, - "agent.profile.updated": (message) => { - store.setState((state) => ({ - ...state, - ...handleProfileUpdated(state, message.payload.profile), - })); - }, - "agent.profile.deleted": (message) => { - const profile = message.payload.profile as Record; - const profileId = profile.id as string; - const agentId = getAgentId(profile); - store.setState((state) => ({ - ...state, - agentProfiles: { - ...state.agentProfiles, - items: state.agentProfiles.items.filter((p) => p.id !== profileId), - }, - settingsAgents: { - items: state.settingsAgents.items.map((item) => - item.id === agentId - ? { - ...item, - profiles: item.profiles.filter((p) => p.id !== profileId), - } - : item, - ), - }, - })); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/available-commands.ts b/apps/web/lib/ws/handlers/available-commands.ts deleted file mode 100644 index 1ed07a140e..0000000000 --- a/apps/web/lib/ws/handlers/available-commands.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { AvailableCommand } from "@/lib/state/slices/session-runtime/types"; - -export function registerAvailableCommandsHandlers(store: StoreApi): WsHandlers { - return { - "session.available_commands": (message) => { - const payload = message.payload; - if (!payload?.session_id) { - return; - } - const sessionId = payload.session_id as string; - const commands = (payload.available_commands || []) as AvailableCommand[]; - - store.getState().setAvailableCommands(sessionId, commands); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/diffs.ts b/apps/web/lib/ws/handlers/diffs.ts deleted file mode 100644 index d64017c148..0000000000 --- a/apps/web/lib/ws/handlers/diffs.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; - -export function registerDiffsHandlers(store: StoreApi): WsHandlers { - return { - "diff.update": (message) => { - store.setState((state) => ({ - ...state, - diffs: { - files: message.payload.files, - }, - })); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/empty-turn-notice.test.ts b/apps/web/lib/ws/handlers/empty-turn-notice.test.ts index de94163bc6..217894de5c 100644 --- a/apps/web/lib/ws/handlers/empty-turn-notice.test.ts +++ b/apps/web/lib/ws/handlers/empty-turn-notice.test.ts @@ -1,11 +1,14 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { parseSlashCommand, isKnownCommand, emptyTurnNoticeText, computeEmptyTurnNotice, + maybeEmitEmptyTurnNotice, type EmptyTurnNoticeInput, } from "./empty-turn-notice"; +import { getBrowserQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { AvailableCommand } from "@/lib/state/slices/session-runtime/types"; import type { Message } from "@/lib/types/http"; import { sessionId, taskId } from "@/lib/types/http"; @@ -180,3 +183,35 @@ describe("computeEmptyTurnNotice", () => { expect(computeEmptyTurnNotice(input)).toBeNull(); }); }); + +describe("maybeEmitEmptyTurnNotice", () => { + it("reads advertised slash commands from the TanStack Query cache", () => { + const queryClient = getBrowserQueryClient(); + queryClient.clear(); + queryClient.setQueryData(qk.sessionRuntime.availableCommands("sess-1"), [{ name: "pr-fixup" }]); + const addMessage = vi.fn(); + const store = { + getState: () => ({ + quickChat: { sessions: [] }, + configChat: { sessions: [] }, + messages: { bySession: { "sess-1": [userMessage("turn-1", "/pr-fixup")] } }, + addMessage, + }), + }; + + maybeEmitEmptyTurnNotice( + store as never, + { + id: "turn-1", + session_id: "sess-1", + task_id: "task-1", + had_output: false, + } as never, + "2026-05-30T00:00:01Z", + ); + + expect(addMessage).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining("ran but produced no output") }), + ); + }); +}); diff --git a/apps/web/lib/ws/handlers/empty-turn-notice.ts b/apps/web/lib/ws/handlers/empty-turn-notice.ts index 8099d7e620..0146b4126b 100644 --- a/apps/web/lib/ws/handlers/empty-turn-notice.ts +++ b/apps/web/lib/ws/handlers/empty-turn-notice.ts @@ -1,4 +1,6 @@ import type { StoreApi } from "zustand"; +import { getBrowserQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import type { AppState } from "@/lib/state/store"; import type { AvailableCommand } from "@/lib/state/slices/session-runtime/types"; import type { TurnEventPayload } from "@/lib/types/backend"; @@ -141,7 +143,9 @@ export function maybeEmitEmptyTurnNotice( hadOutput: payload.had_output, isEphemeralSurface, messages: state.messages.bySession[sid], - availableCommands: state.availableCommands.bySessionId[sid], + availableCommands: getBrowserQueryClient().getQueryData( + qk.sessionRuntime.availableCommands(sid), + ), now, }); diff --git a/apps/web/lib/ws/handlers/executor-profiles.ts b/apps/web/lib/ws/handlers/executor-profiles.ts deleted file mode 100644 index 843ce551e9..0000000000 --- a/apps/web/lib/ws/handlers/executor-profiles.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { ExecutorProfilePayload } from "@/lib/types/backend"; -import type { ExecutorProfile } from "@/lib/types/http"; - -function toProfile(payload: ExecutorProfilePayload): ExecutorProfile { - return { - id: payload.id, - executor_id: payload.executor_id, - name: payload.name, - mcp_policy: payload.mcp_policy, - config: payload.config, - prepare_script: payload.prepare_script, - cleanup_script: payload.cleanup_script, - created_at: payload.created_at ?? new Date().toISOString(), - updated_at: payload.updated_at ?? new Date().toISOString(), - }; -} - -export function registerExecutorProfileHandlers(store: StoreApi): WsHandlers { - return { - "executor.profile.created": (message) => { - const profile = toProfile(message.payload as ExecutorProfilePayload); - store.setState((state) => ({ - ...state, - executors: { - items: state.executors.items.map((exec) => - exec.id === profile.executor_id - ? { ...exec, profiles: [...(exec.profiles ?? []), profile] } - : exec, - ), - }, - })); - }, - "executor.profile.updated": (message) => { - const profile = toProfile(message.payload as ExecutorProfilePayload); - store.setState((state) => ({ - ...state, - executors: { - items: state.executors.items.map((exec) => - exec.id === profile.executor_id - ? { - ...exec, - profiles: (exec.profiles ?? []).map((p) => (p.id === profile.id ? profile : p)), - } - : exec, - ), - }, - })); - }, - "executor.profile.deleted": (message) => { - const { id } = message.payload as { id: string }; - store.setState((state) => ({ - ...state, - executors: { - items: state.executors.items.map((exec) => ({ - ...exec, - profiles: (exec.profiles ?? []).filter((p) => p.id !== id), - })), - }, - })); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/executors.ts b/apps/web/lib/ws/handlers/executors.ts deleted file mode 100644 index da84aef80a..0000000000 --- a/apps/web/lib/ws/handlers/executors.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { Executor } from "@/lib/types/http"; - -export function registerExecutorsHandlers(store: StoreApi): WsHandlers { - return { - "executor.created": (message) => { - const payload = message.payload; - const executor: Executor = { - id: payload.id, - name: payload.name, - type: payload.type, - status: payload.status, - is_system: payload.is_system, - config: payload.config, - created_at: payload.created_at ?? new Date().toISOString(), - updated_at: payload.updated_at ?? new Date().toISOString(), - }; - store.setState((state) => ({ - ...state, - executors: { - items: [...state.executors.items.filter((item) => item.id !== executor.id), executor], - }, - })); - }, - "executor.updated": (message) => { - store.setState((state) => ({ - ...state, - executors: { - items: state.executors.items.map((item) => - item.id === message.payload.id ? { ...item, ...message.payload } : item, - ), - }, - })); - }, - "executor.deleted": (message) => { - store.setState((state) => ({ - ...state, - executors: { - items: state.executors.items.filter((item) => item.id !== message.payload.id), - }, - })); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/github.ts b/apps/web/lib/ws/handlers/github.ts deleted file mode 100644 index bf5ee3204c..0000000000 --- a/apps/web/lib/ws/handlers/github.ts +++ /dev/null @@ -1,27 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { GitHubRateLimitUpdate, TaskCIAutomationOptions, TaskPR } from "@/lib/types/github"; - -export function registerGitHubHandlers(store: StoreApi): WsHandlers { - return { - "github.task_pr.updated": (message) => { - const pr = message.payload as TaskPR; - if (pr.task_id) { - store.getState().setTaskPR(pr.task_id, pr); - } - }, - "github.task_ci_options.updated": (message) => { - const options = message.payload as TaskCIAutomationOptions; - if (options.task_id) { - store.getState().setTaskCIAutomationOptions(options.task_id, options); - } - }, - "github.rate_limit.updated": (message) => { - const update = message.payload as GitHubRateLimitUpdate; - if (update?.snapshots?.length) { - store.getState().applyGitHubRateLimitUpdate(update); - } - }, - }; -} diff --git a/apps/web/lib/ws/handlers/kanban.test.ts b/apps/web/lib/ws/handlers/kanban.test.ts deleted file mode 100644 index 94b5f390ab..0000000000 --- a/apps/web/lib/ws/handlers/kanban.test.ts +++ /dev/null @@ -1,388 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import { registerKanbanHandlers } from "./kanban"; - -const WORKFLOW_ID = "wf1"; -const TASK_ID = "t1"; -const STEP_ID = "s1"; -const TASK_TITLE = "T1"; -const UPDATED_TITLE = "T1 updated"; -const REPO_A_ID = "repo-a"; -const REPO_B_ID = "repo-b"; -const REPO_LINK_A_ID = "link-a"; -const FEATURE_BRANCH = "feature/x"; - -function makeStore(initial: Partial = {}) { - let state = { - kanban: { workflowId: null, steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - ...initial, - } as unknown as AppState; - - return { - getState: () => state, - setState: (updater: AppState | ((s: AppState) => AppState)) => { - state = - typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; - }, - subscribe: () => () => {}, - destroy: () => {}, - getInitialState: () => state, - } as unknown as StoreApi; -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function makeUpdateMessage(workflowId: string, tasks: unknown[], steps: unknown[] = []): any { - return { - id: "msg-1", - type: "notification", - action: "kanban.update", - payload: { workflowId, tasks, steps }, - }; -} - -describe("kanban.update handler — primarySessionId preservation", () => { - it("preserves workflow step WIP fields", () => { - const store = makeStore(); - const handler = registerKanbanHandlers(store)["kanban.update"]!; - - handler( - makeUpdateMessage( - WORKFLOW_ID, - [], - [ - { - id: STEP_ID, - title: "Review", - position: 1, - color: "bg-blue-500", - wip_limit: 2, - pull_from_step_id: "step-0", - }, - ], - ), - ); - - expect(store.getState().kanban.steps[0]).toMatchObject({ - wip_limit: 2, - pull_from_step_id: "step-0", - }); - }); - - it("preserves primarySessionId from existing tasks", () => { - const store = makeStore({ - kanban: { - workflowId: WORKFLOW_ID, - steps: [], - tasks: [ - { - id: TASK_ID, - workflowStepId: STEP_ID, - title: TASK_TITLE, - position: 0, - primarySessionId: "sess-primary", - }, - ], - }, - } as Partial); - - const handler = registerKanbanHandlers(store)["kanban.update"]!; - handler( - makeUpdateMessage(WORKFLOW_ID, [ - { - id: TASK_ID, - workflowStepId: STEP_ID, - title: UPDATED_TITLE, - position: 0, - state: "IN_PROGRESS", - }, - ]), - ); - - const task = store.getState().kanban.tasks.find((t) => t.id === TASK_ID); - expect(task?.primarySessionId).toBe("sess-primary"); - expect(task?.title).toBe(UPDATED_TITLE); - }); - - it("preserves primarySessionState from existing tasks", () => { - const store = makeStore({ - kanban: { - workflowId: WORKFLOW_ID, - steps: [], - tasks: [ - { - id: TASK_ID, - workflowStepId: STEP_ID, - title: TASK_TITLE, - position: 0, - primarySessionId: "sess-primary", - primarySessionState: "RUNNING", - }, - ], - }, - } as Partial); - - const handler = registerKanbanHandlers(store)["kanban.update"]!; - handler( - makeUpdateMessage(WORKFLOW_ID, [ - { id: TASK_ID, workflowStepId: STEP_ID, title: TASK_TITLE, position: 0 }, - ]), - ); - - const task = store.getState().kanban.tasks.find((t) => t.id === TASK_ID); - expect(task?.primarySessionState).toBe("RUNNING"); - }); - - it("new tasks start with undefined primarySessionId", () => { - const store = makeStore({ - kanban: { workflowId: WORKFLOW_ID, steps: [], tasks: [] }, - } as Partial); - - const handler = registerKanbanHandlers(store)["kanban.update"]!; - handler( - makeUpdateMessage(WORKFLOW_ID, [ - { id: "t-new", workflowStepId: STEP_ID, title: "New Task", position: 0 }, - ]), - ); - - const task = store.getState().kanban.tasks.find((t) => t.id === "t-new"); - expect(task).toBeDefined(); - expect(task?.primarySessionId).toBeUndefined(); - }); -}); - -describe("kanban.update handler — repository preservation", () => { - it("preserves existing repositories when kanban.update omits repo metadata", () => { - const store = makeStore({ - kanban: { - workflowId: WORKFLOW_ID, - steps: [], - tasks: [ - { - id: TASK_ID, - workflowStepId: STEP_ID, - title: TASK_TITLE, - position: 0, - repositoryId: REPO_A_ID, - repositories: [ - { - id: REPO_LINK_A_ID, - repository_id: REPO_A_ID, - base_branch: "main", - checkout_branch: FEATURE_BRANCH, - position: 0, - }, - ], - }, - ], - }, - } as Partial); - - const handler = registerKanbanHandlers(store)["kanban.update"]!; - handler( - makeUpdateMessage(WORKFLOW_ID, [ - { - id: TASK_ID, - workflowStepId: STEP_ID, - title: UPDATED_TITLE, - position: 0, - state: "CREATED", - }, - ]), - ); - - const task = store.getState().kanban.tasks.find((t) => t.id === TASK_ID); - expect(task?.repositoryId).toBe(REPO_A_ID); - expect(task?.repositories).toEqual([ - { - id: REPO_LINK_A_ID, - repository_id: REPO_A_ID, - base_branch: "main", - checkout_branch: FEATURE_BRANCH, - position: 0, - }, - ]); - }); -}); - -describe("kanban.update handler — repository switch", () => { - it("does not restore stale snapshot repositories when repository_id changes", () => { - const repoA = [ - { - id: REPO_LINK_A_ID, - repository_id: REPO_A_ID, - base_branch: "main", - checkout_branch: FEATURE_BRANCH, - position: 0, - }, - ]; - const store = makeStore({ - kanban: { - workflowId: WORKFLOW_ID, - steps: [], - tasks: [ - { - id: TASK_ID, - workflowStepId: STEP_ID, - title: TASK_TITLE, - position: 0, - repositoryId: REPO_A_ID, - repositories: repoA, - }, - ], - }, - kanbanMulti: { - isLoading: false, - snapshots: { - [WORKFLOW_ID]: { - workflowId: WORKFLOW_ID, - workflowName: "WF1", - steps: [], - tasks: [ - { - id: TASK_ID, - workflowStepId: STEP_ID, - title: TASK_TITLE, - position: 0, - repositoryId: REPO_A_ID, - repositories: repoA, - }, - ], - }, - }, - }, - } as Partial); - - const handler = registerKanbanHandlers(store)["kanban.update"]!; - handler( - makeUpdateMessage(WORKFLOW_ID, [ - { - id: TASK_ID, - workflowStepId: STEP_ID, - title: UPDATED_TITLE, - position: 0, - repository_id: REPO_B_ID, - }, - ]), - ); - - const snapshotTask = store - .getState() - .kanbanMulti.snapshots[WORKFLOW_ID]?.tasks.find((t) => t.id === TASK_ID); - expect(snapshotTask?.repositoryId).toBe(REPO_B_ID); - expect(snapshotTask?.repositories).toBeUndefined(); - }); -}); - -describe("kanban.update handler — explicit-null primary preservation", () => { - it("does not restore stale snapshot value when primarySessionId is explicitly cleared", () => { - // Repro for the multi-snapshot null-preservation bug: when task.updated - // clears primarySessionId to null in kanban.tasks, the multi-snapshot must - // accept the null rather than fall back to a stale value. - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [ - { - id: "t1", - workflowStepId: "s1", - title: "T1", - position: 0, - primarySessionId: null, - }, - ], - }, - kanbanMulti: { - isLoading: false, - snapshots: { - wf1: { - workflowId: "wf1", - workflowName: "WF1", - steps: [], - tasks: [ - { - id: "t1", - workflowStepId: "s1", - title: "T1", - position: 0, - primarySessionId: "stale-session", - }, - ], - }, - }, - }, - } as Partial); - - const handler = registerKanbanHandlers(store)["kanban.update"]!; - handler( - makeUpdateMessage("wf1", [{ id: "t1", workflowStepId: "s1", title: "T1", position: 0 }]), - ); - - const snapshot = store.getState().kanbanMulti.snapshots["wf1"]; - const task = snapshot?.tasks.find((t) => t.id === "t1"); - expect(task?.primarySessionId).toBeNull(); - }); -}); - -describe("kanban.update handler — multi-snapshot primary lookup", () => { - it("preserves primarySessionId in kanbanMulti snapshot", () => { - const store = makeStore({ - kanban: { workflowId: "wf1", steps: [], tasks: [] }, - kanbanMulti: { - isLoading: false, - snapshots: { - wf1: { - workflowId: "wf1", - workflowName: "WF1", - steps: [], - tasks: [ - { - id: "t1", - workflowStepId: "s1", - title: "T1", - position: 0, - primarySessionId: "sess-multi-primary", - }, - ], - }, - }, - }, - } as Partial); - - const handler = registerKanbanHandlers(store)["kanban.update"]!; - handler( - makeUpdateMessage("wf1", [{ id: "t1", workflowStepId: "s1", title: "T1", position: 0 }]), - ); - - const snapshot = store.getState().kanbanMulti.snapshots["wf1"]; - const task = snapshot?.tasks.find((t) => t.id === "t1"); - expect(task?.primarySessionId).toBe("sess-multi-primary"); - }); -}); - -describe("kanban.update handler — task filtering", () => { - it("skips ephemeral tasks", () => { - const store = makeStore({ - kanban: { workflowId: "wf1", steps: [], tasks: [] }, - } as Partial); - - const handler = registerKanbanHandlers(store)["kanban.update"]!; - handler( - makeUpdateMessage("wf1", [ - { id: "t1", workflowStepId: "s1", title: "Real", position: 0 }, - { - id: "t-ephemeral", - workflowStepId: "s1", - title: "Ephemeral", - position: 1, - is_ephemeral: true, - }, - ]), - ); - - expect(store.getState().kanban.tasks).toHaveLength(1); - expect(store.getState().kanban.tasks[0].id).toBe("t1"); - }); -}); diff --git a/apps/web/lib/ws/handlers/kanban.ts b/apps/web/lib/ws/handlers/kanban.ts deleted file mode 100644 index 2effddfdcc..0000000000 --- a/apps/web/lib/ws/handlers/kanban.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { KanbanState } from "@/lib/state/slices/kanban/types"; -import { mergeTaskRepositoryFields } from "@/lib/ws/handlers/task-repositories"; - -type KanbanTask = KanbanState["tasks"][number]; -type KanbanStep = KanbanState["steps"][number]; - -type KanbanUpdateTask = { - id: string; - workflowStepId: string; - title: string; - description?: string; - position?: number; - state?: KanbanTask["state"]; - repository_id?: string; - repositories?: KanbanTask["repositories"]; - is_ephemeral?: boolean; -}; - -export function registerKanbanHandlers(store: StoreApi): WsHandlers { - return { - "kanban.update": (message) => { - const workflowId = message.payload.workflowId; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const steps: KanbanStep[] = message.payload.steps.map((step: any, index: number) => ({ - id: step.id, - title: step.title, - color: step.color ?? "bg-neutral-400", - position: step.position ?? index, - events: step.events, - show_in_command_panel: step.show_in_command_panel, - agent_profile_id: step.agent_profile_id, - wip_limit: step.wip_limit, - pull_from_step_id: step.pull_from_step_id ?? null, - })); - - store.setState((state) => { - // kanban.update doesn't carry primarySessionId / primarySessionState — - // those are set by task.updated WS events. Build tasks inside setState - // so we can read existing values and preserve them. - const existingById = new Map(state.kanban.tasks.map((t) => [t.id, t])); - const tasks: KanbanTask[] = message.payload.tasks - // Filter out ephemeral tasks (e.g., quick chat) - .filter((task: KanbanUpdateTask) => !task.is_ephemeral) - .map((task: KanbanUpdateTask) => { - const existing = existingById.get(task.id); - const repoFields = mergeTaskRepositoryFields(existing, { - repositoryId: task.repository_id, - repositories: task.repositories, - }); - return { - id: task.id, - workflowStepId: task.workflowStepId, - title: task.title, - description: task.description, - position: task.position ?? 0, - state: task.state, - ...repoFields, - primarySessionId: existing?.primarySessionId, - primarySessionState: existing?.primarySessionState, - primarySessionPendingAction: existing?.primarySessionPendingAction, - }; - }); - - const next = { - ...state, - kanban: { workflowId, steps, tasks }, - }; - - // Also update multi-workflow snapshots if this workflow is tracked - const snapshot = state.kanbanMulti.snapshots[workflowId]; - if (snapshot) { - const existingMultiById = new Map(snapshot.tasks.map((t) => [t.id, t])); - const multiTasks = tasks.map((t) => { - const fallback = existingMultiById.get(t.id); - const repoFields = mergeTaskRepositoryFields(fallback, t); - // Fall back to the multi-snapshot's own value only when the main - // kanban lookup returned `undefined` (task absent from kanban.tasks). - // An explicit `null` means the primary was intentionally cleared - // and must NOT be replaced by a stale snapshot value. - return { - ...t, - ...repoFields, - primarySessionId: - t.primarySessionId === undefined ? fallback?.primarySessionId : t.primarySessionId, - primarySessionState: - t.primarySessionState === undefined - ? fallback?.primarySessionState - : t.primarySessionState, - primarySessionPendingAction: - t.primarySessionPendingAction === undefined - ? fallback?.primarySessionPendingAction - : t.primarySessionPendingAction, - }; - }); - return { - ...next, - kanbanMulti: { - ...next.kanbanMulti, - snapshots: { - ...next.kanbanMulti.snapshots, - [workflowId]: { ...snapshot, steps, tasks: multiTasks }, - }, - }, - }; - } - - return next; - }); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/office.test.ts b/apps/web/lib/ws/handlers/office.test.ts deleted file mode 100644 index d0ef62dfcf..0000000000 --- a/apps/web/lib/ws/handlers/office.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import { registerOfficeHandlers } from "./office"; - -/** - * Minimal in-memory store for the office WS handler tests. - * Focus: workspace_id filtering in the handlers. - */ -function makeStore(activeWorkspaceId: string | null) { - const setOfficeRefetchTrigger = vi.fn(); - const updateOfficeAgentProfile = vi.fn(); - const upsertProviderHealth = vi.fn(); - const appendRunAttempt = vi.fn(); - const setWorkspaceRouting = vi.fn(); - let state = { - workspaces: { items: [], activeId: activeWorkspaceId }, - office: { tasks: { items: [] } }, - setOfficeRefetchTrigger, - updateOfficeAgentProfile, - upsertProviderHealth, - appendRunAttempt, - setWorkspaceRouting, - } as unknown as AppState; - const subs: Array<(s: AppState) => void> = []; - const store: StoreApi = { - getState: () => state, - setState: (next: AppState | ((s: AppState) => Partial)) => { - const partial = - typeof next === "function" ? (next as (s: AppState) => Partial)(state) : next; - state = { ...state, ...partial } as AppState; - subs.forEach((s) => s(state)); - }, - subscribe: (listener: (s: AppState) => void) => { - subs.push(listener); - return () => { - const i = subs.indexOf(listener); - if (i >= 0) subs.splice(i, 1); - }; - }, - getInitialState: () => state, - } as unknown as StoreApi; - return { - store, - setOfficeRefetchTrigger, - updateOfficeAgentProfile, - upsertProviderHealth, - appendRunAttempt, - setWorkspaceRouting, - }; -} - -const ACTIVE_WS = "ws-current"; - -describe("office WS handler — workspace filter", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("triggers refetch when event workspace matches active workspace", () => { - const { store, setOfficeRefetchTrigger } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.task.created"]!; - - handler({ - type: "notification", - action: "office.task.created", - payload: { workspace_id: ACTIVE_WS, task_id: "t-1" }, - } as Parameters[0]); - - expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("tasks"); - expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("dashboard"); - }); - - it("does NOT trigger refetch when event workspace differs from active workspace", () => { - const { store, setOfficeRefetchTrigger } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.task.created"]!; - - handler({ - type: "notification", - action: "office.task.created", - payload: { workspace_id: "ws-other", task_id: "t-1" }, - } as Parameters[0]); - - expect(setOfficeRefetchTrigger).not.toHaveBeenCalled(); - }); - - it("triggers refetch for legacy events without workspace_id (backwards compat)", () => { - const { store, setOfficeRefetchTrigger } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.task.created"]!; - - handler({ - type: "notification", - action: "office.task.created", - payload: { task_id: "t-1" }, - } as Parameters[0]); - - expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("tasks"); - expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("dashboard"); - }); - - it("filters office.agent.completed by workspace too", () => { - const { store, setOfficeRefetchTrigger, updateOfficeAgentProfile } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.agent.completed"]!; - - handler({ - type: "notification", - action: "office.agent.completed", - payload: { workspace_id: "ws-other", agent_profile_id: "agent-1" }, - } as Parameters[0]); - - expect(updateOfficeAgentProfile).not.toHaveBeenCalled(); - expect(setOfficeRefetchTrigger).not.toHaveBeenCalled(); - }); - - it("filters office.approval.created by workspace too", () => { - const { store, setOfficeRefetchTrigger } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.approval.created"]!; - - handler({ - type: "notification", - action: "office.approval.created", - payload: { workspace_id: "ws-other" }, - } as Parameters[0]); - - expect(setOfficeRefetchTrigger).not.toHaveBeenCalled(); - }); -}); - -describe("office WS handler — approval flow events", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("refreshes per-task DTO and inbox on office.task.decision_recorded", () => { - const { store, setOfficeRefetchTrigger } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.task.decision_recorded"]!; - - handler({ - type: "notification", - action: "office.task.decision_recorded", - payload: { - workspace_id: ACTIVE_WS, - task_id: "t-42", - decision_id: "d-1", - role: "approver", - decider_type: "user", - decider_id: "user", - decision: "approved", - }, - } as Parameters[0]); - - expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("task:t-42"); - expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("inbox"); - }); - - it("refreshes the inbox on office.task.review_requested", () => { - const { store, setOfficeRefetchTrigger } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.task.review_requested"]!; - - handler({ - type: "notification", - action: "office.task.review_requested", - payload: { - workspace_id: ACTIVE_WS, - task_id: "t-99", - role: "approver", - reviewer_agent_id: "agent-x", - }, - } as Parameters[0]); - - expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("inbox"); - expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("task:t-99"); - }); - - it("does not refresh on cross-workspace office.task.decision_recorded", () => { - const { store, setOfficeRefetchTrigger } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.task.decision_recorded"]!; - - handler({ - type: "notification", - action: "office.task.decision_recorded", - payload: { workspace_id: "ws-other", task_id: "t-1" }, - } as Parameters[0]); - - expect(setOfficeRefetchTrigger).not.toHaveBeenCalled(); - }); -}); - -describe("office WS handler — provider routing events", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("upserts provider health on office.provider.health_changed", () => { - const { store, upsertProviderHealth } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.provider.health_changed"]!; - - handler({ - type: "notification", - action: "office.provider.health_changed", - payload: { - workspace_id: ACTIVE_WS, - provider_id: "claude-acp", - scope: "provider", - scope_value: "", - state: "degraded", - backoff_step: 1, - error_code: "quota_limited", - }, - } as Parameters[0]); - - expect(upsertProviderHealth).toHaveBeenCalledWith( - ACTIVE_WS, - expect.objectContaining({ - provider_id: "claude-acp", - state: "degraded", - error_code: "quota_limited", - }), - ); - }); - - it("appends route attempt on office.route_attempt.appended", () => { - const { store, appendRunAttempt } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.route_attempt.appended"]!; - - handler({ - type: "notification", - action: "office.route_attempt.appended", - payload: { - workspace_id: ACTIVE_WS, - run_id: "run-1", - attempt: { - seq: 1, - provider_id: "codex-acp", - model: "gpt-5.4", - tier: "balanced", - outcome: "launched", - started_at: "2026-05-10T12:00:00Z", - }, - }, - } as Parameters[0]); - - expect(appendRunAttempt).toHaveBeenCalledWith( - "run-1", - expect.objectContaining({ seq: 1, provider_id: "codex-acp" }), - ); - }); - - it("invalidates routing config on office.routing.settings_updated", () => { - const { store, setWorkspaceRouting } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.routing.settings_updated"]!; - - handler({ - type: "notification", - action: "office.routing.settings_updated", - payload: { workspace_id: ACTIVE_WS }, - } as Parameters[0]); - - expect(setWorkspaceRouting).toHaveBeenCalledWith(ACTIVE_WS, undefined); - }); - - it("filters provider health changes to current workspace", () => { - const { store, upsertProviderHealth } = makeStore(ACTIVE_WS); - const handlers = registerOfficeHandlers(store); - const handler = handlers["office.provider.health_changed"]!; - - handler({ - type: "notification", - action: "office.provider.health_changed", - payload: { - workspace_id: "ws-other", - provider_id: "claude-acp", - scope: "provider", - scope_value: "", - state: "degraded", - backoff_step: 0, - }, - } as Parameters[0]); - - expect(upsertProviderHealth).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/web/lib/ws/handlers/office.ts b/apps/web/lib/ws/handlers/office.ts deleted file mode 100644 index a96c1e2880..0000000000 --- a/apps/web/lib/ws/handlers/office.ts +++ /dev/null @@ -1,281 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; - -/** - * Registers WS handlers for office domain events. - * - * Strategy: - * - Task status/move: direct update of the issue in store (fast, no network) - * - Agent status: direct update of agent instance in store - * - New tasks, comments, approvals, dashboard data: trigger refetch via - * the officeRefetchTrigger mechanism — pages that care watch this field - * and re-fetch their API data. - */ -export function registerOfficeHandlers(store: StoreApi): WsHandlers { - const triggerRefetch = (type: string) => { - store.getState().setOfficeRefetchTrigger(type); - }; - - // Returns true if the event belongs to the active workspace (or has no - // workspace_id, e.g. legacy events). Office WS broadcasts hit every - // connected client; we filter here so cross-workspace events don't trigger - // refetches for the wrong dashboard. - const isCurrentWorkspace = (payload: Record): boolean => { - const wsId = payload.workspace_id as string | undefined; - const activeId = store.getState().workspaces.activeId; - return !wsId || wsId === activeId; - }; - - const updateTaskStatus = (taskId: string, fields: Record) => { - store.setState((state) => ({ - ...state, - office: { - ...state.office, - tasks: { - ...state.office.tasks, - items: state.office.tasks.items.map((i) => (i.id === taskId ? { ...i, ...fields } : i)), - }, - }, - })); - }; - - return { - ...buildTaskHandlers(triggerRefetch, updateTaskStatus, isCurrentWorkspace), - ...buildAgentHandlers(store, triggerRefetch, isCurrentWorkspace), - ...buildMiscHandlers(triggerRefetch, isCurrentWorkspace), - ...buildRoutingHandlers(store, isCurrentWorkspace), - }; -} - -type WorkspaceCheck = (payload: Record) => boolean; - -function buildTaskHandlers( - triggerRefetch: (type: string) => void, - updateTaskStatus: (taskId: string, fields: Record) => void, - isCurrentWorkspace: WorkspaceCheck, -): WsHandlers { - return { - "office.task.updated": (message) => { - const p = message.payload; - if (!isCurrentWorkspace(p)) return; - const taskId = (p.task_id ?? p.id) as string | undefined; - if (!taskId) return; - updateTaskStatus(taskId, normalizeIssueFields(p)); - // Per-task channel — the detail page subscribes to refresh the - // server-authoritative task DTO after a property mutation. - triggerRefetch(`task:${taskId}`); - triggerRefetch("dashboard"); - }, - - "office.task.created": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - triggerRefetch("tasks"); - triggerRefetch("dashboard"); - }, - - "office.task.moved": (message) => { - const p = message.payload; - if (!isCurrentWorkspace(p)) return; - const taskId = (p.task_id ?? p.id) as string | undefined; - const newStatus = p.new_status as string | undefined; - if (!taskId || !newStatus) { - triggerRefetch("tasks"); - triggerRefetch("dashboard"); - return; - } - updateTaskStatus(taskId, { status: newStatus as OfficeTaskStatus }); - triggerRefetch("dashboard"); - triggerRefetch("activity"); - }, - - "office.task.status_changed": (message) => { - const p = message.payload; - if (!isCurrentWorkspace(p)) return; - const taskId = (p.task_id ?? p.id) as string | undefined; - const newStatus = p.new_status as string | undefined; - if (!taskId || !newStatus) { - triggerRefetch("tasks"); - return; - } - updateTaskStatus(taskId, { status: newStatus as OfficeTaskStatus }); - triggerRefetch("dashboard"); - }, - - "office.comment.created": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - const taskId = message.payload.task_id as string | undefined; - triggerRefetch(taskId ? `comments:${taskId}` : "comments"); - }, - - "office.task.decision_recorded": (message) => { - const p = message.payload; - if (!isCurrentWorkspace(p)) return; - const taskId = p.task_id as string | undefined; - if (taskId) triggerRefetch(`task:${taskId}`); - // Inbox surfaces tasks awaiting review/approval; an incoming - // decision can drop a task off the user's inbox just as easily as - // adding one (e.g. when the final approver signs off). - triggerRefetch("inbox"); - }, - - "office.task.review_requested": (message) => { - const p = message.payload; - if (!isCurrentWorkspace(p)) return; - triggerRefetch("inbox"); - const taskId = p.task_id as string | undefined; - if (taskId) triggerRefetch(`task:${taskId}`); - }, - }; -} - -function buildAgentHandlers( - store: StoreApi, - triggerRefetch: (type: string) => void, - isCurrentWorkspace: WorkspaceCheck, -): WsHandlers { - return { - "office.agent.completed": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - const agentId = message.payload.agent_profile_id as string | undefined; - if (agentId) store.getState().updateOfficeAgentProfile(agentId, { status: "idle" }); - triggerRefetch("dashboard"); - triggerRefetch("agents"); - triggerRefetch("activity"); - }, - - "office.agent.failed": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - const agentId = message.payload.agent_profile_id as string | undefined; - if (agentId) store.getState().updateOfficeAgentProfile(agentId, { status: "idle" }); - triggerRefetch("dashboard"); - triggerRefetch("agents"); - }, - - "office.agent.updated": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - triggerRefetch("agents"); - }, - }; -} - -function buildMiscHandlers( - triggerRefetch: (type: string) => void, - isCurrentWorkspace: WorkspaceCheck, -): WsHandlers { - return { - "office.approval.created": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - triggerRefetch("inbox"); - triggerRefetch("dashboard"); - }, - - "office.approval.resolved": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - triggerRefetch("inbox"); - triggerRefetch("approvals"); - }, - - "office.cost.recorded": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - triggerRefetch("costs"); - triggerRefetch("dashboard"); - }, - - "office.run.queued": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - triggerRefetch("runs"); - triggerRefetch("agents"); - // The user comment that produced this run now carries a runId / - // runStatus = "queued" — the chat badge needs to flip from absent - // to "Queued". Refetch the comments list for the affected task. - const taskId = message.payload.task_id as string | undefined; - if (taskId) triggerRefetch(`comments:${taskId}`); - }, - - "office.run.processed": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - triggerRefetch("runs"); - triggerRefetch("agents"); - // The run lifecycle just advanced (claimed → finished/failed/ - // cancelled) — refresh the chat for the affected task so the - // badge transitions to its new state (or hides on finished). - const taskId = message.payload.task_id as string | undefined; - if (taskId) triggerRefetch(`comments:${taskId}`); - }, - - "office.routine.triggered": (message) => { - if (!isCurrentWorkspace(message.payload)) return; - triggerRefetch("routines"); - triggerRefetch("activity"); - }, - }; -} - -function buildRoutingHandlers( - store: StoreApi, - isCurrentWorkspace: WorkspaceCheck, -): WsHandlers { - return { - "office.provider.health_changed": (message) => { - const p = message.payload; - if (!isCurrentWorkspace(p)) return; - const wsId = (p.workspace_id as string | undefined) ?? store.getState().workspaces.activeId; - if (!wsId) return; - const row = extractProviderHealth(p); - if (!row) return; - store.getState().upsertProviderHealth(wsId, row); - }, - "office.route_attempt.appended": (message) => { - const p = message.payload; - if (!isCurrentWorkspace(p)) return; - const runId = p.run_id as string | undefined; - const attempt = p.attempt as Record | undefined; - if (!runId || !attempt) return; - store.getState().appendRunAttempt(runId, attempt as RouteAttemptPayload); - }, - "office.routing.settings_updated": (message) => { - const p = message.payload; - if (!isCurrentWorkspace(p)) return; - const wsId = (p.workspace_id as string | undefined) ?? store.getState().workspaces.activeId; - if (!wsId) return; - store.getState().setWorkspaceRouting(wsId, undefined); - }, - }; -} - -type ProviderHealthPayload = import("@/lib/state/slices/office/types").ProviderHealth; -type RouteAttemptPayload = import("@/lib/state/slices/office/types").RouteAttempt; - -function extractProviderHealth(p: Record): ProviderHealthPayload | null { - if (typeof p.provider_id !== "string" || typeof p.scope !== "string") return null; - return { - workspace_id: typeof p.workspace_id === "string" ? p.workspace_id : undefined, - provider_id: p.provider_id, - scope: p.scope as ProviderHealthPayload["scope"], - scope_value: typeof p.scope_value === "string" ? p.scope_value : "", - state: (p.state as ProviderHealthPayload["state"]) ?? "healthy", - error_code: p.error_code as ProviderHealthPayload["error_code"], - retry_at: typeof p.retry_at === "string" ? p.retry_at : undefined, - backoff_step: typeof p.backoff_step === "number" ? p.backoff_step : 0, - last_failure: typeof p.last_failure === "string" ? p.last_failure : undefined, - last_success: typeof p.last_success === "string" ? p.last_success : undefined, - raw_excerpt: typeof p.raw_excerpt === "string" ? p.raw_excerpt : undefined, - }; -} - -// Normalize snake_case event data fields to camelCase store fields -function normalizeIssueFields(p: Record): Record { - const out: Record = {}; - if (p.title != null) out.title = p.title; - if (p.description != null) out.description = p.description; - if (p.status != null) out.status = p.status; - if (p.new_status != null) out.status = p.new_status; - if (p.priority != null) out.priority = p.priority; - if (p.updated_at != null) out.updatedAt = p.updated_at; - if (p.assignee_agent_profile_id != null) out.assigneeAgentProfileId = p.assignee_agent_profile_id; - return out; -} - -// Re-import the type for the status field cast -type OfficeTaskStatus = import("@/lib/state/slices/office/types").OfficeTaskStatus; diff --git a/apps/web/lib/ws/handlers/prompt-usage.ts b/apps/web/lib/ws/handlers/prompt-usage.ts deleted file mode 100644 index 2051a52107..0000000000 --- a/apps/web/lib/ws/handlers/prompt-usage.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { SessionPromptUsagePayload } from "@/lib/types/session-runtime-payloads"; - -export function registerPromptUsageHandlers(store: StoreApi): WsHandlers { - return { - "session.prompt_usage": (message) => { - const payload = message.payload as SessionPromptUsagePayload | undefined; - if (!payload?.session_id || !payload?.usage) { - return; - } - store.getState().setPromptUsage(payload.session_id, { - inputTokens: payload.usage.input_tokens, - outputTokens: payload.usage.output_tokens, - cachedReadTokens: payload.usage.cached_read_tokens, - cachedWriteTokens: payload.usage.cached_write_tokens, - totalTokens: payload.usage.total_tokens, - }); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/secrets.ts b/apps/web/lib/ws/handlers/secrets.ts deleted file mode 100644 index d03a360ff4..0000000000 --- a/apps/web/lib/ws/handlers/secrets.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { SecretListItem } from "@/lib/types/http-secrets"; - -export function registerSecretsHandlers(store: StoreApi): WsHandlers { - return { - "secrets.created": (message) => { - const item = message.payload as SecretListItem; - store.getState().addSecret(item); - }, - "secrets.updated": (message) => { - const item = message.payload as SecretListItem; - store.getState().updateSecret(item); - }, - "secrets.deleted": (message) => { - const { id } = message.payload as { id: string }; - store.getState().removeSecret(id); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/session-mode.ts b/apps/web/lib/ws/handlers/session-mode.ts deleted file mode 100644 index 39ec219260..0000000000 --- a/apps/web/lib/ws/handlers/session-mode.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { SessionModeChangedPayload } from "@/lib/types/backend"; - -export function registerSessionModeHandlers(store: StoreApi): WsHandlers { - return { - "session.mode_changed": (message) => { - const payload = message.payload as SessionModeChangedPayload | undefined; - if (!payload?.session_id) { - return; - } - const sessionId = payload.session_id; - // Empty current_mode_id means the agent exited its special mode - const modeId = payload.current_mode_id || ""; - const availableModes = payload.available_modes?.map((m) => ({ - id: m.id, - name: m.name, - description: m.description, - })); - - if (modeId) { - store.getState().setSessionMode(sessionId, modeId, availableModes); - } else { - // Keep availableModes so the UI still knows what modes exist - store.getState().setSessionMode(sessionId, "", availableModes); - } - }, - }; -} diff --git a/apps/web/lib/ws/handlers/session-poll-mode.ts b/apps/web/lib/ws/handlers/session-poll-mode.ts deleted file mode 100644 index b3220b57ad..0000000000 --- a/apps/web/lib/ws/handlers/session-poll-mode.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { SessionPollMode } from "@/lib/state/slices/session-runtime/types"; - -const VALID_MODES = new Set(["fast", "slow", "paused"]); - -export function registerSessionPollModeHandlers(store: StoreApi): WsHandlers { - return { - "session.poll_mode_changed": (message) => { - const { session_id, poll_mode } = message.payload; - if (!session_id || !poll_mode) return; - if (!VALID_MODES.has(poll_mode as SessionPollMode)) return; - store.getState().setSessionPollMode(session_id, poll_mode as SessionPollMode); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/session-todos.ts b/apps/web/lib/ws/handlers/session-todos.ts deleted file mode 100644 index f17835809f..0000000000 --- a/apps/web/lib/ws/handlers/session-todos.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { SessionTodosPayload } from "@/lib/types/backend"; -import type { TodoEntry } from "@/lib/state/slices/session-runtime/types"; - -export function registerSessionTodosHandlers(store: StoreApi): WsHandlers { - return { - "session.todos_updated": (message) => { - const payload = message.payload as SessionTodosPayload | undefined; - if (!payload?.session_id) { - return; - } - store.getState().setSessionTodos( - payload.session_id, - (payload.entries ?? []).map( - (e): TodoEntry => ({ - description: e.description, - status: e.status as TodoEntry["status"], - priority: e.priority, - }), - ), - ); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/system-events.ts b/apps/web/lib/ws/handlers/system-events.ts deleted file mode 100644 index e49ad7f484..0000000000 --- a/apps/web/lib/ws/handlers/system-events.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; - -export function registerSystemEventsHandlers(store: StoreApi): WsHandlers { - return { - "system.error": () => { - // TODO: surface as toast/notification once UI is ready. - }, - "system.job.update": (message) => { - // The WS payload is the full SystemJob row published by the backend - // jobs tracker (see internal/system/jobs). Upsert by id so the - // jobs map mirrors the latest queued/running/succeeded/failed state. - store.getState().upsertSystemJob(message.payload); - }, - "system.metrics.updated": (message) => { - store.getState().setSystemMetricsSnapshot(message.payload); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/task-lifecycle-diagnostics.test.ts b/apps/web/lib/ws/handlers/task-lifecycle-diagnostics.test.ts deleted file mode 100644 index dd4f79f4c4..0000000000 --- a/apps/web/lib/ws/handlers/task-lifecycle-diagnostics.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import { didPreservePrimaryState, registerTasksHandlers } from "./tasks"; - -type Listener = (state: AppState) => void; - -function makeStore(initial: Partial = {}) { - let state = { - kanban: { workflowId: "wf1", steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - tasks: { - activeTaskId: null, - activeSessionId: null, - pinnedSessionId: null, - lastSessionByTaskId: {}, - }, - taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} }, - environmentIdBySessionId: {}, - setActiveSessionAuto: vi.fn(), - removeTaskFromSidebarPrefs: vi.fn(), - ...initial, - } as unknown as AppState; - - const listeners = new Set(); - return { - getState: () => state, - setState: (updater: AppState | ((s: AppState) => AppState)) => { - const next = - typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; - state = { ...state, ...next }; - for (const l of listeners) l(state); - }, - subscribe: (l: Listener) => { - listeners.add(l); - return () => listeners.delete(l); - }, - destroy: vi.fn(), - getInitialState: vi.fn(), - } as unknown as StoreApi & { getState: () => AppState }; -} - -function makeTask(id: string, primarySessionId: string | null, workflowId = "wf1") { - return { - task_id: id, - workflow_id: workflowId, - workflow_step_id: "step1", - title: "Test", - description: "", - state: "IN_PROGRESS", - primary_session_id: primarySessionId, - is_ephemeral: false, - } as Record; -} - -function makeUpdatedMessage(payload: Record) { - return { - id: "msg-1", - type: "notification" as const, - action: "task.updated" as const, - payload, - } as Parameters["task.updated"]>>[0]; -} - -function makeCreatedMessage(payload: Record) { - return { - id: "msg-1", - type: "notification" as const, - action: "task.created" as const, - payload, - } as Parameters["task.created"]>>[0]; -} - -function makeStateChangedMessage(payload: Record) { - return { - id: "msg-1", - type: "notification" as const, - action: "task.state_changed" as const, - payload, - } as Parameters["task.state_changed"]>>[0]; -} - -const existingTask = { - id: "t1", - workflowStepId: "step1", - title: "Old", - position: 0, - state: "IN_PROGRESS" as const, - primarySessionId: "sess-1", - primarySessionState: "RUNNING", -}; - -function taskWithPrimaryState(primarySessionState: string | undefined) { - return { - ...existingTask, - primarySessionState, - }; -} - -describe("didPreservePrimaryState", () => { - it("detects only omitted payload primary state that stayed preserved", () => { - expect( - didPreservePrimaryState( - { workflow_id: "wf1", primary_session_state: "RUNNING" }, - taskWithPrimaryState("COMPLETED"), - taskWithPrimaryState("COMPLETED"), - ), - ).toBe(false); - expect( - didPreservePrimaryState( - { workflow_id: "wf1" }, - taskWithPrimaryState(undefined), - taskWithPrimaryState("RUNNING"), - ), - ).toBe(false); - expect( - didPreservePrimaryState( - { workflow_id: "wf1" }, - taskWithPrimaryState("RUNNING"), - taskWithPrimaryState("RUNNING"), - ), - ).toBe(true); - expect( - didPreservePrimaryState( - { workflow_id: "wf1" }, - taskWithPrimaryState("RUNNING"), - taskWithPrimaryState("COMPLETED"), - ), - ).toBe(false); - }); -}); - -describe("task lifecycle diagnostics", () => { - it("upserts task state and primary session state into both kanban stores", () => { - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [existingTask], - } as unknown as AppState["kanban"], - kanbanMulti: { - isLoading: false, - snapshots: { - wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [existingTask] }, - }, - } as unknown as AppState["kanbanMulti"], - }); - - registerTasksHandlers(store)["task.state_changed"]!( - makeStateChangedMessage({ - ...makeTask("t1", "sess-1"), - state: "REVIEW", - primary_session_state: "WAITING_FOR_INPUT", - }), - ); - - const state = store.getState(); - const kanbanTask = state.kanban.tasks.find((task) => task.id === "t1"); - const snapshotTask = state.kanbanMulti.snapshots.wf1.tasks.find((task) => task.id === "t1"); - expect(kanbanTask?.state).toBe("REVIEW"); - expect(kanbanTask?.primarySessionState).toBe("WAITING_FOR_INPUT"); - expect(snapshotTask?.state).toBe("REVIEW"); - expect(snapshotTask?.primarySessionState).toBe("WAITING_FOR_INPUT"); - }); - - it("skips ephemeral task state changes", () => { - const store = makeStore(); - - registerTasksHandlers(store)["task.state_changed"]!( - makeStateChangedMessage({ - ...makeTask("t-ephemeral", "sess-1"), - is_ephemeral: true, - state: "REVIEW", - }), - ); - - expect(store.getState().kanban.tasks).toEqual([]); - }); - - it("skips ephemeral task creation", () => { - const store = makeStore(); - - registerTasksHandlers(store)["task.created"]!( - makeCreatedMessage({ - ...makeTask("t-ephemeral", "sess-1"), - is_ephemeral: true, - }), - ); - - expect(store.getState().kanban.tasks).toEqual([]); - }); - - it("logs whether a task update preserved an omitted primary session state", () => { - const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => undefined); - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [existingTask], - } as unknown as AppState["kanban"], - }); - const handlers = registerTasksHandlers(store); - - handlers["task.updated"]!(makeUpdatedMessage(makeTask("t1", "sess-1"))); - handlers["task.updated"]!( - makeUpdatedMessage({ - ...makeTask("t1", "sess-1"), - primary_session_state: null, - }), - ); - - const logs = debugSpy.mock.calls.map((call) => String(call[0])); - expect(logs.some((line) => line.includes("preservedPrimaryState=true"))).toBe(true); - expect(logs.some((line) => line.includes("payloadPrimarySessionState=null"))).toBe(true); - expect(logs.some((line) => line.includes("preservedPrimaryState=false"))).toBe(true); - debugSpy.mockRestore(); - }); -}); - -describe("task primary-session clears", () => { - it("does not preserve primary session fields when the payload explicitly clears them", () => { - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [existingTask], - } as unknown as AppState["kanban"], - kanbanMulti: { - isLoading: false, - snapshots: { - wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [existingTask] }, - }, - } as unknown as AppState["kanbanMulti"], - }); - - registerTasksHandlers(store)["task.updated"]!( - makeUpdatedMessage({ - ...makeTask("t1", null), - primary_session_state: null, - }), - ); - - const state = store.getState(); - const kanbanTask = state.kanban.tasks.find((task) => task.id === "t1"); - const snapshotTask = state.kanbanMulti.snapshots.wf1.tasks.find((task) => task.id === "t1"); - expect(kanbanTask?.primarySessionId).toBeUndefined(); - expect(kanbanTask?.primarySessionState).toBeUndefined(); - expect(snapshotTask?.primarySessionId).toBeUndefined(); - expect(snapshotTask?.primarySessionState).toBeUndefined(); - }); - - it("does not preserve primary session fields on explicit state-change clears", () => { - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [existingTask], - } as unknown as AppState["kanban"], - kanbanMulti: { - isLoading: false, - snapshots: { - wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [existingTask] }, - }, - } as unknown as AppState["kanbanMulti"], - }); - - registerTasksHandlers(store)["task.state_changed"]!( - makeStateChangedMessage({ - ...makeTask("t1", null), - primary_session_state: null, - }), - ); - - const state = store.getState(); - const kanbanTask = state.kanban.tasks.find((task) => task.id === "t1"); - const snapshotTask = state.kanbanMulti.snapshots.wf1.tasks.find((task) => task.id === "t1"); - expect(kanbanTask?.primarySessionId).toBeUndefined(); - expect(kanbanTask?.primarySessionState).toBeUndefined(); - expect(snapshotTask?.primarySessionId).toBeUndefined(); - expect(snapshotTask?.primarySessionState).toBeUndefined(); - }); -}); diff --git a/apps/web/lib/ws/handlers/task-plans.test.ts b/apps/web/lib/ws/handlers/task-plans.test.ts deleted file mode 100644 index 92c31a6603..0000000000 --- a/apps/web/lib/ws/handlers/task-plans.test.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; - -import { registerTaskPlansHandlers } from "./task-plans"; - -const TASK_ID = "task-1"; -const ACTION_CREATED = "task.plan.created"; -const ACTION_UPDATED = "task.plan.updated"; -const ACTION_DELETED = "task.plan.deleted"; - -function makePayload(overrides: Partial> = {}) { - return { - id: "plan-1", - task_id: TASK_ID, - title: "Plan", - content: "# Plan", - created_by: "agent", - created_at: "2026-04-20T00:00:00Z", - updated_at: "2026-04-20T00:00:00Z", - ...overrides, - }; -} - -function makeMessage(action: string, payload: Record) { - return { id: "msg-1", type: "notification", action, payload }; -} - -function makeStore( - overrides: Record = {}, - prevPlan: Record | null = null, -) { - const state = { - tasks: { activeTaskId: TASK_ID, activeSessionId: "s-1" }, - taskPlans: { byTaskId: prevPlan ? { [TASK_ID]: prevPlan } : {} }, - setTaskPlan: vi.fn(), - markTaskPlanSeen: vi.fn(), - ...overrides, - }; - return { - getState: () => state as unknown as AppState, - setState: vi.fn(), - subscribe: vi.fn(), - destroy: vi.fn(), - getInitialState: vi.fn(), - } as unknown as StoreApi; -} - -describe("task.plan.* handlers", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("agent created: stores plan and does NOT mark seen", () => { - const store = makeStore(); - const handlers = registerTaskPlansHandlers(store); - - handlers[ACTION_CREATED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_CREATED, makePayload()) as any, - ); - - expect(store.getState().setTaskPlan).toHaveBeenCalledWith(TASK_ID, expect.any(Object)); - expect(store.getState().markTaskPlanSeen).not.toHaveBeenCalled(); - }); - - it("agent updated: stores plan and does NOT mark seen", () => { - const store = makeStore(); - const handlers = registerTaskPlansHandlers(store); - - handlers[ACTION_UPDATED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_UPDATED, makePayload()) as any, - ); - - expect(store.getState().setTaskPlan).toHaveBeenCalled(); - expect(store.getState().markTaskPlanSeen).not.toHaveBeenCalled(); - }); - - it("user-authored create: marks plan as seen", () => { - const store = makeStore(); - const handlers = registerTaskPlansHandlers(store); - - handlers[ACTION_CREATED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_CREATED, makePayload({ created_by: "user" })) as any, - ); - - expect(store.getState().setTaskPlan).toHaveBeenCalled(); - expect(store.getState().markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID); - }); - - it("user-authored update: marks seen even when task is not active", () => { - const store = makeStore({ - tasks: { activeTaskId: "other-task", activeSessionId: "s-1" }, - }); - const handlers = registerTaskPlansHandlers(store); - - handlers[ACTION_UPDATED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_UPDATED, makePayload({ created_by: "user" })) as any, - ); - - expect(store.getState().markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID); - }); - - it("agent update on plan originally created by user: stores plan without marking seen", () => { - // Backend sets created_by to the last modifier on update, not the original - // creator — so an agent update on a user-created plan emits created_by="agent". - const store = makeStore(); - const handlers = registerTaskPlansHandlers(store); - - handlers[ACTION_UPDATED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_UPDATED, makePayload({ created_by: "agent" })) as any, - ); - - expect(store.getState().setTaskPlan).toHaveBeenCalled(); - expect(store.getState().markTaskPlanSeen).not.toHaveBeenCalled(); - }); - - it("user-authored update with UNCHANGED content does NOT mark seen", () => { - // Editor auto-save round-trips the agent's plan content through TipTap - // and saves it as user-authored — same content, new updated_at. Without - // a content-change check this would erase the agent's unseen indicator. - const store = makeStore({}, makePayload({ content: "# Plan", created_by: "agent" })); - const handlers = registerTaskPlansHandlers(store); - - const payload = makePayload({ - content: "# Plan", - created_by: "user", - updated_at: "2026-04-20T01:00:00Z", - }); - handlers[ACTION_UPDATED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_UPDATED, payload) as any, - ); - - expect(store.getState().setTaskPlan).toHaveBeenCalled(); - expect(store.getState().markTaskPlanSeen).not.toHaveBeenCalled(); - }); - - it("user-authored update with CHANGED content marks seen", () => { - const store = makeStore({}, makePayload({ content: "# Old", created_by: "agent" })); - const handlers = registerTaskPlansHandlers(store); - - const payload = makePayload({ - content: "# New", - created_by: "user", - updated_at: "2026-04-20T01:00:00Z", - }); - handlers[ACTION_UPDATED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_UPDATED, payload) as any, - ); - - expect(store.getState().markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID); - }); - - it("delete: nulls plan and marks as seen", () => { - const store = makeStore(); - const handlers = registerTaskPlansHandlers(store); - - handlers[ACTION_DELETED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_DELETED, { task_id: TASK_ID }) as any, - ); - - expect(store.getState().setTaskPlan).toHaveBeenCalledWith(TASK_ID, null); - expect(store.getState().markTaskPlanSeen).toHaveBeenCalledWith(TASK_ID); - }); -}); diff --git a/apps/web/lib/ws/handlers/task-plans.ts b/apps/web/lib/ws/handlers/task-plans.ts deleted file mode 100644 index 68b1c50ebb..0000000000 --- a/apps/web/lib/ws/handlers/task-plans.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { BackendMessageMap } from "@/lib/types/backend"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { TaskPlanRevision } from "@/lib/types/http"; - -type PlanMessage = BackendMessageMap["task.plan.created"] | BackendMessageMap["task.plan.updated"]; -type RevisionMessage = - | BackendMessageMap["task.plan.revision.created"] - | BackendMessageMap["task.plan.reverted"]; - -function handlePlanUpsert(store: StoreApi, message: PlanMessage) { - const { - task_id, - id, - title, - content, - created_by, - created_at, - updated_at, - implementation_started_at, - implementation_started_session_id, - implementation_started_by, - } = message.payload; - const prevPlan = store.getState().taskPlans.byTaskId[task_id]; - store.getState().setTaskPlan(task_id, { - id, - task_id, - title, - content, - created_by, - created_at, - updated_at, - implementation_started_at, - implementation_started_session_id, - implementation_started_by, - }); - - // User-authored writes mark the plan as seen — but only when the content - // actually changed. The plan editor's auto-save on mount can emit a - // user-authored update with unchanged content (TipTap markdown round-trip - // normalises whitespace), which would otherwise wipe an unseen agent - // indicator the moment the panel opens. - if (created_by === "user" && prevPlan?.content !== content) { - store.getState().markTaskPlanSeen(task_id); - } -} - -function handleRevisionPush(store: StoreApi, message: RevisionMessage) { - const p = message.payload; - const rev: TaskPlanRevision = { - id: p.id, - task_id: p.task_id, - revision_number: p.revision_number, - title: p.title, - author_kind: p.author_kind, - author_name: p.author_name, - revert_of_revision_id: p.revert_of_revision_id ?? null, - coalesced: p.coalesced, - created_at: p.created_at, - updated_at: p.updated_at, - }; - store.getState().upsertPlanRevision(p.task_id, rev); -} - -export function registerTaskPlansHandlers(store: StoreApi): WsHandlers { - return { - "task.plan.created": (message) => handlePlanUpsert(store, message), - "task.plan.updated": (message) => handlePlanUpsert(store, message), - "task.plan.deleted": (message) => { - const { task_id } = message.payload; - // Intentionally NOT clearTaskPlan: setTaskPlan(null) preserves - // loadedByTaskId[taskId] = true so useTaskPlan doesn't see !isLoaded - // and refetch a plan that was just deleted. clearTaskPlan would drop - // that flag and trigger a wasted HTTP round-trip. - store.getState().setTaskPlan(task_id, null); - store.getState().markTaskPlanSeen(task_id); - }, - "task.plan.revision.created": (message) => handleRevisionPush(store, message), - // `task.plan.reverted` is published alongside `task.plan.revision.created` - // for the same row by the backend RevertPlan path. Re-running the upsert - // on this event would be a no-op against the list (same id, same data) - // but would needlessly evict the revisionContentCache entry and trigger - // an extra Zustand update. Treat this event as a notification-only signal - // — register no-op so the dispatcher doesn't warn about an unhandled type. - "task.plan.reverted": () => {}, - }; -} diff --git a/apps/web/lib/ws/handlers/task-repositories.test.ts b/apps/web/lib/ws/handlers/task-repositories.test.ts deleted file mode 100644 index a72ca0a8ba..0000000000 --- a/apps/web/lib/ws/handlers/task-repositories.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { mergeTaskRepositoryFields } from "./task-repositories"; - -const REPO_A = "repo-a"; -const REPO_B = "repo-b"; - -function taskRepo(repositoryId: string) { - return { - id: `task-${repositoryId}`, - repository_id: repositoryId, - base_branch: "main", - position: 0, - }; -} - -describe("mergeTaskRepositoryFields", () => { - it("derives repositoryId from provided repositories when repositoryId is omitted", () => { - const repositories = [taskRepo(REPO_B)]; - - expect( - mergeTaskRepositoryFields( - { repositoryId: REPO_A, repositories: undefined }, - { repositoryId: undefined, repositories }, - ), - ).toEqual({ repositoryId: REPO_B, repositories }); - }); - - it("clears repository fields when an empty repository list is provided", () => { - expect( - mergeTaskRepositoryFields( - { - repositoryId: REPO_A, - repositories: [taskRepo(REPO_A)], - }, - { repositoryId: undefined, repositories: [] }, - ), - ).toEqual({ repositoryId: undefined, repositories: [] }); - }); - - it("clears stale repositories when the primary repository changes", () => { - expect( - mergeTaskRepositoryFields( - { - repositoryId: REPO_A, - repositories: [taskRepo(REPO_A)], - }, - { repositoryId: REPO_B, repositories: undefined }, - ), - ).toEqual({ repositoryId: REPO_B, repositories: undefined }); - }); - - it("preserves existing repository fields when both fields are omitted", () => { - const existing = { - repositoryId: REPO_A, - repositories: [taskRepo(REPO_A)], - }; - - expect( - mergeTaskRepositoryFields(existing, { repositoryId: undefined, repositories: undefined }), - ).toEqual(existing); - }); - - it("returns omitted fields when no existing task is available", () => { - expect( - mergeTaskRepositoryFields(undefined, { repositoryId: undefined, repositories: undefined }), - ).toEqual({ repositoryId: undefined, repositories: undefined }); - }); -}); diff --git a/apps/web/lib/ws/handlers/task-repositories.ts b/apps/web/lib/ws/handlers/task-repositories.ts deleted file mode 100644 index dbc89a8042..0000000000 --- a/apps/web/lib/ws/handlers/task-repositories.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { KanbanState } from "@/lib/state/slices/kanban/types"; - -type KanbanTask = KanbanState["tasks"][number]; - -type TaskRepositoryFields = Pick; - -export function mergeTaskRepositoryFields( - existing: TaskRepositoryFields | undefined, - next: TaskRepositoryFields, -): TaskRepositoryFields { - const repositoriesProvided = next.repositories !== undefined; - const nextRepositoryId = next.repositoryId ?? next.repositories?.[0]?.repository_id; - const repositoryIdChanged = - nextRepositoryId !== undefined && nextRepositoryId !== existing?.repositoryId; - - return { - repositoryId: repositoriesProvided - ? nextRepositoryId - : (nextRepositoryId ?? existing?.repositoryId), - repositories: - repositoriesProvided || repositoryIdChanged ? next.repositories : existing?.repositories, - }; -} diff --git a/apps/web/lib/ws/handlers/tasks-archive.test.ts b/apps/web/lib/ws/handlers/tasks-archive.test.ts index 9826d594e4..23dc50eb8c 100644 --- a/apps/web/lib/ws/handlers/tasks-archive.test.ts +++ b/apps/web/lib/ws/handlers/tasks-archive.test.ts @@ -9,15 +9,12 @@ vi.mock("@/lib/recent-tasks", () => ({ })); type Listener = (state: AppState) => void; -type KanbanTask = { id: string; title: string; workflowId: string; workflowStepId: string }; const TASK_ID = "t1"; const SESSION_ID = "sess-old"; const ARCHIVED_AT = "2026-06-30T12:00:00Z"; function makeStore(initial: Partial = {}) { let state = { - kanban: { workflowId: "wf1", steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, tasks: { activeTaskId: null, activeSessionId: null, @@ -29,7 +26,6 @@ function makeStore(initial: Partial = {}) { setActiveSessionAuto: vi.fn(), removeTaskFromSidebarPrefs: vi.fn(), setTaskDeletedNotification: vi.fn(), - setOfficeRefetchTrigger: vi.fn(), ...initial, } as unknown as AppState; @@ -92,11 +88,6 @@ function archiveTask(store: StoreApi, taskId = TASK_ID) { function makeStoreWithTask(initial: Partial = {}) { return makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: TASK_ID, primarySessionId: SESSION_ID, workflowId: "wf1" }], - } as unknown as AppState["kanban"], ...initial, }); } @@ -107,40 +98,6 @@ describe("task.updated archive cleanup", () => { window.history.replaceState({}, "", "/"); }); - it("removes archived tasks from the active kanban cache even when workflow focus changed", () => { - const staleTask: KanbanTask = { - id: "t1", - title: "Test", - workflowId: "wf1", - workflowStepId: "step1", - }; - const store = makeStore({ - kanban: { - workflowId: "wf-active", - steps: [], - tasks: [staleTask], - } as unknown as AppState["kanban"], - kanbanMulti: { - isLoading: false, - snapshots: { - wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [staleTask] }, - }, - } as unknown as AppState["kanbanMulti"], - }); - - const handlers = registerTasksHandlers(store); - handlers["task.updated"]!( - makeUpdatedMessage({ - ...taskPayload(TASK_ID, "wf1"), - archived_at: ARCHIVED_AT, - }), - ); - - const state = store.getState(); - expect(state.kanban.tasks).toEqual([]); - expect(state.kanbanMulti.snapshots.wf1.tasks).toEqual([]); - }); - it("clears active task state, pin, recent history, and sidebar prefs for archived task events", () => { const store = makeStoreWithTask({ tasks: { @@ -162,7 +119,16 @@ describe("task.updated archive cleanup", () => { expect(state.tasks.lastSessionByTaskId).toHaveProperty("t2", "sess-other"); expect(removeRecentTask).toHaveBeenCalledWith(TASK_ID); expect(state.removeTaskFromSidebarPrefs).toHaveBeenCalledWith(TASK_ID); - expect(state.setOfficeRefetchTrigger).toHaveBeenCalledWith("tasks"); + }); + + it("does not expose legacy kanban mirrors when archiving tasks", () => { + const store = makeStoreWithTask(); + + archiveTask(store); + + const state = store.getState(); + expect("kanban" in state).toBe(false); + expect("kanbanMulti" in state).toBe(false); }); it.each(["/t/t1", "/tasks/t1"])("redirects away when archived on %s", (path) => { diff --git a/apps/web/lib/ws/handlers/tasks-created-snapshot.test.ts b/apps/web/lib/ws/handlers/tasks-created-snapshot.test.ts deleted file mode 100644 index 0afece241d..0000000000 --- a/apps/web/lib/ws/handlers/tasks-created-snapshot.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import { registerTasksHandlers } from "./tasks"; - -type Listener = (state: AppState) => void; - -function makeStore(initial: Partial = {}) { - let state = { - kanban: { workflowId: "wf1", steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, - workflows: { - activeId: "wf1", - items: [ - { id: "wf1", workspaceId: "ws1", name: "Active Flow" }, - { id: "wf2", workspaceId: "ws1", name: "Sibling Flow" }, - ], - }, - tasks: { - activeTaskId: null, - activeSessionId: null, - pinnedSessionId: null, - lastSessionByTaskId: {}, - }, - taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} }, - environmentIdBySessionId: {}, - removeTaskFromSidebarPrefs: vi.fn(), - setTaskDeletedNotification: vi.fn(), - ...initial, - } as unknown as AppState; - - const listeners = new Set(); - return { - getState: () => state, - setState: (updater: AppState | ((s: AppState) => AppState)) => { - const next = - typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; - state = { ...state, ...next }; - for (const listener of listeners) listener(state); - }, - subscribe: (listener: Listener) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - destroy: vi.fn(), - getInitialState: vi.fn(), - } as unknown as StoreApi & { getState: () => AppState }; -} - -function makeCreatedMessage(payload: Record) { - return { - id: "msg-1", - type: "notification" as const, - action: "task.created" as const, - payload, - } as Parameters["task.created"]>>[0]; -} - -describe("task.created sidebar snapshots", () => { - it("keeps a created subtask visible when its workflow snapshot has not loaded yet", () => { - const store = makeStore(); - const handlers = registerTasksHandlers(store); - - handlers["task.created"]!( - makeCreatedMessage({ - task_id: "child-before-snapshot", - workflow_id: "wf2", - workflow_step_id: "step2", - title: "Child before snapshot", - state: "IN_PROGRESS", - parent_id: "parent-task", - is_ephemeral: false, - }), - ); - - const snapshot = store.getState().kanbanMulti.snapshots.wf2; - expect(snapshot.workflowName).toBe("Sibling Flow"); - expect(snapshot.steps).toEqual([]); - expect(snapshot.isPlaceholder).toBe(true); - expect(snapshot.tasks.map((task) => task.id)).toEqual(["child-before-snapshot"]); - expect(snapshot.tasks[0].parentTaskId).toBe("parent-task"); - }); - - it("keeps the task even when workflow metadata has not loaded yet", () => { - const store = makeStore({ - workflows: { activeId: null, items: [] }, - } as Partial); - const handlers = registerTasksHandlers(store); - - handlers["task.created"]!( - makeCreatedMessage({ - task_id: "child-before-workflows", - workflow_id: "wf-late", - workflow_step_id: "step-late", - title: "Child before workflows", - state: "IN_PROGRESS", - is_ephemeral: false, - }), - ); - - const snapshot = store.getState().kanbanMulti.snapshots["wf-late"]; - expect(snapshot.workflowName).toBe("wf-late"); - expect(snapshot.tasks.map((task) => task.id)).toEqual(["child-before-workflows"]); - }); -}); diff --git a/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts b/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts deleted file mode 100644 index c5e288b948..0000000000 --- a/apps/web/lib/ws/handlers/tasks-walkthrough-cleanup.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import { registerTasksHandlers } from "./tasks"; - -type Listener = (state: AppState) => void; - -function makeStore() { - let state = { - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: "sess-1", workflowId: "wf1" }], - }, - kanbanMulti: { snapshots: {}, isLoading: false }, - tasks: { - activeTaskId: null, - activeSessionId: null, - pinnedSessionId: null, - lastSessionByTaskId: {}, - }, - taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} }, - environmentIdBySessionId: {}, - walkthroughs: { - byTaskId: { - t1: { - id: "wt-1", - task_id: "t1", - title: "Walkthrough", - steps: [], - created_by: "agent", - created_at: "2026-01-01T00:00:00Z", - updated_at: "2026-01-01T00:00:00Z", - }, - t2: null, - }, - activeStepByTaskId: { t1: 2, t2: 1 }, - lastSeenUpdatedAtByTaskId: { t1: "old", t2: "keep" }, - }, - removeTaskFromSidebarPrefs: vi.fn(), - setTaskDeletedNotification: vi.fn(), - } as unknown as AppState; - - const listeners = new Set(); - return { - getState: () => state, - setState: (updater: AppState | ((s: AppState) => AppState)) => { - const next = - typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; - state = { ...state, ...next }; - for (const listener of listeners) listener(state); - }, - subscribe: (listener: Listener) => { - listeners.add(listener); - return () => listeners.delete(listener); - }, - destroy: vi.fn(), - getInitialState: vi.fn(), - } as unknown as StoreApi & { getState: () => AppState }; -} - -function makeDeletedMessage() { - return { - id: "msg-1", - type: "notification" as const, - action: "task.deleted" as const, - payload: { task_id: "t1", workflow_id: "wf1" }, - } as Parameters["task.deleted"]>>[0]; -} - -describe("task.deleted walkthrough cleanup", () => { - beforeEach(() => { - window.localStorage.clear(); - window.sessionStorage.clear(); - }); - - it("clears deleted task walkthrough state without touching other tasks", () => { - const store = makeStore(); - const handlers = registerTasksHandlers(store); - - handlers["task.deleted"]!(makeDeletedMessage()); - - const state = store.getState(); - expect(state.walkthroughs.byTaskId).not.toHaveProperty("t1"); - expect(state.walkthroughs.activeStepByTaskId).not.toHaveProperty("t1"); - expect(state.walkthroughs.lastSeenUpdatedAtByTaskId).not.toHaveProperty("t1"); - expect(state.walkthroughs.byTaskId).toHaveProperty("t2", null); - expect(state.walkthroughs.lastSeenUpdatedAtByTaskId).toHaveProperty("t2", "keep"); - }); -}); diff --git a/apps/web/lib/ws/handlers/tasks.test.ts b/apps/web/lib/ws/handlers/tasks.test.ts index 545a078285..24fc90ae68 100644 --- a/apps/web/lib/ws/handlers/tasks.test.ts +++ b/apps/web/lib/ws/handlers/tasks.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { StoreApi } from "zustand"; +import { makeQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; import { removeRecentTask } from "@/lib/recent-tasks"; import type { AppState } from "@/lib/state/store"; import { registerTasksHandlers } from "./tasks"; @@ -16,13 +18,11 @@ type Listener = (state: AppState) => void; /** * Minimal in-memory store for the tasks WS handler tests. - * The handler reads kanban tasks, kanbanMulti snapshots, and tasks.activeTaskId/activeSessionId, - * and calls setActiveSession; everything else can stay default. + * The handler reads task/session UI state and performs client-local cleanup side effects; + * everything else can stay default. */ function makeStore(initial: Partial = {}) { let state = { - kanban: { workflowId: "wf1", steps: [], tasks: [] }, - kanbanMulti: { snapshots: {}, isLoading: false }, tasks: { activeTaskId: null, activeSessionId: null, @@ -112,7 +112,6 @@ const REVIEW_TITLE = "Review PR #11259"; function makeActiveStore() { return makeStore({ - kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, tasks: { activeTaskId: "t1", activeSessionId: null, @@ -120,12 +119,11 @@ function makeActiveStore() { lastSessionByTaskId: {}, }, environmentIdBySessionId: {}, - } as unknown as Partial); + }); } function makeInactiveStore() { return makeStore({ - kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, tasks: { activeTaskId: null, activeSessionId: null, @@ -133,7 +131,13 @@ function makeInactiveStore() { lastSessionByTaskId: {}, }, environmentIdBySessionId: {}, - } as unknown as Partial); + }); +} + +function makeHandlers(store: ReturnType, cachedPrimary: string | null) { + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.tasks.detail("t1"), { primary_session_id: cachedPrimary }); + return registerTasksHandlers(store, queryClient); } describe("task.updated primary-session focus follow", () => { @@ -147,11 +151,6 @@ describe("task.updated primary-session focus follow", () => { it("follows focus to the new primary when the user is on the previous primary", () => { store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }], - } as unknown as AppState["kanban"], tasks: { activeTaskId: "t1", activeSessionId: "sess-old", @@ -161,7 +160,7 @@ describe("task.updated primary-session focus follow", () => { setActiveSessionAuto, }); - const handlers = registerTasksHandlers(store); + const handlers = makeHandlers(store, "sess-old"); handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); expect(setActiveSessionAuto).toHaveBeenCalledTimes(1); @@ -171,11 +170,6 @@ describe("task.updated primary-session focus follow", () => { it("does NOT follow focus when the user is on a different session than the previous primary", () => { // User manually selected sess-other; primary swapping shouldn't yank them away. store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }], - } as unknown as AppState["kanban"], tasks: { activeTaskId: "t1", activeSessionId: SESS_OTHER, @@ -185,7 +179,24 @@ describe("task.updated primary-session focus follow", () => { setActiveSessionAuto, }); - const handlers = registerTasksHandlers(store); + const handlers = makeHandlers(store, "sess-old"); + handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); + + expect(setActiveSessionAuto).not.toHaveBeenCalled(); + }); + + it("does NOT follow focus when the user is unpinned but not on the previous primary", () => { + store = makeStore({ + tasks: { + activeTaskId: "t1", + activeSessionId: SESS_OTHER, + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + setActiveSessionAuto, + }); + + const handlers = makeHandlers(store, "sess-old"); handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); expect(setActiveSessionAuto).not.toHaveBeenCalled(); @@ -193,11 +204,6 @@ describe("task.updated primary-session focus follow", () => { it("does NOT follow focus when the user is viewing a different task", () => { store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }], - } as unknown as AppState["kanban"], tasks: { activeTaskId: "t2", activeSessionId: "sess-old", @@ -207,7 +213,7 @@ describe("task.updated primary-session focus follow", () => { setActiveSessionAuto, }); - const handlers = registerTasksHandlers(store); + const handlers = makeHandlers(store, "sess-old"); handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); expect(setActiveSessionAuto).not.toHaveBeenCalled(); @@ -215,11 +221,6 @@ describe("task.updated primary-session focus follow", () => { it("does NOT call setActiveSessionAuto when the primary did not change", () => { store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }], - } as unknown as AppState["kanban"], tasks: { activeTaskId: "t1", activeSessionId: "sess-old", @@ -229,11 +230,54 @@ describe("task.updated primary-session focus follow", () => { setActiveSessionAuto, }); - const handlers = registerTasksHandlers(store); + const handlers = makeHandlers(store, "sess-old"); handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-old"))); expect(setActiveSessionAuto).not.toHaveBeenCalled(); }); + + it("does NOT follow focus when the old primary is absent from the task cache", () => { + store = makeStore({ + tasks: { + activeTaskId: "t1", + activeSessionId: "sess-old", + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + setActiveSessionAuto, + }); + + const handlers = registerTasksHandlers(store, makeQueryClient()); + handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); + + expect(setActiveSessionAuto).not.toHaveBeenCalled(); + }); +}); + +describe("task.updated primary-session focus follow (snapshot fallback)", () => { + it("falls back to hydrated workflow snapshots when the task detail cache is absent", () => { + const setActiveSessionAuto = vi.fn(); + const store = makeStore({ + tasks: { + activeTaskId: "t1", + activeSessionId: "sess-old", + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + setActiveSessionAuto, + }); + const queryClient = makeQueryClient(); + queryClient.setQueryData(qk.workflows.snapshot("wf1"), { + workflow: { id: "wf1" }, + tasks: [{ id: "t1", primary_session_id: "sess-old" }], + }); + + const handlers = registerTasksHandlers(store, queryClient); + handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); + + expect(setActiveSessionAuto).toHaveBeenCalledTimes(1); + expect(setActiveSessionAuto).toHaveBeenCalledWith("t1", "sess-new"); + }); }); // Regression: even when the user happens to be sitting on the previous @@ -251,11 +295,6 @@ describe("task.updated primary-session focus follow (pinning)", () => { it("does NOT follow focus when the user has pinned the previous primary", () => { store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }], - } as unknown as AppState["kanban"], tasks: { activeTaskId: "t1", activeSessionId: "sess-old", @@ -265,7 +304,7 @@ describe("task.updated primary-session focus follow (pinning)", () => { setActiveSessionAuto, }); - const handlers = registerTasksHandlers(store); + const handlers = makeHandlers(store, "sess-old"); handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); expect(setActiveSessionAuto).not.toHaveBeenCalled(); @@ -273,11 +312,6 @@ describe("task.updated primary-session focus follow (pinning)", () => { it("does NOT follow focus when active-session drift orphaned a non-terminal pin", () => { store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: SESS_DRIFTED, workflowId: "wf1" }], - } as unknown as AppState["kanban"], tasks: { activeTaskId: "t1", activeSessionId: SESS_DRIFTED, @@ -293,7 +327,7 @@ describe("task.updated primary-session focus follow (pinning)", () => { setActiveSessionAuto, }); - const handlers = registerTasksHandlers(store); + const handlers = makeHandlers(store, SESS_DRIFTED); handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); expect(setActiveSessionAuto).not.toHaveBeenCalled(); @@ -311,11 +345,6 @@ describe("task.updated primary-session focus follow (stale pin cleanup)", () => it("clears a terminal orphaned pin when following focus to the new primary", () => { store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: SESS_DRIFTED, workflowId: "wf1" }], - } as unknown as AppState["kanban"], tasks: { activeTaskId: "t1", activeSessionId: SESS_DRIFTED, @@ -331,7 +360,7 @@ describe("task.updated primary-session focus follow (stale pin cleanup)", () => setActiveSessionAuto, }); - const handlers = registerTasksHandlers(store); + const handlers = makeHandlers(store, SESS_DRIFTED); handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); expect(setActiveSessionAuto).toHaveBeenCalledWith("t1", "sess-new"); @@ -340,11 +369,6 @@ describe("task.updated primary-session focus follow (stale pin cleanup)", () => it("clears a deleted orphaned pin when following focus to the new primary", () => { store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: SESS_DRIFTED, workflowId: "wf1" }], - } as unknown as AppState["kanban"], tasks: { activeTaskId: "t1", activeSessionId: SESS_DRIFTED, @@ -366,7 +390,7 @@ describe("task.updated primary-session focus follow (stale pin cleanup)", () => setActiveSessionAuto, }); - const handlers = registerTasksHandlers(store); + const handlers = makeHandlers(store, SESS_DRIFTED); handlers["task.updated"]!(makeMessage(makeTask("t1", "sess-new"))); expect(setActiveSessionAuto).toHaveBeenCalledWith("t1", "sess-new"); @@ -374,23 +398,9 @@ describe("task.updated primary-session focus follow (stale pin cleanup)", () => }); }); -describe("task.updated cross-workflow placement", () => { - it("removes the task from its old workflow snapshot before upserting into the new one", () => { - const task = { id: "t1", title: "Test", workflowId: "wf1", workflowStepId: "step1" }; - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [task], - } as unknown as AppState["kanban"], - kanbanMulti: { - isLoading: false, - snapshots: { - wf1: { workflow: { id: "wf1" }, steps: [], tasks: [task] }, - wf2: { workflow: { id: "wf2" }, steps: [], tasks: [] }, - }, - } as unknown as AppState["kanbanMulti"], - }); +describe("task.updated Query-owned placement", () => { + it("does not expose legacy kanban mirrors when tasks move workflows", () => { + const store = makeStore(); const handlers = registerTasksHandlers(store); handlers["task.updated"]!( @@ -398,140 +408,8 @@ describe("task.updated cross-workflow placement", () => { ); const state = store.getState(); - expect(state.kanban.tasks).toHaveLength(0); - expect(state.kanbanMulti.snapshots.wf1.tasks).toHaveLength(0); - expect(state.kanbanMulti.snapshots.wf2.tasks).toHaveLength(1); - expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.id).toBe("t1"); - expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.workflowStepId).toBe("step1"); - }); -}); - -describe("task.updated repository preservation", () => { - it("preserves repository metadata when a rename update omits repo fields", () => { - const repo = { - id: "task-repo-1", - repository_id: "repo-a", - base_branch: "main", - checkout_branch: "feature/rename", - position: 0, - }; - const existingTask = { - id: "t1", - workflowStepId: "step1", - title: "Old title", - position: 0, - repositoryId: "repo-a", - repositories: [repo], - }; - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [existingTask], - } as unknown as AppState["kanban"], - kanbanMulti: { - isLoading: false, - snapshots: { - wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [existingTask] }, - }, - } as unknown as AppState["kanbanMulti"], - }); - - const handlers = registerTasksHandlers(store); - handlers["task.updated"]!( - makeMessage({ - ...makeTask("t1", null), - title: "Renamed task", - repository_id: undefined, - repositories: undefined, - }), - ); - - const state = store.getState(); - const kanbanTask = state.kanban.tasks.find((task) => task.id === "t1"); - const snapshotTask = state.kanbanMulti.snapshots.wf1.tasks.find((task) => task.id === "t1"); - expect(kanbanTask?.title).toBe("Renamed task"); - expect(kanbanTask?.repositoryId).toBe("repo-a"); - expect(kanbanTask?.repositories).toEqual([repo]); - expect(snapshotTask?.repositoryId).toBe("repo-a"); - expect(snapshotTask?.repositories).toEqual([repo]); - }); - - it("does not preserve stale repository rows when the primary repository changes", () => { - const existingTask = { - id: "t1", - workflowStepId: "step1", - title: "Old title", - position: 0, - repositoryId: "repo-a", - repositories: [ - { - id: "task-repo-1", - repository_id: "repo-a", - base_branch: "main", - position: 0, - }, - ], - }; - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [existingTask], - } as unknown as AppState["kanban"], - }); - - const handlers = registerTasksHandlers(store); - handlers["task.updated"]!( - makeMessage({ - ...makeTask("t1", null), - repository_id: "repo-b", - repositories: undefined, - }), - ); - - const task = store.getState().kanban.tasks.find((item) => item.id === "t1"); - expect(task?.repositoryId).toBe("repo-b"); - expect(task?.repositories).toBeUndefined(); - }); -}); - -describe("task.updated repository clearing", () => { - it("clears repository metadata when an update explicitly sends an empty repository list", () => { - const existingTask = { - id: "t1", - workflowStepId: "step1", - title: "Old title", - position: 0, - repositoryId: "repo-a", - repositories: [ - { - id: "task-repo-1", - repository_id: "repo-a", - base_branch: "main", - position: 0, - }, - ], - }; - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [existingTask], - } as unknown as AppState["kanban"], - }); - - const handlers = registerTasksHandlers(store); - handlers["task.updated"]!( - makeMessage({ - ...makeTask("t1", null), - repositories: [], - }), - ); - - const task = store.getState().kanban.tasks.find((item) => item.id === "t1"); - expect(task?.repositoryId).toBeUndefined(); - expect(task?.repositories).toEqual([]); + expect("kanban" in state).toBe(false); + expect("kanbanMulti" in state).toBe(false); }); }); @@ -542,11 +420,6 @@ describe("task.deleted cleanup", () => { it("removes the deleted task from recent task history", () => { const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }], - } as unknown as AppState["kanban"], environmentIdBySessionId: {}, }); @@ -564,11 +437,6 @@ describe("task.deleted cleanup", () => { it("clears deleted task session state", () => { const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: SESS_PINNED, workflowId: "wf1" }], - } as unknown as AppState["kanban"], tasks: { activeTaskId: "t1", activeSessionId: SESS_PINNED, @@ -586,6 +454,19 @@ describe("task.deleted cleanup", () => { expect(state.tasks.lastSessionByTaskId).not.toHaveProperty("t1"); expect(state.tasks.lastSessionByTaskId).toHaveProperty("t2", SESS_OTHER); }); + + it("does not expose legacy kanban mirrors when deleting tasks", () => { + const store = makeStore({ + environmentIdBySessionId: {}, + }); + + const handlers = registerTasksHandlers(store); + handlers["task.deleted"]!(makeDeletedMessage({ task_id: "t1", workflow_id: "wf1" })); + + const state = store.getState(); + expect("kanban" in state).toBe(false); + expect("kanbanMulti" in state).toBe(false); + }); }); describe("task.deleted live notification + redirect", () => { @@ -611,7 +492,6 @@ describe("task.deleted live notification + redirect", () => { it("does not notify when a non-focused task is deleted", () => { const store = makeStore({ - kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, tasks: { activeTaskId: "t2", activeSessionId: null, @@ -619,7 +499,7 @@ describe("task.deleted live notification + redirect", () => { lastSessionByTaskId: {}, }, environmentIdBySessionId: {}, - } as unknown as Partial); + }); const handlers = registerTasksHandlers(store); handlers["task.deleted"]!(makeDeletedMessage({ task_id: "t1", workflow_id: "wf1" })); @@ -642,11 +522,15 @@ describe("task.deleted live notification + redirect", () => { expect(store.getState().setTaskDeletedNotification).not.toHaveBeenCalled(); }); - // Covers both the canonical `/t/:id` and compatibility `/tasks/:id` routes, - // and the not-yet-hydrated case (activeTaskId still null) via makeInactiveStore. - it.each(["/t/t1", "/tasks/t1"])( - "redirects home and notifies when parked on %s before activeTaskId hydrates", - (path) => { + // Covers the canonical `/t/:id`, compatibility `/tasks/:id`, and Office + // detail routes, plus the not-yet-hydrated case (activeTaskId still null). + it.each([ + ["/t/t1", "/"], + ["/tasks/t1", "/"], + ["/office/tasks/t1", "/office/tasks"], + ])( + "redirects and notifies when parked on %s before activeTaskId hydrates", + (path, expectedPath) => { window.history.replaceState({}, "", path); const store = makeInactiveStore(); const handlers = registerTasksHandlers(store); @@ -660,7 +544,7 @@ describe("task.deleted live notification + redirect", () => { }), ); - expect(window.location.pathname).toBe("/"); + expect(window.location.pathname).toBe(expectedPath); expect(store.getState().setTaskDeletedNotification).toHaveBeenCalledWith({ taskId: "t1", title: REVIEW_TITLE, diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index be674cf5b5..bf71c46530 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -1,266 +1,42 @@ +import type { QueryClient } from "@tanstack/react-query"; import type { StoreApi } from "zustand"; -import { createDebugLogger, isDebug } from "@/lib/debug/log"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { KanbanState } from "@/lib/state/slices/kanban/types"; import { cleanupTaskStorage } from "@/lib/local-storage"; +import { getBrowserQueryClient } from "@/lib/query/client"; +import { qk } from "@/lib/query/keys"; +import { workflowSnapshotQueryData } from "@/lib/query/workflow-snapshot-cache"; import { removeRecentTask } from "@/lib/recent-tasks"; import { useContextFilesStore } from "@/lib/state/context-files-store"; -import { toKanbanTask, type TaskLike } from "@/lib/kanban/map-task"; -import { sessionId as toSessionId } from "@/lib/types/http"; -import { mergeTaskRepositoryFields } from "@/lib/ws/handlers/task-repositories"; import { softNavigate } from "@/lib/routing/client-router"; import { isTaskDetailPath, normalizePathname } from "@/lib/links"; import { clearPinnedSessionIfOverridden, shouldPreservePinnedSessionForTask, } from "@/lib/ws/handlers/agent-session"; +import type { AppState } from "@/lib/state/store"; +import type { Task } from "@/lib/types/http"; +import type { WsHandlers } from "@/lib/ws/handlers/types"; -type KanbanTask = KanbanState["tasks"][number]; -const lifecycleDebug = createDebugLogger("task-lifecycle:ws"); - -function hasPayloadField(payload: TaskEventPayload, field: keyof TaskEventPayload): boolean { - return Object.prototype.hasOwnProperty.call(payload, field); -} - -function mergeTaskUpdate( - existing: KanbanTask | undefined, - nextTask: KanbanTask, - payload: TaskEventPayload, -): KanbanTask { - if (!existing) return nextTask; - const merged = { - ...nextTask, - ...mergeTaskRepositoryFields(existing, nextTask), - }; - if (!hasPayloadField(payload, "primary_session_id") && nextTask.primarySessionId === undefined) { - merged.primarySessionId = existing.primarySessionId; - } - if ( - !hasPayloadField(payload, "primary_session_state") && - nextTask.primarySessionState === undefined - ) { - merged.primarySessionState = existing.primarySessionState; - } - if ( - !hasPayloadField(payload, "primary_session_pending_action") && - nextTask.primarySessionPendingAction === undefined - ) { - merged.primarySessionPendingAction = existing.primarySessionPendingAction; - } - return merged; -} - -function upsertTask( - tasks: KanbanTask[], - nextTask: KanbanTask, - payload: TaskEventPayload, -): KanbanTask[] { - const existing = tasks.find((task) => task.id === nextTask.id); - const merged = mergeTaskUpdate(existing, nextTask, payload); - return existing - ? tasks.map((task) => (task.id === nextTask.id ? merged : task)) - : [...tasks, merged]; -} - -function upsertMultiTask( - state: AppState, - workflowId: string, - task: KanbanTask, - payload: TaskEventPayload, -): AppState { - const snapshot = state.kanbanMulti.snapshots[workflowId]; - if (!snapshot) { - const workflowName = - state.workflows?.items.find((item) => item.id === workflowId)?.name ?? workflowId; - return { - ...state, - kanbanMulti: { - ...state.kanbanMulti, - snapshots: { - ...state.kanbanMulti.snapshots, - [workflowId]: { - workflowId, - workflowName, - steps: [], - tasks: upsertTask([], task, payload), - isPlaceholder: true, - }, - }, - }, - }; - } - return { - ...state, - kanbanMulti: { - ...state.kanbanMulti, - snapshots: { - ...state.kanbanMulti.snapshots, - [workflowId]: { - ...snapshot, - tasks: upsertTask(snapshot.tasks, task, payload), - }, - }, - }, - }; -} - -type TaskEventPayload = TaskLike & { - workflow_id: string; - old_workflow_id?: string | null; +type TaskUpdatedPayload = { + task_id: string; + primary_session_id?: string | null; is_ephemeral?: boolean; archived_at?: string | null; }; -/** Upsert a task in both single-kanban and multi-kanban snapshots. */ -function upsertTaskInBothKanbans( - state: AppState, - wfId: string, - payload: TaskEventPayload, -): AppState { - // Skip ephemeral tasks - they should never be added to kanban - if (payload.is_ephemeral) { - return state; - } - - const nextTask = toKanbanTask(payload); - let next = state; - - if (state.kanban.workflowId === wfId) { - next = { - ...next, - kanban: { ...next.kanban, tasks: upsertTask(next.kanban.tasks, nextTask, payload) }, - }; - } - - next = upsertMultiTask(next, wfId, nextTask, payload); - - return next; -} - -/** Look up a task across both single-kanban and multi-kanban snapshots. */ -function findTaskInState(state: AppState, taskId: string): KanbanTask | undefined { - const fromKanban = state.kanban.tasks.find((t) => t.id === taskId); - if (fromKanban) return fromKanban; - for (const snapshot of Object.values(state.kanbanMulti.snapshots)) { - const found = snapshot.tasks.find((t) => t.id === taskId); - if (found) return found; - } - return undefined; -} - -function taskEventIdForLog(payload: TaskEventPayload): string { - return payload.task_id ?? payload.id ?? ""; -} - -function valueForLog(value: string | null | undefined): string { - return value ?? "-"; -} - -function payloadPrimaryStateForLog(payload: TaskEventPayload): string { - if (payload.primary_session_state === undefined) return "-"; - return payload.primary_session_state ?? "null"; -} - -function taskStateForLog(task: KanbanTask | undefined): string { - return task?.state ?? "-"; -} - -function taskPrimaryStateForLog(task: KanbanTask | undefined): string { - return task?.primarySessionState ?? "-"; -} - -export function didPreservePrimaryState( - payload: TaskEventPayload, - beforeTask: KanbanTask | undefined, - afterTask: KanbanTask | undefined, -): boolean { - if (payload.primary_session_state !== undefined) return false; - const previousPrimaryState = beforeTask?.primarySessionState; - if (previousPrimaryState === undefined) return false; - return previousPrimaryState === afterTask?.primarySessionState; -} - -function logTaskMerge( - action: "task.created" | "task.updated" | "task.state_changed", - beforeState: AppState, - afterState: AppState, - payload: TaskEventPayload, -): void { - if (!isDebug()) return; - const taskId = taskEventIdForLog(payload); - const beforeTask = findTaskInState(beforeState, taskId); - const afterTask = findTaskInState(afterState, taskId); - lifecycleDebug(`${action} merge`, { - task_id: taskId, - payloadState: valueForLog(payload.state), - payloadPrimarySessionId: valueForLog(payload.primary_session_id), - payloadPrimarySessionState: payloadPrimaryStateForLog(payload), - beforeTaskState: taskStateForLog(beforeTask), - beforeTaskPrimaryState: taskPrimaryStateForLog(beforeTask), - afterTaskState: taskStateForLog(afterTask), - afterTaskPrimaryState: taskPrimaryStateForLog(afterTask), - preservedPrimaryState: didPreservePrimaryState(payload, beforeTask, afterTask), - }); -} - -/** Remove a task from both single-kanban and multi-kanban snapshots. */ -function removeTaskFromBothKanbans(state: AppState, taskId: string): AppState { - let next = state; - if (state.kanban.tasks.some((t) => t.id === taskId)) { - next = { - ...next, - kanban: { ...next.kanban, tasks: next.kanban.tasks.filter((t) => t.id !== taskId) }, - }; - } - - const snapshots = Object.entries(next.kanbanMulti.snapshots); - const changedSnapshots = snapshots.filter(([, snapshot]) => - snapshot.tasks.some((t) => t.id === taskId), - ); - if (changedSnapshots.length > 0) { - const nextSnapshots = { ...next.kanbanMulti.snapshots }; - for (const [workflowId, snapshot] of changedSnapshots) { - nextSnapshots[workflowId] = { - ...snapshot, - tasks: snapshot.tasks.filter((t) => t.id !== taskId), - }; - } - next = { - ...next, - kanbanMulti: { - ...next.kanbanMulti, - snapshots: nextSnapshots, - }, - }; - } - return next; -} +type TaskDeletedPayload = { + task_id: string; + title?: string; + reason?: string; +}; -function clearRemovedTaskSelection(state: AppState, taskId: string): AppState { - let next = state; - if (next.tasks.activeTaskId === taskId) { - next = { - ...next, - tasks: { - ...next.tasks, - activeTaskId: null, - activeSessionId: null, - pinnedSessionId: null, - }, - }; - } - if (next.tasks.lastSessionByTaskId[taskId]) { - const { [taskId]: _, ...rest } = next.tasks.lastSessionByTaskId; - next = { ...next, tasks: { ...next.tasks, lastSessionByTaskId: rest } }; +function clearDeletedTaskWalkthroughClientState(state: AppState, taskId: string): AppState { + if (!state.walkthroughs) return state; + if ( + !(taskId in state.walkthroughs.activeStepByTaskId) && + !(taskId in state.walkthroughs.lastSeenUpdatedAtByTaskId) + ) { + return state; } - return next; -} - -function clearDeletedTaskWalkthrough(state: AppState, taskId: string): AppState { - if (!state.walkthroughs?.byTaskId) return state; - if (!(taskId in state.walkthroughs.byTaskId)) return state; - const { [taskId]: _removedWalkthrough, ...byTaskId } = state.walkthroughs.byTaskId; const { [taskId]: _removedStep, ...activeStepByTaskId } = state.walkthroughs.activeStepByTaskId; const { [taskId]: _removedLastSeen, ...lastSeenUpdatedAtByTaskId } = state.walkthroughs.lastSeenUpdatedAtByTaskId; @@ -268,7 +44,6 @@ function clearDeletedTaskWalkthrough(state: AppState, taskId: string): AppState ...state, walkthroughs: { ...state.walkthroughs, - byTaskId, activeStepByTaskId, lastSeenUpdatedAtByTaskId, }, @@ -293,160 +68,168 @@ function redirectAwayFromRemovedTask(taskId: string): void { softNavigate(href, "replace"); } -type TaskUpdatedMessage = Parameters>[0]; -type TaskCreatedMessage = Parameters>[0]; -type TaskStateChangedMessage = Parameters>[0]; -type TaskUpsertMessage = TaskCreatedMessage | TaskStateChangedMessage; -type TaskUpsertAction = "task.created" | "task.state_changed"; - -function handleTaskUpdated(store: StoreApi, message: TaskUpdatedMessage): void { - // Skip ephemeral tasks (e.g., quick chat) - they shouldn't appear on the Kanban board - if (message.payload.is_ephemeral) return; - - // Capture the previous primary session id BEFORE the upsert so we can - // detect a primary-session swap (e.g. workflow profile switch reusing a - // different session) and follow focus to the new primary. - const beforeState = store.getState(); - const taskId = message.payload.task_id; - const previousPrimary = findTaskInState(beforeState, taskId)?.primarySessionId ?? null; - const archivedAt = message.payload.archived_at; +export function registerTasksHandlers( + store: StoreApi, + queryClient: QueryClient = getBrowserQueryClient(), +): WsHandlers { + return { + "task.updated": (message) => { + const payload = message.payload as TaskUpdatedPayload; + if (payload.archived_at) { + handleTaskArchived(store, payload); + return; + } + maybeFollowPrimarySession(store, queryClient, payload); + }, + "task.deleted": (message) => { + handleTaskDeleted(store, message.payload as TaskDeletedPayload); + }, + }; +} - if (archivedAt) { - removeRecentTask(taskId); - const state = store.getState(); - state.removeTaskFromSidebarPrefs(taskId); - state.setOfficeRefetchTrigger("tasks"); +function maybeFollowPrimarySession( + store: StoreApi, + queryClient: QueryClient, + payload: TaskUpdatedPayload, +): void { + if (payload.is_ephemeral) return; + const taskId = payload.task_id; + const newPrimary = payload.primary_session_id ?? null; + if (!taskId || !newPrimary) return; + + const state = store.getState(); + const previousPrimary = cachedPrimarySessionId(queryClient, taskId); + if (previousPrimary === undefined) return; + if ( + newPrimary !== previousPrimary && + state.tasks.activeTaskId === taskId && + state.tasks.activeSessionId === previousPrimary && + !shouldPreservePinnedSessionForTask(state, taskId) + ) { + clearPinnedSessionIfOverridden(store, newPrimary); + state.setActiveSessionAuto(taskId, newPrimary); } +} - store.setState((state) => { - const wfId = message.payload.workflow_id; - const oldWfId = message.payload.old_workflow_id; - let next = state; - - if (archivedAt || (oldWfId && oldWfId !== wfId)) { - next = removeTaskFromBothKanbans(next, taskId); +function cachedPrimarySessionId( + queryClient: QueryClient, + taskId: string, +): string | null | undefined { + const cached = queryClient.getQueryData>( + qk.tasks.detail(taskId), + ); + if (cached && Object.prototype.hasOwnProperty.call(cached, "primary_session_id")) { + return cached.primary_session_id ?? null; + } + for (const snapshot of workflowSnapshotQueryData(queryClient)) { + const task = snapshot.tasks.find((candidate) => candidate.id === taskId); + if (task && Object.prototype.hasOwnProperty.call(task, "primary_session_id")) { + return task.primary_session_id ?? null; } + } + return undefined; +} - if (archivedAt) { - return clearRemovedTaskSelection(next, taskId); - } +function handleTaskDeleted(store: StoreApi, payload: TaskDeletedPayload): void { + const deletedId = payload.task_id; + if (!deletedId) return; + + const currentState = store.getState(); + const wasActive = currentState.tasks.activeTaskId === deletedId; + // Capture the route match before any redirect mutates the pathname. This + // covers a fresh load where the browser is parked on the task's route + // (`/t/`, `/tasks/`, or `/office/tasks/`) but TaskPageContent + // hasn't hydrated `activeTaskId` yet, so `wasActive` is still false. + const onDeletedRoute = + typeof window !== "undefined" && + removedTaskRedirectHref(window.location.pathname, deletedId) !== null; + cleanupRemovedTaskClientState(store, deletedId); + + // Only react to genuine auto-deletions, which the backend tags with a + // reason (e.g. a review task whose PR was approved). User-initiated deletes + // carry no reason: their local delete flow owns navigation by switching to + // the next task, so redirecting here would preempt it. + if (payload.reason && (wasActive || onDeletedRoute)) { + redirectAwayFromRemovedTask(deletedId); + store.getState().setTaskDeletedNotification({ + taskId: deletedId, + title: payload.title, + reason: payload.reason, + }); + } +} - return upsertTaskInBothKanbans(next, wfId, message.payload); - }); +function handleTaskArchived(store: StoreApi, payload: TaskUpdatedPayload): void { + const archivedId = payload.task_id; + if (!archivedId) return; - if (archivedAt) { - redirectAwayFromRemovedTask(taskId); - return; - } + const currentState = store.getState(); + const wasActive = currentState.tasks.activeTaskId === archivedId; + const onArchivedRoute = + typeof window !== "undefined" && + removedTaskRedirectHref(window.location.pathname, archivedId) !== null; - // Follow focus to the new primary when: - // - the user is currently viewing this task, - // - the user was sitting on the previous primary, - // - they do NOT have a non-terminal pinned session for this task, and - // - the primary actually changed. - // This makes workflow profile switches transparent for unpinned users - // without yanking users off a live session they deliberately selected. - const afterState = store.getState(); - logTaskMerge("task.updated", beforeState, afterState, message.payload); - const newPrimary = findTaskInState(afterState, taskId)?.primarySessionId ?? null; - if ( - newPrimary && - newPrimary !== previousPrimary && - afterState.tasks.activeTaskId === taskId && - afterState.tasks.activeSessionId === previousPrimary && - !shouldPreservePinnedSessionForTask(afterState, taskId) - ) { - clearPinnedSessionIfOverridden(store, newPrimary); - afterState.setActiveSessionAuto(taskId, newPrimary); + cleanupRemovedTaskClientState(store, archivedId); + + if (wasActive || onArchivedRoute) { + redirectAwayFromRemovedTask(archivedId); } } -function handleTaskUpsert( - action: TaskUpsertAction, - store: StoreApi, - message: TaskUpsertMessage, -): void { - // Skip ephemeral tasks (e.g., quick chat) - they shouldn't appear on the Kanban board - if (message.payload.is_ephemeral) return; +function cleanupRemovedTaskClientState(store: StoreApi, taskId: string): void { + removeRecentTask(taskId); + + const currentState = store.getState(); + const sessionIds = sessionIdsForDeletedTask(currentState, taskId); + const envIds = environmentIdsForSessions(currentState, sessionIds); + cleanupTaskStorage(taskId, sessionIds, envIds); + currentState.removeTaskFromSidebarPrefs(taskId); + for (const sid of sessionIds) { + useContextFilesStore.getState().clearSession(sid); + } - const beforeState = store.getState(); store.setState((state) => - upsertTaskInBothKanbans(state, message.payload.workflow_id, message.payload), + clearDeletedTaskWalkthroughClientState(cleanupRemovedTaskSelectionState(state, taskId), taskId), ); - logTaskMerge(action, beforeState, store.getState(), message.payload); } -export function registerTasksHandlers(store: StoreApi): WsHandlers { - return { - "task.created": (message) => { - handleTaskUpsert("task.created", store, message); - }, - "task.updated": (message) => handleTaskUpdated(store, message), - "task.deleted": (message) => { - const deletedId = message.payload.task_id; - removeRecentTask(deletedId); - - const currentState = store.getState(); - const sessionIds = (currentState.taskSessionsByTask.itemsByTaskId[deletedId] ?? []).map( - (s) => s.id, - ); - const task = currentState.kanban.tasks.find((t) => t.id === deletedId); - if (task?.primarySessionId) { - const primaryId = toSessionId(task.primarySessionId); - if (!sessionIds.includes(primaryId)) { - sessionIds.push(primaryId); - } - } - const envIds = Array.from( - new Set( - sessionIds - .map((sid) => currentState.environmentIdBySessionId[sid]) - .filter((eid): eid is string => Boolean(eid)), - ), - ); - cleanupTaskStorage(deletedId, sessionIds, envIds); - // Keep the in-memory sidebar pin/order arrays in sync — without this, - // a later togglePinnedTask / setSidebarTaskOrder would persist the - // stale state (still containing the deleted ID) back to localStorage. - currentState.removeTaskFromSidebarPrefs(deletedId); - for (const sid of sessionIds) { - useContextFilesStore.getState().clearSession(sid); - } - - const wasActive = currentState.tasks.activeTaskId === deletedId; - - store.setState((state) => - clearDeletedTaskWalkthrough( - clearRemovedTaskSelection(removeTaskFromBothKanbans(state, deletedId), deletedId), - deletedId, - ), - ); +function sessionIdsForDeletedTask(state: AppState, taskId: string): string[] { + const ids = new Set( + (state.taskSessionsByTask?.itemsByTaskId[taskId] ?? []).map((session) => session.id), + ); + for (const session of Object.values(state.taskSessions?.items ?? {})) { + if (session.task_id === taskId) ids.add(session.id); + } + return [...ids]; +} - // Capture the route match before any redirect mutates the pathname. This - // covers a fresh load where the browser is parked on the task's route - // but TaskPageContent hasn't hydrated `activeTaskId` yet, so `wasActive` - // is still false. - const onDeletedRoute = - typeof window !== "undefined" && - removedTaskRedirectHref(window.location.pathname, deletedId) !== null; +function environmentIdsForSessions(state: AppState, sessionIds: string[]): string[] { + return Array.from( + new Set( + sessionIds + .map((sid) => state.environmentIdBySessionId[sid]) + .filter((eid): eid is string => Boolean(eid)), + ), + ); +} - // Only react to genuine auto-deletions, which the backend tags with a - // reason (e.g. a review task whose PR was approved). User-initiated deletes - // carry no reason: their local delete flow (useTaskRemoval) owns - // navigation by switching to the next task, so redirecting here would - // preempt it and strand the user on the home route. For auto-deletions we - // move off the now-dead route (helper is route-guarded) and explain why. - if (message.payload.reason && (wasActive || onDeletedRoute)) { - redirectAwayFromRemovedTask(deletedId); - store.getState().setTaskDeletedNotification({ - taskId: deletedId, - title: message.payload.title, - reason: message.payload.reason, - }); - } - }, - "task.state_changed": (message) => { - handleTaskUpsert("task.state_changed", store, message); - }, - }; +function cleanupRemovedTaskSelectionState(state: AppState, deletedId: string): AppState { + let next = state; + if (state.tasks.activeTaskId === deletedId) { + next = { + ...next, + tasks: { + ...next.tasks, + activeTaskId: null, + activeSessionId: null, + pinnedSessionId: null, + }, + }; + } + if (next.tasks.lastSessionByTaskId[deletedId]) { + const rest = { ...next.tasks.lastSessionByTaskId }; + delete rest[deletedId]; + next = { ...next, tasks: { ...next.tasks, lastSessionByTaskId: rest } }; + } + return next; } diff --git a/apps/web/lib/ws/handlers/terminals.ts b/apps/web/lib/ws/handlers/terminals.ts index 571f591865..b9178fd1de 100644 --- a/apps/web/lib/ws/handlers/terminals.ts +++ b/apps/web/lib/ws/handlers/terminals.ts @@ -4,9 +4,6 @@ import type { WsHandlers } from "@/lib/ws/handlers/types"; export function registerTerminalsHandlers(store: StoreApi): WsHandlers { return { - "terminal.output": (message) => { - store.getState().setTerminalOutput(message.payload.terminalId, message.payload.data); - }, "session.shell.output": (message) => { const { session_id, type, data } = message.payload; if (!session_id) { diff --git a/apps/web/lib/ws/handlers/walkthroughs.test.ts b/apps/web/lib/ws/handlers/walkthroughs.test.ts deleted file mode 100644 index 451faa3c79..0000000000 --- a/apps/web/lib/ws/handlers/walkthroughs.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; - -import { registerWalkthroughsHandlers } from "./walkthroughs"; - -const TASK_ID = "task-1"; -const ACTION_CREATED = "task.walkthrough.created"; -const ACTION_UPDATED = "task.walkthrough.updated"; -const ACTION_DELETED = "task.walkthrough.deleted"; - -function makePayload(overrides: Partial> = {}) { - return { - id: "wt-1", - task_id: TASK_ID, - title: "Walkthrough", - steps: [{ file: "a.go", line: 1, text: "first" }], - created_by: "agent", - created_at: "2026-04-20T00:00:00Z", - updated_at: "2026-04-20T00:00:00Z", - ...overrides, - }; -} - -function makeMessage(action: string, payload: Record) { - return { id: "msg-1", type: "notification", action, payload }; -} - -function makeStore() { - const state = { - tasks: { activeTaskId: TASK_ID, activeSessionId: "s-1" }, - walkthroughs: { byTaskId: {}, activeStepByTaskId: {}, lastSeenUpdatedAtByTaskId: {} }, - setWalkthrough: vi.fn(), - markWalkthroughSeen: vi.fn(), - }; - return { - getState: () => state as unknown as AppState, - setState: vi.fn(), - subscribe: vi.fn(), - destroy: vi.fn(), - getInitialState: vi.fn(), - } as unknown as StoreApi; -} - -describe("task.walkthrough.* handlers", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("created: stores the walkthrough with its steps", () => { - const store = makeStore(); - const handlers = registerWalkthroughsHandlers(store); - - handlers[ACTION_CREATED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_CREATED, makePayload()) as any, - ); - - expect(store.getState().setWalkthrough).toHaveBeenCalledWith( - TASK_ID, - expect.objectContaining({ id: "wt-1", steps: expect.any(Array) }), - ); - expect(store.getState().markWalkthroughSeen).not.toHaveBeenCalled(); - }); - - it("updated: re-stores the walkthrough", () => { - const store = makeStore(); - const handlers = registerWalkthroughsHandlers(store); - - handlers[ACTION_UPDATED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_UPDATED, makePayload({ title: "v2" })) as any, - ); - - expect(store.getState().setWalkthrough).toHaveBeenCalledWith( - TASK_ID, - expect.objectContaining({ title: "v2" }), - ); - }); - - it("deleted: clears the walkthrough and marks seen", () => { - const store = makeStore(); - const handlers = registerWalkthroughsHandlers(store); - - handlers[ACTION_DELETED]!( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - makeMessage(ACTION_DELETED, makePayload()) as any, - ); - - expect(store.getState().setWalkthrough).toHaveBeenCalledWith(TASK_ID, null); - expect(store.getState().markWalkthroughSeen).toHaveBeenCalledWith(TASK_ID); - }); -}); diff --git a/apps/web/lib/ws/handlers/walkthroughs.ts b/apps/web/lib/ws/handlers/walkthroughs.ts deleted file mode 100644 index c596d1b05e..0000000000 --- a/apps/web/lib/ws/handlers/walkthroughs.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { BackendMessageMap } from "@/lib/types/backend"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; - -type WalkthroughMessage = - | BackendMessageMap["task.walkthrough.created"] - | BackendMessageMap["task.walkthrough.updated"]; - -function handleWalkthroughUpsert(store: StoreApi, message: WalkthroughMessage) { - const { task_id, id, title, steps, created_by, created_at, updated_at } = message.payload; - store.getState().setWalkthrough(task_id, { - id, - task_id, - title, - steps, - created_by, - created_at, - updated_at, - }); -} - -export function registerWalkthroughsHandlers(store: StoreApi): WsHandlers { - return { - "task.walkthrough.created": (message) => handleWalkthroughUpsert(store, message), - "task.walkthrough.updated": (message) => handleWalkthroughUpsert(store, message), - "task.walkthrough.deleted": (message) => { - store.getState().setWalkthrough(message.payload.task_id, null); - store.getState().markWalkthroughSeen(message.payload.task_id); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/workflows.test.ts b/apps/web/lib/ws/handlers/workflows.test.ts deleted file mode 100644 index 85bed1a19e..0000000000 --- a/apps/web/lib/ws/handlers/workflows.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { describe, it, expect } from "vitest"; -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { BackendMessageMap, WorkflowPayload } from "@/lib/types/backend"; -import { registerWorkflowsHandlers } from "./workflows"; - -type WorkflowItem = { id: string; workspaceId: string; name: string; hidden?: boolean }; - -function makeStore(items: WorkflowItem[], activeId: string | null) { - let state = { - workflows: { items, activeId }, - workspaces: { activeId: "ws-1" }, - kanban: { workflowId: null, steps: [], tasks: [] }, - } as unknown as AppState; - - return { - getState: () => state, - setState: (updater: AppState | ((s: AppState) => AppState)) => { - state = - typeof updater === "function" ? (updater as (s: AppState) => AppState)(state) : updater; - }, - subscribe: () => () => {}, - destroy: () => {}, - getInitialState: () => state, - } as unknown as StoreApi; -} - -function updatedMessage(payload: WorkflowPayload): BackendMessageMap["workflow.updated"] { - return { - id: "msg-1", - type: "notification", - action: "workflow.updated", - payload, - timestamp: "2026-01-01T00:00:00Z", - }; -} - -function createdMessage(payload: WorkflowPayload): BackendMessageMap["workflow.created"] { - return { - id: "msg-1", - type: "notification", - action: "workflow.created", - payload, - timestamp: "2026-01-01T00:00:00Z", - }; -} - -function stepUpdatedMessage( - step: BackendMessageMap["workflow.step.updated"]["payload"]["step"], -): BackendMessageMap["workflow.step.updated"] { - return { - id: "msg-1", - type: "notification", - action: "workflow.step.updated", - payload: { step }, - timestamp: "2026-01-01T00:00:00Z", - }; -} - -describe("workflow.created handler — preserves user filter", () => { - it("does not promote a new workflow when activeId is null ('All Workflows')", () => { - const store = makeStore( - [{ id: "wf-1", workspaceId: "ws-1", name: "Existing", hidden: false }], - null, - ); - const handlers = registerWorkflowsHandlers(store); - - handlers["workflow.created"]?.( - createdMessage({ id: "wf-2", workspace_id: "ws-1", name: "Brand New" }), - ); - - expect(store.getState().workflows.activeId).toBeNull(); - expect(store.getState().workflows.items.map((i) => i.id)).toEqual(["wf-2", "wf-1"]); - }); - - it("leaves an existing activeId untouched when a new workflow appears", () => { - const store = makeStore( - [{ id: "wf-1", workspaceId: "ws-1", name: "Existing", hidden: false }], - "wf-1", - ); - const handlers = registerWorkflowsHandlers(store); - - handlers["workflow.created"]?.( - createdMessage({ id: "wf-2", workspace_id: "ws-1", name: "Brand New" }), - ); - - expect(store.getState().workflows.activeId).toBe("wf-1"); - }); -}); - -describe("workflow.updated handler — hidden flag reconciles activeId", () => { - it("clears activeId to next visible workflow when active becomes hidden", () => { - const store = makeStore( - [ - { id: "wf-1", workspaceId: "ws-1", name: "Improve Kandev", hidden: false }, - { id: "wf-2", workspaceId: "ws-1", name: "Default", hidden: false }, - ], - "wf-1", - ); - const handlers = registerWorkflowsHandlers(store); - - handlers["workflow.updated"]?.( - updatedMessage({ id: "wf-1", workspace_id: "ws-1", name: "Improve Kandev", hidden: true }), - ); - - expect(store.getState().workflows.activeId).toBe("wf-2"); - expect(store.getState().workflows.items.find((i) => i.id === "wf-1")?.hidden).toBe(true); - }); - - it("clears activeId to null when no visible workflow remains", () => { - const store = makeStore( - [{ id: "wf-1", workspaceId: "ws-1", name: "Only One", hidden: false }], - "wf-1", - ); - const handlers = registerWorkflowsHandlers(store); - - handlers["workflow.updated"]?.( - updatedMessage({ id: "wf-1", workspace_id: "ws-1", name: "Only One", hidden: true }), - ); - - expect(store.getState().workflows.activeId).toBeNull(); - }); - - it("leaves activeId untouched when a non-active workflow becomes hidden", () => { - const store = makeStore( - [ - { id: "wf-1", workspaceId: "ws-1", name: "Active", hidden: false }, - { id: "wf-2", workspaceId: "ws-1", name: "Other", hidden: false }, - ], - "wf-1", - ); - const handlers = registerWorkflowsHandlers(store); - - handlers["workflow.updated"]?.( - updatedMessage({ id: "wf-2", workspace_id: "ws-1", name: "Other", hidden: true }), - ); - - expect(store.getState().workflows.activeId).toBe("wf-1"); - }); - - it("leaves activeId untouched when payload omits hidden", () => { - const store = makeStore( - [{ id: "wf-1", workspaceId: "ws-1", name: "Old Name", hidden: false }], - "wf-1", - ); - const handlers = registerWorkflowsHandlers(store); - - handlers["workflow.updated"]?.( - updatedMessage({ id: "wf-1", workspace_id: "ws-1", name: "New Name" }), - ); - - expect(store.getState().workflows.activeId).toBe("wf-1"); - expect(store.getState().workflows.items[0]?.name).toBe("New Name"); - }); -}); - -describe("workflow step handlers", () => { - it("preserves WIP fields from step update payloads", () => { - const store = makeStore([{ id: "wf-1", workspaceId: "ws-1", name: "Workflow" }], "wf-1"); - store.setState({ - ...store.getState(), - kanban: { - workflowId: "wf-1", - steps: [{ id: "step-1", title: "Review", color: "bg-blue-500", position: 1 }], - tasks: [], - }, - } as AppState); - const handlers = registerWorkflowsHandlers(store); - - handlers["workflow.step.updated"]?.( - stepUpdatedMessage({ - id: "step-1", - workflow_id: "wf-1", - name: "Review", - state: "", - position: 1, - color: "bg-blue-500", - wip_limit: 2, - pull_from_step_id: "step-0", - }), - ); - - expect(store.getState().kanban.steps[0]).toMatchObject({ - wip_limit: 2, - pull_from_step_id: "step-0", - }); - }); -}); diff --git a/apps/web/lib/ws/handlers/workflows.ts b/apps/web/lib/ws/handlers/workflows.ts deleted file mode 100644 index 79f6eba1ca..0000000000 --- a/apps/web/lib/ws/handlers/workflows.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; -import type { WorkflowPayload } from "@/lib/types/backend"; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function stepFromPayload(step: any) { - return { - id: step.id as string, - title: (step.name ?? step.title) as string, - color: (step.color ?? "bg-neutral-400") as string, - position: (step.position ?? 0) as number, - events: step.events, - show_in_command_panel: step.show_in_command_panel, - allow_manual_move: step.allow_manual_move, - prompt: step.prompt, - is_start_step: step.is_start_step, - agent_profile_id: step.agent_profile_id, - wip_limit: step.wip_limit, - pull_from_step_id: step.pull_from_step_id ?? null, - stage_type: step.stage_type, - }; -} - -function applyWorkflowCreated(state: AppState, payload: WorkflowPayload): AppState { - if (state.workspaces.activeId !== payload.workspace_id) return state; - if (state.workflows.items.some((item) => item.id === payload.id)) return state; - const isHidden = Boolean(payload.hidden); - // Never use `??` here: null is a valid "All Workflows" selection, not a missing value. - return { - ...state, - workflows: { - items: [ - { - id: payload.id, - workspaceId: payload.workspace_id, - name: payload.name, - hidden: isHidden, - style: payload.style, - }, - ...state.workflows.items, - ], - activeId: state.workflows.activeId, - }, - }; -} - -function applyWorkflowUpdated(state: AppState, payload: WorkflowPayload): AppState { - const items = state.workflows.items.map((item) => - item.id === payload.id - ? { - ...item, - name: payload.name, - agent_profile_id: payload.agent_profile_id, - hidden: payload.hidden !== undefined ? Boolean(payload.hidden) : item.hidden, - style: payload.style ?? item.style, - } - : item, - ); - // If the active workflow just became hidden, fall back to the next visible - // entry so the kanban / picker isn't left bound to a workflow the user can - // no longer reach (the backend fires `workflow.updated`, not `workflow.deleted`, - // when `SetWorkflowHidden` flips the flag). - const activeBecameHidden = state.workflows.activeId === payload.id && payload.hidden === true; - const nextActiveId = activeBecameHidden - ? (items.find((item) => !item.hidden)?.id ?? null) - : state.workflows.activeId; - return { - ...state, - workflows: { - ...state.workflows, - activeId: nextActiveId, - items, - }, - }; -} - -export function registerWorkflowsHandlers(store: StoreApi): WsHandlers { - return { - "workflow.created": (message) => { - store.setState((state) => applyWorkflowCreated(state, message.payload)); - }, - "workflow.updated": (message) => { - store.setState((state) => applyWorkflowUpdated(state, message.payload)); - }, - "workflow.deleted": (message) => { - store.setState((state) => { - const items = state.workflows.items.filter((item) => item.id !== message.payload.id); - const nextActiveId = - state.workflows.activeId === message.payload.id - ? (items[0]?.id ?? null) - : state.workflows.activeId; - return { - ...state, - workflows: { - items, - activeId: nextActiveId, - }, - kanban: - state.kanban.workflowId === message.payload.id - ? { workflowId: nextActiveId, steps: [], tasks: [] } - : state.kanban, - }; - }); - }, - "workflow.step.created": (message) => { - const step = message.payload.step; - store.setState((state) => { - if (state.kanban.workflowId !== step.workflow_id) return state; - if (state.kanban.steps.some((s) => s.id === step.id)) return state; - const steps = [...state.kanban.steps, stepFromPayload(step)].sort( - (a, b) => a.position - b.position, - ); - return { ...state, kanban: { ...state.kanban, steps } }; - }); - }, - "workflow.step.updated": (message) => { - const step = message.payload.step; - store.setState((state) => { - if (state.kanban.workflowId !== step.workflow_id) return state; - const steps = state.kanban.steps - .map((s) => (s.id === step.id ? stepFromPayload(step) : s)) - .sort((a, b) => a.position - b.position); - return { ...state, kanban: { ...state.kanban, steps } }; - }); - }, - "workflow.step.deleted": (message) => { - const step = message.payload.step; - store.setState((state) => { - if (state.kanban.workflowId !== step.workflow_id) return state; - const steps = state.kanban.steps.filter((s) => s.id !== step.id); - return { ...state, kanban: { ...state.kanban, steps } }; - }); - }, - }; -} diff --git a/apps/web/lib/ws/handlers/workspaces.ts b/apps/web/lib/ws/handlers/workspaces.ts deleted file mode 100644 index 0a1299e41d..0000000000 --- a/apps/web/lib/ws/handlers/workspaces.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { StoreApi } from "zustand"; -import type { AppState, WorkspaceState } from "@/lib/state/store"; -import type { WsHandlers } from "@/lib/ws/handlers/types"; - -type WorkspaceItem = WorkspaceState["items"][number]; - -export function registerWorkspacesHandlers(store: StoreApi): WsHandlers { - return { - "workspace.created": (message) => { - store.setState((state) => { - const payload = message.payload; - const newWorkspace: WorkspaceItem = { - id: payload.id, - name: payload.name, - description: payload.description ?? null, - owner_id: payload.owner_id ?? "", - default_executor_id: payload.default_executor_id ?? null, - default_environment_id: payload.default_environment_id ?? null, - default_agent_profile_id: payload.default_agent_profile_id ?? null, - default_config_agent_profile_id: payload.default_config_agent_profile_id ?? null, - created_at: payload.created_at ?? new Date().toISOString(), - updated_at: payload.updated_at ?? new Date().toISOString(), - }; - const exists = state.workspaces.items.some((item) => item.id === payload.id); - const items = exists - ? state.workspaces.items.map((item) => - item.id === payload.id ? { ...item, ...newWorkspace } : item, - ) - : [newWorkspace, ...state.workspaces.items]; - const activeId = state.workspaces.activeId ?? payload.id; - return { - ...state, - workspaces: { - items, - activeId, - }, - }; - }); - }, - "workspace.updated": (message) => { - store.setState((state) => ({ - ...state, - workspaces: { - ...state.workspaces, - items: state.workspaces.items.map((item) => - item.id === message.payload.id - ? { - ...item, - name: message.payload.name, - description: message.payload.description ?? item.description, - default_executor_id: message.payload.default_executor_id ?? null, - default_environment_id: message.payload.default_environment_id ?? null, - default_agent_profile_id: message.payload.default_agent_profile_id ?? null, - default_config_agent_profile_id: - "default_config_agent_profile_id" in message.payload - ? (message.payload.default_config_agent_profile_id ?? null) - : (item.default_config_agent_profile_id ?? null), - updated_at: message.payload.updated_at ?? item.updated_at, - } - : item, - ), - }, - })); - }, - "workspace.deleted": (message) => { - store.setState((state) => { - const items = state.workspaces.items.filter((item) => item.id !== message.payload.id); - const activeId = - state.workspaces.activeId === message.payload.id - ? (items[0]?.id ?? null) - : state.workspaces.activeId; - const clearBoards = state.workspaces.activeId === message.payload.id; - return { - ...state, - workspaces: { - items, - activeId, - }, - workflows: clearBoards ? { items: [], activeId: null } : state.workflows, - kanban: clearBoards ? { workflowId: null, steps: [], tasks: [] } : state.kanban, - }; - }); - }, - }; -} diff --git a/apps/web/lib/ws/router.test.ts b/apps/web/lib/ws/router.test.ts new file mode 100644 index 0000000000..30c7262237 --- /dev/null +++ b/apps/web/lib/ws/router.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { registerWsHandlers } from "./router"; + +describe("registerWsHandlers", () => { + it("does not register legacy handlers for Query-owned todo and prompt usage events", () => { + const handlers = registerWsHandlers({} as StoreApi); + + expect(handlers).not.toHaveProperty("workflow.created"); + expect(handlers).not.toHaveProperty("workflow.updated"); + expect(handlers).not.toHaveProperty("workflow.deleted"); + expect(handlers).not.toHaveProperty("workflow.step.created"); + expect(handlers).not.toHaveProperty("workflow.step.updated"); + expect(handlers).not.toHaveProperty("workflow.step.deleted"); + expect(handlers).not.toHaveProperty("workspace.created"); + expect(handlers).not.toHaveProperty("workspace.updated"); + expect(handlers).not.toHaveProperty("workspace.deleted"); + expect(handlers).not.toHaveProperty("terminal.output"); + expect(handlers).not.toHaveProperty("session.agent_capabilities"); + expect(handlers).not.toHaveProperty("session.poll_mode_changed"); + expect(handlers).not.toHaveProperty("session.mode_changed"); + expect(handlers).not.toHaveProperty("session.todos_updated"); + expect(handlers).not.toHaveProperty("session.prompt_usage"); + expect(handlers).not.toHaveProperty("session.available_commands"); + expect(handlers).not.toHaveProperty("kanban.update"); + expect(handlers).not.toHaveProperty("task.created"); + expect(handlers).not.toHaveProperty("task.state_changed"); + + expect(handlers).toHaveProperty("task.updated"); + expect(handlers).toHaveProperty("task.deleted"); + expect(handlers).toHaveProperty("session.shell.output"); + expect(handlers).toHaveProperty("session.process.output"); + expect(handlers).toHaveProperty("session.process.status"); + }); +}); diff --git a/apps/web/lib/ws/router.ts b/apps/web/lib/ws/router.ts index 7a2611e572..742d4ac351 100644 --- a/apps/web/lib/ws/router.ts +++ b/apps/web/lib/ws/router.ts @@ -1,71 +1,33 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; -import { registerAgentsHandlers } from "@/lib/ws/handlers/agents"; import { registerTaskSessionHandlers } from "@/lib/ws/handlers/agent-session"; -import { registerAvailableCommandsHandlers } from "@/lib/ws/handlers/available-commands"; -import { registerSessionModeHandlers } from "@/lib/ws/handlers/session-mode"; -import { registerSessionPollModeHandlers } from "@/lib/ws/handlers/session-poll-mode"; -import { registerAgentCapabilitiesHandlers } from "@/lib/ws/handlers/agent-capabilities"; import { registerSessionModelsHandlers } from "@/lib/ws/handlers/session-models"; import { registerSessionInfoHandlers } from "@/lib/ws/handlers/session-info"; -import { registerSessionTodosHandlers } from "@/lib/ws/handlers/session-todos"; -import { registerPromptUsageHandlers } from "@/lib/ws/handlers/prompt-usage"; -import { registerWorkflowsHandlers } from "@/lib/ws/handlers/workflows"; import { registerMessagesHandlers } from "@/lib/ws/handlers/messages"; import { registerNotificationsHandlers } from "@/lib/ws/handlers/notifications"; -import { registerDiffsHandlers } from "@/lib/ws/handlers/diffs"; -import { registerExecutorsHandlers } from "@/lib/ws/handlers/executors"; -import { registerExecutorProfileHandlers } from "@/lib/ws/handlers/executor-profiles"; import { registerExecutorPrepareHandlers } from "@/lib/ws/handlers/executor-prepare"; import { registerGitStatusHandlers } from "@/lib/ws/handlers/git-status"; -import { registerKanbanHandlers } from "@/lib/ws/handlers/kanban"; -import { registerSystemEventsHandlers } from "@/lib/ws/handlers/system-events"; import { registerTasksHandlers } from "@/lib/ws/handlers/tasks"; -import { registerTaskPlansHandlers } from "@/lib/ws/handlers/task-plans"; -import { registerWalkthroughsHandlers } from "@/lib/ws/handlers/walkthroughs"; import { registerTerminalsHandlers } from "@/lib/ws/handlers/terminals"; import { registerTurnsHandlers } from "@/lib/ws/handlers/turns"; -import { registerSecretsHandlers } from "@/lib/ws/handlers/secrets"; import { registerUsersHandlers } from "@/lib/ws/handlers/users"; -import { registerWorkspacesHandlers } from "@/lib/ws/handlers/workspaces"; -import { registerGitHubHandlers } from "@/lib/ws/handlers/github"; -import { registerOfficeHandlers } from "@/lib/ws/handlers/office"; import { registerRunHandlers } from "@/lib/ws/handlers/run"; export function registerWsHandlers(store: StoreApi) { return { - ...registerKanbanHandlers(store), ...registerTasksHandlers(store), - ...registerTaskPlansHandlers(store), - ...registerWalkthroughsHandlers(store), - ...registerWorkflowsHandlers(store), - ...registerWorkspacesHandlers(store), - ...registerExecutorsHandlers(store), - ...registerExecutorProfileHandlers(store), ...registerExecutorPrepareHandlers(store), - ...registerAgentsHandlers(store), ...registerTaskSessionHandlers(store), - ...registerAvailableCommandsHandlers(store), - ...registerSessionModeHandlers(store), - ...registerSessionPollModeHandlers(store), - ...registerAgentCapabilitiesHandlers(store), ...registerSessionModelsHandlers(store), ...registerSessionInfoHandlers(store), - ...registerSessionTodosHandlers(store), - ...registerPromptUsageHandlers(store), ...registerUsersHandlers(store), ...registerTerminalsHandlers(store), - ...registerDiffsHandlers(store), ...registerMessagesHandlers(store), ...registerNotificationsHandlers(store), - ...registerSecretsHandlers(store), ...registerGitStatusHandlers(store), - ...registerSystemEventsHandlers(store), ...registerTurnsHandlers(store), - ...registerGitHubHandlers(store), - ...registerOfficeHandlers(store), ...registerRunHandlers(), }; } diff --git a/apps/web/lib/ws/use-websocket.test.tsx b/apps/web/lib/ws/use-websocket.test.tsx new file mode 100644 index 0000000000..6b43407236 --- /dev/null +++ b/apps/web/lib/ws/use-websocket.test.tsx @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import type { WebSocketClient } from "@/lib/ws/client"; +import { registerWsHandlers } from "@/lib/ws/router"; +import { getWebSocketClient, setWebSocketClient, subscribeWebSocketClient } from "./connection"; +import { useWebSocket } from "./use-websocket"; + +vi.mock("@/lib/ws/router", () => ({ + registerWsHandlers: vi.fn(), +})); + +class FakeSocket { + onopen: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onerror: (() => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + send = vi.fn(); + close = vi.fn(() => { + this.onclose?.({} as CloseEvent); + }); + + constructor(readonly url: string) {} +} + +function makeStore() { + return { + getState: () => ({ + setConnectionStatus: vi.fn(), + }), + } as unknown as StoreApi; +} + +function dispatchTaskUpdated(client: WebSocketClient) { + ( + client as unknown as { + handleParsedMessage: (message: unknown) => void; + } + ).handleParsedMessage({ + type: "notification", + action: "task.updated", + payload: { task_id: "task-1" }, + }); +} + +describe("useWebSocket", () => { + beforeEach(() => { + vi.stubGlobal("WebSocket", FakeSocket); + setWebSocketClient(null); + }); + + afterEach(() => { + setWebSocketClient(null); + vi.unstubAllGlobals(); + }); + + it("publishes the client only after legacy handlers are registered", () => { + const calls: string[] = []; + vi.mocked(registerWsHandlers).mockReturnValue({ + "task.updated": () => calls.push("legacy"), + } as never); + const unsubscribe = subscribeWebSocketClient((client) => { + if (!client) return; + client.on("task.updated", () => calls.push("bridge")); + }); + + const { unmount } = renderHook(() => useWebSocket(makeStore(), "ws://example.test/ws")); + const client = getWebSocketClient(); + expect(client).not.toBeNull(); + + dispatchTaskUpdated(client as WebSocketClient); + + expect(calls).toEqual(["legacy", "bridge"]); + unsubscribe(); + unmount(); + }); +}); diff --git a/apps/web/lib/ws/use-websocket.tsx b/apps/web/lib/ws/use-websocket.tsx index 789803a382..b476f7540e 100644 --- a/apps/web/lib/ws/use-websocket.tsx +++ b/apps/web/lib/ws/use-websocket.tsx @@ -38,12 +38,12 @@ export function useWebSocket(store: StoreApi, url: string) { ); clientRef.current = client; client.connect(); - setWebSocketClient(client); const handlers = registerWsHandlers(store); const unsubscribers = Object.entries(handlers).map(([type, handler]) => client.on(type as keyof typeof handlers, handler as never), ); + setWebSocketClient(client); return () => { unsubscribers.forEach((unsubscribe) => unsubscribe()); diff --git a/apps/web/lib/ws/ws-account-e2e-helper.test.ts b/apps/web/lib/ws/ws-account-e2e-helper.test.ts new file mode 100644 index 0000000000..fe30d10413 --- /dev/null +++ b/apps/web/lib/ws/ws-account-e2e-helper.test.ts @@ -0,0 +1,280 @@ +import type { Page } from "@playwright/test"; +import { describe, expect, it, vi } from "vitest"; +import { + computeWsDrops, + reconcileExpectedWsDrops, + registerExpectedWsDrop, + type WsAccountSnapshot, + type WsSentEvent, + type WsSentFetcher, +} from "../../e2e/helpers/ws-account"; + +function pageWithSnapshot(snapshot: WsAccountSnapshot | null, url = "http://127.0.0.1:18080/") { + return { + evaluate: vi.fn(async () => snapshot), + waitForFunction: vi.fn(async () => undefined), + url: () => url, + } as unknown as Page; +} + +function noOpFetcher(): WsSentFetcher { + return { + getWsSent: vi.fn(async () => ({ + connection_id: "conn-1", + events: [], + })), + }; +} + +function snapshot(): WsAccountSnapshot { + return { + connectionId: "conn-1", + processedSeqs: [1], + gaps: [], + minSeq: 1, + maxSeq: 1, + receivedEvents: [], + bySession: {}, + }; +} + +const SENT_AT = "2026-06-23T00:00:00Z"; + +function sentEvent(connectionSeq: number, action: string, sessionSeq?: number): WsSentEvent { + return { + connection_seq: connectionSeq, + ...(sessionSeq ? { session_seq: sessionSeq, session_id: "session-1" } : {}), + type: "notification", + action, + sent_at: SENT_AT, + }; +} + +function fetcherWithEvents( + connectionEvents: WsSentEvent[], + sessionEvents: WsSentEvent[] = [], +): WsSentFetcher { + return { + getWsSent: vi.fn(async (_connectionId, _sinceSeq, sessionId) => ({ + connection_id: "conn-1", + events: sessionId ? sessionEvents : connectionEvents, + })), + }; +} + +describe("computeWsDrops strict mode", () => { + it("fails strict accounting when an app page has no browser hook", async () => { + const page = pageWithSnapshot(null); + + await expect(computeWsDrops(page, noOpFetcher(), { strict: true })).rejects.toThrow( + "Strict WS accounting could not read the browser hook", + ); + expect(page.waitForFunction).toHaveBeenCalled(); + }); + + it("waits for the browser hook after app navigation before strict accounting fails", async () => { + const readySnapshot = snapshot(); + const page = { + evaluate: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(readySnapshot), + waitForFunction: vi.fn(async () => undefined), + url: () => "http://127.0.0.1:18080/settings/agents", + } as unknown as Page; + + await expect(computeWsDrops(page, noOpFetcher(), { strict: true })).resolves.toEqual([]); + expect(page.waitForFunction).toHaveBeenCalled(); + }); + + it("allows strict accounting when the hook is installed before any frames arrive", async () => { + const page = pageWithSnapshot({ + connectionId: null, + processedSeqs: [], + gaps: [], + minSeq: null, + maxSeq: null, + receivedEvents: [], + bySession: {}, + }); + + await expect(computeWsDrops(page, noOpFetcher(), { strict: true })).resolves.toEqual([]); + }); + + it("fails strict accounting when stamped frames omit the connection id", async () => { + const page = pageWithSnapshot({ + connectionId: null, + processedSeqs: [1], + gaps: [], + minSeq: 1, + maxSeq: 1, + receivedEvents: [], + bySession: {}, + }); + + await expect(computeWsDrops(page, noOpFetcher(), { strict: true })).rejects.toThrow( + "Strict WS accounting parsed stamped WS envelopes without a browser connection id", + ); + }); + + it("does not fail strict accounting before a page has loaded the app", async () => { + const page = pageWithSnapshot(null, "about:blank"); + + await expect(computeWsDrops(page, noOpFetcher(), { strict: true })).resolves.toEqual([]); + expect(page.waitForFunction).not.toHaveBeenCalled(); + }); + + it("fails strict accounting when the backend sent-log lookup fails", async () => { + const page = pageWithSnapshot(snapshot()); + const fetcher: WsSentFetcher = { + getWsSent: vi.fn(async () => { + throw new Error("missing route"); + }), + }; + + await expect(computeWsDrops(page, fetcher, { strict: true })).rejects.toThrow( + "Strict WS accounting sent-log lookup failed", + ); + }); +}); + +describe("computeWsDrops connection sequences", () => { + it("ignores connection events sent after the browser snapshot max sequence", async () => { + const page = pageWithSnapshot({ + connectionId: "conn-1", + processedSeqs: [1, 2], + gaps: [], + minSeq: 1, + maxSeq: 2, + receivedEvents: [], + bySession: {}, + }); + const fetcher = fetcherWithEvents([ + sentEvent(1, "one"), + sentEvent(2, "two"), + sentEvent(3, "in-flight"), + ]); + + await expect(computeWsDrops(page, fetcher, { strict: true })).resolves.toEqual([]); + }); + + it("still reports connection gaps at or below the browser snapshot max sequence", async () => { + const page = pageWithSnapshot({ + connectionId: "conn-1", + processedSeqs: [1, 3], + gaps: [2], + minSeq: 1, + maxSeq: 3, + receivedEvents: [], + bySession: {}, + }); + const missing = sentEvent(2, "two"); + const fetcher = fetcherWithEvents([sentEvent(1, "one"), missing, sentEvent(3, "three")]); + + await expect(computeWsDrops(page, fetcher, { strict: true })).resolves.toEqual([missing]); + }); +}); + +describe("computeWsDrops session sequences", () => { + it("ignores session events sent after the browser snapshot max session sequence", async () => { + const page = pageWithSnapshot({ + connectionId: "conn-1", + processedSeqs: [1, 2], + gaps: [], + minSeq: 1, + maxSeq: 2, + receivedEvents: [], + bySession: { + "session-1": { processedSeqs: [1], gaps: [], minSeq: 1, maxSeq: 1 }, + }, + }); + const fetcher = fetcherWithEvents( + [sentEvent(1, "one"), sentEvent(2, "two")], + [sentEvent(1, "one", 1), sentEvent(2, "in-flight", 2)], + ); + + await expect(computeWsDrops(page, fetcher, { strict: true })).resolves.toEqual([]); + }); + + it("ignores session events sent before the browser snapshot min session sequence", async () => { + const page = pageWithSnapshot({ + connectionId: "conn-1", + processedSeqs: [3, 4], + gaps: [], + minSeq: 3, + maxSeq: 4, + receivedEvents: [], + bySession: { + "session-1": { processedSeqs: [3, 4], gaps: [], minSeq: 3, maxSeq: 4 }, + }, + }); + const fetcher = fetcherWithEvents( + [sentEvent(3, "three"), sentEvent(4, "four")], + [sentEvent(1, "before-hook", 1), sentEvent(3, "three", 3), sentEvent(4, "four", 4)], + ); + + await expect(computeWsDrops(page, fetcher, { strict: true })).resolves.toEqual([]); + }); + + it("still reports session gaps at or below the browser snapshot max session sequence", async () => { + const page = pageWithSnapshot({ + connectionId: "conn-1", + processedSeqs: [1, 2, 3], + gaps: [], + minSeq: 1, + maxSeq: 3, + receivedEvents: [], + bySession: { + "session-1": { processedSeqs: [1, 3], gaps: [2], minSeq: 1, maxSeq: 3 }, + }, + }); + const missing = sentEvent(2, "two", 2); + const fetcher = fetcherWithEvents( + [sentEvent(1, "one"), sentEvent(2, "two"), sentEvent(3, "three")], + [sentEvent(1, "one", 1), missing, sentEvent(3, "three", 3)], + ); + + await expect(computeWsDrops(page, fetcher, { strict: true })).resolves.toEqual([missing]); + }); +}); + +describe("reconcileExpectedWsDrops", () => { + it("consumes matching expected drops and preserves unexpected drops", () => { + const page = {} as Page; + registerExpectedWsDrop(page, { + type: "notification", + action: "session.message.added", + sessionId: "session-1", + }); + + const expected = { + connection_seq: 1, + session_seq: 1, + session_id: "session-1", + type: "notification", + action: "session.message.added", + sent_at: "2026-06-23T00:00:00Z", + }; + const unexpected = { + connection_seq: 2, + session_seq: 2, + session_id: "session-1", + type: "notification", + action: "session.message.updated", + sent_at: "2026-06-23T00:00:01Z", + }; + + expect(reconcileExpectedWsDrops(page, [expected, unexpected])).toEqual({ + unexpected: [unexpected], + missing: [], + }); + }); + + it("reports registered expected drops that were not observed", () => { + const page = {} as Page; + const missing = { action: "session.message.added", reason: "intentional drop" }; + registerExpectedWsDrop(page, missing); + + expect(reconcileExpectedWsDrops(page, [])).toEqual({ + unexpected: [], + missing: [missing], + }); + }); +}); diff --git a/apps/web/lib/ws/ws-account.test.ts b/apps/web/lib/ws/ws-account.test.ts new file mode 100644 index 0000000000..f160369ab4 --- /dev/null +++ b/apps/web/lib/ws/ws-account.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { WsAccount, detectGaps } from "./ws-account"; + +function envelope( + connectionSeq: number, + overrides: Partial<{ + connectionId: string; + sessionSeq: number; + sessionId: string; + action: string; + type: string; + }> = {}, +) { + return { + connection_id: overrides.connectionId ?? "conn-1", + connection_seq: connectionSeq, + session_id: overrides.sessionId, + session_seq: overrides.sessionSeq, + type: overrides.type ?? "notification", + action: overrides.action ?? "session.message.added", + payload: overrides.sessionId ? { session_id: overrides.sessionId } : {}, + }; +} + +describe("detectGaps", () => { + it("returns missing integers between the smallest and largest seq", () => { + expect(detectGaps([1, 2, 4, 7])).toEqual([3, 5, 6]); + }); + + it("does not report gaps for empty, single, or contiguous input", () => { + expect(detectGaps([])).toEqual([]); + expect(detectGaps([8])).toEqual([]); + expect(detectGaps([8, 9, 10])).toEqual([]); + }); +}); + +describe("WsAccount", () => { + it("records parsed connection envelopes and detects connection gaps", () => { + const account = new WsAccount(); + account.recordEnvelope(envelope(1)); + account.recordEnvelope(envelope(2)); + account.recordEnvelope(envelope(4)); + + expect(account.snapshot()).toMatchObject({ + connectionId: "conn-1", + processedSeqs: [1, 2, 4], + gaps: [3], + minSeq: 1, + maxSeq: 4, + }); + }); + + it("resets buckets when the backend connection id changes", () => { + const account = new WsAccount(); + account.recordEnvelope( + envelope(1, { connectionId: "conn-a", sessionId: "session-1", sessionSeq: 1 }), + ); + account.recordEnvelope( + envelope(1, { connectionId: "conn-b", sessionId: "session-1", sessionSeq: 1 }), + ); + + expect(account.snapshot()).toMatchObject({ + connectionId: "conn-b", + processedSeqs: [1], + bySession: { + "session-1": { + processedSeqs: [1], + gaps: [], + }, + }, + }); + }); + + it("clears the current connection id and tracked events", () => { + const account = new WsAccount(); + account.recordEnvelope(envelope(1, { connectionId: "conn-a" })); + account.clear(); + + expect(account.snapshot()).toMatchObject({ + connectionId: null, + processedSeqs: [], + bySession: {}, + }); + }); + + it("tracks independent per-session sequence buckets", () => { + const account = new WsAccount(); + account.recordEnvelope(envelope(1, { sessionId: "session-a", sessionSeq: 1 })); + account.recordEnvelope(envelope(2, { sessionId: "session-b", sessionSeq: 1 })); + account.recordEnvelope(envelope(3, { sessionId: "session-a", sessionSeq: 3 })); + + const snapshot = account.snapshot(); + expect(snapshot.gaps).toEqual([]); + expect(snapshot.bySession["session-a"]).toMatchObject({ + processedSeqs: [1, 3], + gaps: [2], + }); + expect(snapshot.bySession["session-b"]).toMatchObject({ + processedSeqs: [1], + gaps: [], + }); + }); + + it("uses only the stamped top-level session id for session buckets", () => { + const account = new WsAccount(); + account.recordEnvelope({ + connection_id: "conn-1", + connection_seq: 1, + session_seq: 1, + type: "notification", + action: "session.message.added", + session_id: "session-stamped", + payload: { session_id: "session-payload" }, + }); + account.recordEnvelope({ + connection_id: "conn-1", + connection_seq: 2, + session_seq: 1, + type: "notification", + action: "session.message.added", + payload: { session_id: "payload-only" }, + }); + + expect(account.snapshot().bySession).toEqual({ + "session-stamped": { + processedSeqs: [1], + gaps: [], + minSeq: 1, + maxSeq: 1, + }, + }); + }); + + it("evicts oldest entries per bucket", () => { + const account = new WsAccount(2); + account.recordEnvelope(envelope(1, { sessionId: "session-a", sessionSeq: 1 })); + account.recordEnvelope(envelope(2, { sessionId: "session-a", sessionSeq: 2 })); + account.recordEnvelope(envelope(3, { sessionId: "session-a", sessionSeq: 3 })); + + const snapshot = account.snapshot(); + expect(snapshot.processedSeqs).toEqual([2, 3]); + expect(snapshot.bySession["session-a"].processedSeqs).toEqual([2, 3]); + }); +}); diff --git a/apps/web/lib/ws/ws-account.ts b/apps/web/lib/ws/ws-account.ts new file mode 100644 index 0000000000..d1aab78e7f --- /dev/null +++ b/apps/web/lib/ws/ws-account.ts @@ -0,0 +1,215 @@ +export const WS_ACCOUNT_MAX_ENTRIES = 5000; + +export interface WsAccountEnvelope { + connection_id?: string; + connection_seq?: number; + session_id?: string; + session_seq?: number; + type?: string; + action?: string; + payload?: unknown; +} + +export interface WsAccountEntry { + connectionSeq: number; + sessionSeq?: number; + type: string; + action: string; + sessionId: string | null; + receivedAt: number; +} + +export interface WsAccountReceivedEvent { + connectionSeq: number; + sessionSeq?: number; + action: string; + sessionId: string | null; + type: string; +} + +export interface WsAccountSessionSnapshot { + processedSeqs: number[]; + gaps: number[]; + minSeq: number | null; + maxSeq: number | null; +} + +export interface WsAccountSnapshot extends WsAccountSessionSnapshot { + connectionId: string | null; + receivedEvents: WsAccountReceivedEvent[]; + bySession: Record; +} + +type WsAccountWindow = Window & { + __KANDEV_E2E_EXPOSE_STORE__?: boolean; + __kandev_ws_account__?: () => WsAccountSnapshot; + __kandev_ws_account_clear__?: () => void; +}; + +export function detectGaps(processed: number[]): number[] { + if (processed.length < 2) return []; + const min = processed[0]; + const max = processed[processed.length - 1]; + const seen = new Set(processed); + const gaps: number[] = []; + for (let seq = min + 1; seq < max; seq++) { + if (!seen.has(seq)) gaps.push(seq); + } + return gaps; +} + +class WsAccountBucket { + private entries = new Map(); + + constructor(private readonly maxEntries: number) {} + + record(seq: number, entry: WsAccountEntry): void { + if (this.entries.has(seq)) { + this.entries.delete(seq); + } + this.entries.set(seq, entry); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + clear(): void { + this.entries.clear(); + } + + snapshot(): WsAccountSessionSnapshot & { receivedEvents: WsAccountReceivedEvent[] } { + const processedSeqs = Array.from(this.entries.keys()).sort((a, b) => a - b); + const receivedEvents = processedSeqs.map((seq) => { + const entry = this.entries.get(seq); + return { + connectionSeq: entry?.connectionSeq ?? seq, + sessionSeq: entry?.sessionSeq, + action: entry?.action ?? "", + sessionId: entry?.sessionId ?? null, + type: entry?.type ?? "", + }; + }); + return { + processedSeqs, + gaps: detectGaps(processedSeqs), + minSeq: processedSeqs[0] ?? null, + maxSeq: processedSeqs.at(-1) ?? null, + receivedEvents, + }; + } +} + +export class WsAccount { + private connectionId: string | null = null; + private connection = new WsAccountBucket(this.maxEntries); + private bySession = new Map(); + + constructor(private readonly maxEntries: number = WS_ACCOUNT_MAX_ENTRIES) {} + + recordEnvelope(envelope: WsAccountEnvelope): void { + const entry = toAccountEntry(envelope); + if (!entry) return; + const connectionId = envelope.connection_id ?? null; + if (connectionId && connectionId !== this.connectionId) { + this.connection.clear(); + this.bySession.clear(); + this.connectionId = connectionId; + } + this.connection.record(entry.connectionSeq, entry); + if (entry.sessionId && entry.sessionSeq && entry.sessionSeq > 0) { + this.sessionBucket(entry.sessionId).record(entry.sessionSeq, entry); + } + } + + snapshot(): WsAccountSnapshot { + const connectionSnapshot = this.connection.snapshot(); + const bySession: Record = {}; + for (const [sessionId, bucket] of this.bySession) { + const snapshot = bucket.snapshot(); + bySession[sessionId] = { + processedSeqs: snapshot.processedSeqs, + gaps: snapshot.gaps, + minSeq: snapshot.minSeq, + maxSeq: snapshot.maxSeq, + }; + } + return { + connectionId: this.connectionId, + processedSeqs: connectionSnapshot.processedSeqs, + gaps: connectionSnapshot.gaps, + minSeq: connectionSnapshot.minSeq, + maxSeq: connectionSnapshot.maxSeq, + receivedEvents: connectionSnapshot.receivedEvents, + bySession, + }; + } + + clear(): void { + this.connectionId = null; + this.connection.clear(); + this.bySession.clear(); + } + + private sessionBucket(sessionId: string): WsAccountBucket { + const existing = this.bySession.get(sessionId); + if (existing) return existing; + const bucket = new WsAccountBucket(this.maxEntries); + this.bySession.set(sessionId, bucket); + return bucket; + } +} + +const globalAccount = new WsAccount(); + +export function recordParsedWsEnvelope(envelope: unknown): void { + const win = getWindow(); + if (!win || !wsAccountEnabled(win)) return; + installWsAccountGlobals(win); + globalAccount.recordEnvelope(envelope as WsAccountEnvelope); +} + +export function installWsAccountGlobalsForE2E(): void { + const win = getWindow(); + if (!win || !wsAccountEnabled(win)) return; + installWsAccountGlobals(win); +} + +export function installWsAccountGlobals(win: WsAccountWindow): void { + if (win.__kandev_ws_account__) return; + win.__kandev_ws_account__ = () => globalAccount.snapshot(); + win.__kandev_ws_account_clear__ = () => globalAccount.clear(); +} + +function wsAccountEnabled(win: WsAccountWindow): boolean { + return win.__KANDEV_E2E_EXPOSE_STORE__ === true; +} + +function getWindow(): WsAccountWindow | null { + if (typeof window === "undefined") return null; + return window as WsAccountWindow; +} + +function toAccountEntry(envelope: WsAccountEnvelope): WsAccountEntry | null { + const connectionSeq = envelope.connection_seq; + if (typeof connectionSeq !== "number" || !Number.isFinite(connectionSeq) || connectionSeq <= 0) { + return null; + } + return { + connectionSeq, + sessionSeq: normalizeSeq(envelope.session_seq), + type: envelope.type ?? "", + action: envelope.action ?? "", + sessionId: normalizeSessionId(envelope.session_id), + receivedAt: Date.now(), + }; +} + +function normalizeSeq(seq: number | undefined): number | undefined { + return typeof seq === "number" && Number.isFinite(seq) && seq > 0 ? seq : undefined; +} + +function normalizeSessionId(value: unknown): string | null { + return typeof value === "string" && value !== "" ? value : null; +} diff --git a/apps/web/package.json b/apps/web/package.json index 14169f652c..bf9be5b716 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -51,6 +51,8 @@ "@monaco-editor/react": "^4.7.0", "@pierre/diffs": "^1.1.22", "@tabler/icons-react": "^3.36.1", + "@tanstack/react-query": "5.101.1", + "@tanstack/react-query-devtools": "5.101.1", "@tanstack/react-table": "^8.21.3", "@tiptap/core": "^3.19.0", "@tiptap/extension-bubble-menu": "^3.19.0", @@ -127,6 +129,7 @@ "@eslint/js": "^9.39.2", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4", + "@tanstack/eslint-plugin-query": "5.101.1", "@testing-library/react": "^16.3.2", "@types/diff": "^8.0.0", "@types/node": "^20", diff --git a/apps/web/src/boot-payload.ts b/apps/web/src/boot-payload.ts index 3c089c8276..85dc5de16e 100644 --- a/apps/web/src/boot-payload.ts +++ b/apps/web/src/boot-payload.ts @@ -1,6 +1,8 @@ import type { AppState } from "@/lib/state/store"; import { getBackendConfig } from "@/lib/config"; import type { FetchedSessionData } from "@/lib/ssr/session-page-state"; +import type { FeatureFlags } from "@/lib/state/slices/features/types"; +import type { Worktree } from "@/lib/state/slices/session/types"; import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http"; export type BootRoute = { @@ -36,11 +38,21 @@ export type BootRouteData = { }; }; +export type BootInitialState = Partial & { + features?: FeatureFlags; + worktrees?: { + items?: Record; + }; + sessionWorktreesBySessionId?: { + itemsBySessionId?: Record; + }; +}; + export type BootPayload = { version?: number; route?: BootRoute; runtime?: BootRuntime; - initialState?: Partial; + initialState?: BootInitialState; routeData?: BootRouteData; }; @@ -61,7 +73,7 @@ export function readBootPayload(win: Window = window): BootPayload { version: typeof payload.version === "number" ? payload.version : undefined, route: isRecord(payload.route) ? readRoute(payload.route) : undefined, runtime, - initialState: isRecord(payload.initialState) ? (payload.initialState as Partial) : {}, + initialState: isRecord(payload.initialState) ? (payload.initialState as BootInitialState) : {}, routeData: isRecord(payload.routeData) ? (payload.routeData as BootRouteData) : undefined, }; } diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index de08375420..6b9c8bbb69 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,19 +1,26 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import type { QueryClient } from "@tanstack/react-query"; import "@/app/globals.css"; import { StateProvider } from "@/components/state-provider"; +import { getBrowserQueryClient } from "@/lib/query/client"; +import { QueryProvider } from "@/lib/query/provider"; +import { seedQueryClientFromBootPayload } from "@/lib/query/seed"; +import { installWsAccountGlobalsForE2E } from "@/lib/ws/ws-account"; import { AppShell } from "./app-shell"; import { loadBootPayload } from "./boot-payload"; import type { BootPayload } from "./boot-payload"; import { SpaRoutes } from "./spa-routes"; -function App({ payload }: { payload: BootPayload }) { +function App({ payload, queryClient }: { payload: BootPayload; queryClient: QueryClient }) { return ( - - - - - + + + + + + + ); } @@ -23,10 +30,15 @@ if (!root) { throw new Error("Missing #root element"); } +installWsAccountGlobalsForE2E(); + void loadBootPayload().then((payload) => { + const queryClient = getBrowserQueryClient(); + seedQueryClientFromBootPayload(queryClient, payload); + createRoot(root).render( - + , ); }); diff --git a/apps/web/src/office-agent-client-routes.tsx b/apps/web/src/office-agent-client-routes.tsx index a6bd7aec42..f94b5a9ec1 100644 --- a/apps/web/src/office-agent-client-routes.tsx +++ b/apps/web/src/office-agent-client-routes.tsx @@ -1,38 +1,20 @@ -import { useEffect, useState } from "react"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { DashboardView } from "@/app/office/agents/[id]/dashboard/dashboard-view"; import { RunsListView } from "@/app/office/agents/[id]/runs/runs-list-view"; import { RunDetailView } from "@/app/office/agents/[id]/runs/[runId]/run-detail-view"; + import { - getAgentSummary, - getRunDetail, - listAgentRuns, - type AgentRunsListPage, - type AgentSummaryResponse, - type RunDetail, -} from "@/lib/api/domains/office-extended-api"; -import { toRouteErrorState, type LoadState } from "@/lib/routing/client-route-helpers"; + officeAgentRunsInfiniteQueryOptions, + officeAgentSummaryQueryOptions, + officeRunDetailQueryOptions, +} from "@/lib/query/query-options/office"; +import type { LoadState } from "@/lib/routing/client-route-helpers"; const DASHBOARD_DAYS = 14; export function AgentDashboardRoute({ agentId }: { agentId: string }) { - const [state, setState] = useState>({ status: "loading" }); - - useEffect(() => { - let cancelled = false; - setState({ status: "loading" }); - - getAgentSummary(agentId, DASHBOARD_DAYS, { cache: "no-store" }) - .then((data) => { - if (!cancelled) setState({ status: "ready", data }); - }) - .catch((error: unknown) => { - if (!cancelled) setState(toRouteErrorState(error)); - }); - - return () => { - cancelled = true; - }; - }, [agentId]); + const query = useQuery(officeAgentSummaryQueryOptions(agentId, DASHBOARD_DAYS)); + const state = queryState(query.data, query.error); if (state.status !== "ready") { return ; @@ -42,24 +24,8 @@ export function AgentDashboardRoute({ agentId }: { agentId: string }) { } export function AgentRunsRoute({ agentId }: { agentId: string }) { - const [state, setState] = useState>({ status: "loading" }); - - useEffect(() => { - let cancelled = false; - setState({ status: "loading" }); - - listAgentRuns(agentId, { limit: 25 }, { cache: "no-store" }) - .then((data) => { - if (!cancelled) setState({ status: "ready", data }); - }) - .catch((error: unknown) => { - if (!cancelled) setState(toRouteErrorState(error)); - }); - - return () => { - cancelled = true; - }; - }, [agentId]); + const query = useInfiniteQuery(officeAgentRunsInfiniteQueryOptions(agentId, { limit: 25 })); + const state = queryState(query.data?.pages[0], query.error); if (state.status !== "ready") { return ; @@ -69,34 +35,14 @@ export function AgentRunsRoute({ agentId }: { agentId: string }) { } export function AgentRunDetailRoute({ agentId, runId }: { agentId: string; runId: string }) { - const [state, setState] = useState>({ - status: "loading", - }); - - useEffect(() => { - let cancelled = false; - setState({ status: "loading" }); - - async function loadRunDetail() { - const [initial, recent] = await Promise.all([ - getRunDetail(agentId, runId, { cache: "no-store" }), - listAgentRuns(agentId, { limit: 30 }, { cache: "no-store" }), - ]); - return { initial, recent }; - } - - loadRunDetail() - .then((data) => { - if (!cancelled) setState({ status: "ready", data }); - }) - .catch((error: unknown) => { - if (!cancelled) setState(toRouteErrorState(error)); - }); - - return () => { - cancelled = true; - }; - }, [agentId, runId]); + const detailQuery = useQuery(officeRunDetailQueryOptions(agentId, runId)); + const recentQuery = useInfiniteQuery(officeAgentRunsInfiniteQueryOptions(agentId, { limit: 30 })); + const initial = detailQuery.data; + const recent = recentQuery.data?.pages[0]; + const state = queryState( + initial && recent ? { initial, recent } : undefined, + detailQuery.error ?? recentQuery.error, + ); if (state.status !== "ready") { return ; @@ -107,6 +53,17 @@ export function AgentRunDetailRoute({ agentId, runId }: { agentId: string; runId ); } +function queryState(data: T | undefined, error: unknown): LoadState { + if (data !== undefined) return { status: "ready", data }; + if (error) { + return { + status: "error", + message: error instanceof Error ? error.message : "Failed to load route", + }; + } + return { status: "loading" }; +} + function AgentRoutePlaceholder({ state, label }: { state: LoadState; label: string }) { if (state.status === "error") { return
{state.message}
; diff --git a/apps/web/src/office-routes.tsx b/apps/web/src/office-routes.tsx index 9b0e22032e..20c65870db 100644 --- a/apps/web/src/office-routes.tsx +++ b/apps/web/src/office-routes.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import ProjectDetailPage from "@/app/office/projects/[id]/page"; import AgentDetailLayout from "@/app/office/agents/[id]/layout"; import AgentChannelsPage from "@/app/office/agents/[id]/channels/page"; @@ -33,7 +34,9 @@ import { listAgentProfiles, listProjects, } from "@/lib/api/domains/office-api"; -import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useAppStoreApi } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { useWorkspaces } from "@/hooks/domains/workspace/use-workspaces"; import { useRouter, useSearchParams } from "@/lib/routing/client-router"; import { LEGACY_OFFICE_ACTIVE_WORKSPACE_COOKIE, @@ -41,7 +44,8 @@ import { readActiveWorkspaceCookie, readCookie, } from "@/lib/routing/route-bootstrap"; -import type { WorkspaceState } from "@/lib/state/slices/workspace/types"; +import { qk } from "@/lib/query/keys"; +import type { Workspace } from "@/lib/types/http"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { AgentDashboardRoute, @@ -55,14 +59,14 @@ type RouteRenderer = () => React.ReactNode; const OFFICE_ROUTES: Record = { "/office": () => , - "/office/inbox": () => , - "/office/tasks": () => , - "/office/projects": () => , - "/office/routines": () => , - "/office/agents": () => , - "/office/workspace/activity": () => , - "/office/workspace/costs": () => , - "/office/workspace/skills": () => , + "/office/inbox": () => , + "/office/tasks": () => , + "/office/projects": () => , + "/office/routines": () => , + "/office/agents": () => , + "/office/workspace/activity": () => , + "/office/workspace/costs": () => , + "/office/workspace/skills": () => , "/office/workspace/routing": () => , "/office/workspace/settings": () => , "/office/workspace/settings/sync": () => , @@ -71,8 +75,8 @@ const OFFICE_ROUTES: Record = { export function OfficeRoutes({ pathname }: { pathname: string }) { const router = useRouter(); - const officeEnabled = useAppStore((state) => state.features.office); - const workspaces = useAppStore((state) => state.workspaces); + const officeEnabled = useFeature("office"); + const { items: workspaceItems, activeId: activeWorkspaceId } = useWorkspaces(); const normalizedPathname = normalizeOfficePath(pathname); const routeWorkspaceId = useSearchParams().get("workspaceId"); const bootstrap = useOfficeRouteBootstrap(officeEnabled, routeWorkspaceId); @@ -80,7 +84,7 @@ export function OfficeRoutes({ pathname }: { pathname: string }) { normalizedPathname, bootstrap.complete, bootstrap.onboardingComplete, - workspaces.items, + workspaceItems, ); useEffect(() => { @@ -98,7 +102,12 @@ export function OfficeRoutes({ pathname }: { pathname: string }) { if ( setupRedirectHref || - shouldHoldOfficeHomeForBootstrap(normalizedPathname, bootstrap.complete, workspaces) + shouldHoldOfficeHomeForBootstrap( + normalizedPathname, + bootstrap.complete, + workspaceItems, + activeWorkspaceId, + ) ) { return ; } @@ -123,7 +132,7 @@ export function resolveOfficeHomeSetupRedirect( pathname: string, bootstrapComplete: boolean, onboardingComplete: boolean | null, - workspaceItems: WorkspaceState["items"], + workspaceItems: Workspace[], ): "/office/setup" | "/office/setup?mode=new" | null { if (pathname !== "/office" || !bootstrapComplete) return null; if (onboardingComplete === false) return "/office/setup"; @@ -196,6 +205,7 @@ function useOfficeRouteBootstrap( routeWorkspaceId: string | null, ): OfficeBootstrapState { const store = useAppStoreApi(); + const queryClient = useQueryClient(); const [bootstrap, setBootstrap] = useState({ complete: false, onboardingComplete: null, @@ -226,6 +236,7 @@ function useOfficeRouteBootstrap( } const workspaceItems = workspacesResponse.workspaces.map(mapWorkspaceItem); + queryClient.setQueryData(qk.workspaces.all(), workspaceItems); const officeWorkspaceItems = workspaceItems.filter( (workspace) => workspace.office_workflow_id, ); @@ -238,19 +249,15 @@ function useOfficeRouteBootstrap( ); store.getState().hydrate({ - workspaces: { items: workspaceItems, activeId: activeWorkspaceId }, + workspaces: { activeId: activeWorkspaceId }, userSettings: { ...mapUserSettingsResponse(userSettingsResponse), workspaceId: activeWorkspaceId, }, }); - store.getState().setMeta(metaResponse); + queryClient.setQueryData(qk.office.meta(), metaResponse); if (!activeWorkspaceId) { - store.getState().setOfficeAgentProfiles([]); - store.getState().setProjects([]); - store.getState().setInboxItems([]); - store.getState().setInboxCount(0); setBootstrap({ complete: true, onboardingComplete }); return; } @@ -265,10 +272,16 @@ function useOfficeRouteBootstrap( ]); if (cancelled) return; - store.getState().setOfficeAgentProfiles(agentsResponse.agents); - store.getState().setProjects(projectsResponse.projects); - store.getState().setInboxItems(inboxResponse.items); - store.getState().setInboxCount(inboxResponse.total_count); + queryClient.setQueryData(qk.office.agents(activeWorkspaceId), { + agents: agentsResponse.agents, + }); + queryClient.setQueryData(qk.office.projects(activeWorkspaceId), { + projects: projectsResponse.projects, + }); + queryClient.setQueryData(qk.office.inbox(activeWorkspaceId), { + items: inboxResponse.items, + total_count: inboxResponse.total_count, + }); setBootstrap({ complete: true, onboardingComplete }); } @@ -278,7 +291,7 @@ function useOfficeRouteBootstrap( return () => { cancelled = true; }; - }, [officeEnabled, routeWorkspaceId, store]); + }, [officeEnabled, queryClient, routeWorkspaceId, store]); return bootstrap; } @@ -286,14 +299,14 @@ function useOfficeRouteBootstrap( export function resolveActiveOfficeWorkspaceId( workspaceItems: { id: string; office_workflow_id?: string | null }[], routeWorkspaceId: string | null, - activeCookieWorkspaceId: string | null, - officeCookieWorkspaceId: string | null, + cookieWorkspaceId: string | null, + legacyCookieWorkspaceId: string | null, settingsWorkspaceId: string | null, ): string | null { return ( workspaceItems.find((workspace) => workspace.id === routeWorkspaceId)?.id ?? - workspaceItems.find((workspace) => workspace.id === activeCookieWorkspaceId)?.id ?? - workspaceItems.find((workspace) => workspace.id === officeCookieWorkspaceId)?.id ?? + workspaceItems.find((workspace) => workspace.id === cookieWorkspaceId)?.id ?? + workspaceItems.find((workspace) => workspace.id === legacyCookieWorkspaceId)?.id ?? workspaceItems.find((workspace) => workspace.id === settingsWorkspaceId)?.id ?? workspaceItems[0]?.id ?? null @@ -387,16 +400,17 @@ function OfficeRouteLoading() { function shouldHoldOfficeHomeForBootstrap( pathname: string, bootstrapComplete: boolean, - workspaces: WorkspaceState, + workspaceItems: Workspace[], + activeWorkspaceId: string | null, ): boolean { return ( pathname === "/office" && !bootstrapComplete && - (!hasOfficeWorkspace(workspaces.items) || !workspaces.activeId) + (!hasOfficeWorkspace(workspaceItems) || !activeWorkspaceId) ); } -function hasOfficeWorkspace(workspaceItems: WorkspaceState["items"]): boolean { +function hasOfficeWorkspace(workspaceItems: Workspace[]): boolean { return workspaceItems.some((workspace) => Boolean(workspace.office_workflow_id)); } diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts index 7197dec4b5..b5f126b117 100644 --- a/apps/web/src/settings-routes.test.ts +++ b/apps/web/src/settings-routes.test.ts @@ -1,10 +1,16 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; import { isValidElement, type ReactElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import IntegrationsGitLabPage from "@/app/settings/integrations/gitlab/page"; +import { qk } from "@/lib/query/keys"; import { workspaceId, workflowId } from "@/lib/types/ids"; import type { ListWorkspacesResponse, UserSettingsResponse } from "@/lib/types/http"; -import { buildSettingsInitialStateForRoute, renderSettingsRoute } from "./settings-routes"; +import { + applySettingsInitialState, + buildSettingsInitialStateForRoute, + renderSettingsRoute, +} from "./settings-routes"; const ACTIVE_WORKSPACE_COOKIE = "kandev-active-workspace"; const OWNER_ID = "owner-1"; @@ -38,6 +44,19 @@ describe("buildSettingsInitialStateForRoute", () => { expect(state.userSettings?.workspaceId).toBe("ws-2"); }); + it("uses the route workspace on workspace settings pages", () => { + document.cookie = `${ACTIVE_WORKSPACE_COOKIE}=ws-1; path=/`; + + const state = buildState({ + pathname: "/settings/workspace/ws-2/integrations/gitlab", + workspaces: workspaceRows(["ws-1", "ws-2"]), + userSettingsResponse: userSettings({ workspace_id: workspaceId("ws-1") }), + }); + + expect(state.workspaces?.activeId).toBe("ws-2"); + expect(state.userSettings?.workspaceId).toBe("ws-2"); + }); + it("falls back to user settings when cookie has an office workspace", () => { document.cookie = `${ACTIVE_WORKSPACE_COOKIE}=ws-office; path=/`; @@ -80,7 +99,6 @@ describe("buildSettingsInitialStateForRoute", () => { expect(state.workspaces).toEqual({ items: [], activeId: null }); expect(state.executors).toEqual({ items: [] }); - expect(state.agentProfiles).toEqual({ items: [], version: 0 }); expect(state.settingsAgents).toEqual({ items: [] }); expect(state.agentDiscovery).toEqual({ items: [], loading: false, loaded: true }); expect(state.availableAgents).toEqual({ @@ -89,7 +107,6 @@ describe("buildSettingsInitialStateForRoute", () => { loading: false, loaded: true, }); - expect(state.settingsData).toEqual({ executorsLoaded: true, agentsLoaded: true }); expect(state.userSettings).toBeUndefined(); }); }); @@ -116,10 +133,39 @@ describe("renderSettingsRoute", () => { }); }); +describe("applySettingsInitialState", () => { + it("hydrates the root store and seeds settings query keys", () => { + const queryClient = new QueryClient(); + const hydrate = vi.fn(); + const state = buildState({ + executors: [{ id: "executor-1", name: "Docker" }], + agents: [ + { + id: "agent-1", + name: "codex", + profiles: [{ id: "profile-1", agentDisplayName: "Codex", name: "Default" }], + }, + ], + } as unknown as Partial[0]>); + + applySettingsInitialState({ getState: () => ({ hydrate }) }, queryClient, state); + + expect(hydrate).toHaveBeenCalledWith(state); + expect(queryClient.getQueryData(qk.settings.executors())).toEqual({ + executors: [{ id: "executor-1", name: "Docker" }], + }); + expect(queryClient.getQueryData(qk.settings.agents())).toEqual({ + agents: state.settingsAgents?.items, + total: 1, + }); + }); +}); + function buildState( overrides: Partial[0]> = {}, ) { return buildSettingsInitialStateForRoute({ + pathname: "/settings", workspaces: [], executors: [], agents: [], diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx index babf2c0814..fa306c9457 100644 --- a/apps/web/src/settings-routes.tsx +++ b/apps/web/src/settings-routes.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; import AgentsSettingsPage from "@/app/settings/agents/page"; import AgentSetupPage from "@/app/settings/agents/[agentId]/page"; @@ -65,6 +66,7 @@ import { } from "@/lib/api/domains/settings-api"; import { listWorkflowTemplates } from "@/lib/api/domains/workflow-api"; import { listRepositories, listWorkspaces } from "@/lib/api/domains/workspace-api"; +import { seedQueryClientFromInitialState, type QuerySeedInitialState } from "@/lib/query/seed"; import { useRouter } from "@/lib/routing/client-router"; import { safeDecodePathSegment } from "@/lib/routing/path"; import { @@ -74,7 +76,6 @@ import { } from "@/lib/routing/route-bootstrap"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import type { AppState } from "@/lib/state/store"; -import { toAgentProfileOption } from "@/lib/state/slices/settings/types"; import type { ListWorkspacesResponse, Repository, @@ -98,6 +99,7 @@ type WorkspaceWorkflowsRouteState = { workflowTemplates: WorkflowTemplate[]; }; type SettingsInitialStateData = { + pathname: string; workspaces: ListWorkspacesResponse["workspaces"]; executors: Awaited>["executors"]; agents: Awaited>["agents"]; @@ -360,6 +362,7 @@ function SettingsRedirect({ to }: { to: string }) { function SettingsRouteBootstrap({ pathname }: { pathname: string }) { const store = useAppStoreApi(); + const queryClient = useQueryClient(); const bootstrappedRef = useRef(false); useEffect(() => { @@ -368,9 +371,9 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) { let cancelled = false; async function bootstrap() { - const initialState = await loadSettingsInitialState(); + const initialState = await loadSettingsInitialState(pathname); if (!cancelled && Object.keys(initialState).length > 0) { - store.getState().hydrate(initialState); + applySettingsInitialState(store, queryClient, initialState); } } @@ -379,12 +382,21 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) { cancelled = true; bootstrappedRef.current = false; }; - }, [pathname, store]); + }, [pathname, queryClient, store]); return null; } -async function loadSettingsInitialState(): Promise> { +export function applySettingsInitialState( + store: { getState: () => { hydrate: AppState["hydrate"] } }, + queryClient: QueryClient, + initialState: QuerySeedInitialState, +) { + store.getState().hydrate(initialState as Partial); + seedQueryClientFromInitialState(queryClient, initialState); +} + +async function loadSettingsInitialState(pathname: string): Promise { const [workspaces, executors, agents, discovery, available, userSettingsResponse] = await Promise.all([ listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })), @@ -396,6 +408,7 @@ async function loadSettingsInitialState(): Promise> { ]); return buildSettingsInitialStateForRoute({ + pathname, workspaces: workspaces.workspaces, executors: executors.executors, agents: agents.agents, @@ -407,6 +420,7 @@ async function loadSettingsInitialState(): Promise> { } export function buildSettingsInitialStateForRoute({ + pathname, workspaces, executors, agents, @@ -414,24 +428,24 @@ export function buildSettingsInitialStateForRoute({ availableAgents, availableTools, userSettingsResponse, -}: SettingsInitialStateData): Partial { +}: SettingsInitialStateData): QuerySeedInitialState { const workspaceItems = workspaces.map(mapWorkspaceItem); + const routeWorkspaceId = matchSingle(pathname, /^\/settings\/workspace\/([^/]+)/); + const routeActiveWorkspaceId = + routeWorkspaceId && workspaceItems.some((workspace) => workspace.id === routeWorkspaceId) + ? routeWorkspaceId + : null; const activeWorkspaceId = resolveSettingsActiveWorkspaceId( workspaceItems, readActiveWorkspaceCookie(), userSettingsResponse?.settings?.workspace_id ?? null, ); + const selectedWorkspaceId = routeActiveWorkspaceId ?? activeWorkspaceId; const mappedUserSettings = mapUserSettingsResponse(userSettingsResponse); return { - workspaces: { items: workspaceItems, activeId: activeWorkspaceId }, + workspaces: { items: workspaceItems, activeId: selectedWorkspaceId }, executors: { items: executors }, - agentProfiles: { - items: agents.flatMap((agent) => - agent.profiles.map((profile) => toAgentProfileOption(agent, profile)), - ), - version: 0, - }, settingsAgents: { items: agents }, agentDiscovery: { items: discoveryAgents, loading: false, loaded: true }, availableAgents: { @@ -440,12 +454,11 @@ export function buildSettingsInitialStateForRoute({ loading: false, loaded: true, }, - settingsData: { executorsLoaded: true, agentsLoaded: true }, ...(mappedUserSettings.loaded ? { userSettings: { ...mappedUserSettings, - workspaceId: activeWorkspaceId, + workspaceId: selectedWorkspaceId, }, } : {}), diff --git a/apps/web/src/spa-routes.tsx b/apps/web/src/spa-routes.tsx index 75f1591fff..06988cdc7b 100644 --- a/apps/web/src/spa-routes.tsx +++ b/apps/web/src/spa-routes.tsx @@ -1,4 +1,5 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { GitHubPageClient } from "@/app/github/github-page-client"; import { GitLabPageClient } from "@/app/gitlab/gitlab-page-client"; import { JiraPageClient } from "@/app/jira/jira-page-client"; @@ -22,12 +23,16 @@ import { listRepositories, listWorkspaces } from "@/lib/api/domains/workspace-ap import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; import { hasHydratedKanbanRouteState } from "@/lib/routing/kanban-route-hydration"; import { usePathname, useSearchParams } from "@/lib/routing/client-router"; +import { useCachedRepositories } from "@/hooks/domains/workspace/use-repository-cache"; +import { readWorkflowsByWorkspace } from "@/hooks/use-workflow-cache"; +import { qk } from "@/lib/query/keys"; import { mapWorkspaceItem, readActiveWorkspaceCookie } from "@/lib/routing/route-bootstrap"; import { resolveActiveId } from "@/lib/ssr/resolve-active-id"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import type { ListWorkflowStepsResponse, Repository, + Workspace, Workflow, WorkflowStep, } from "@/lib/types/http"; @@ -40,8 +45,6 @@ const SettingsRoutes = lazy(() => import("./settings-routes").then((mod) => ({ default: mod.SettingsRoutes })), ); -const EMPTY_REPOSITORIES: Repository[] = []; - type SpaRoute = | { kind: "kanban"; @@ -188,9 +191,20 @@ function KanbanRoute({ route }: { route: Extract } function useKanbanRouteBootstrap(route: Extract) { const store = useAppStoreApi(); + const queryClient = useQueryClient(); useEffect(() => { - if (hasHydratedKanbanRouteState(store.getState(), route)) return; + const state = store.getState(); + const workflowSnapshot = readWorkflowsByWorkspace(queryClient); + const cachedWorkflows = state.workspaces.activeId + ? (workflowSnapshot.workflowsByWorkspace[state.workspaces.activeId] ?? []) + : []; + const hydratedWorkflowIds = new Set( + cachedWorkflows + .filter((workflow) => queryClient.getQueryData(qk.workflows.snapshot(workflow.id))) + .map((workflow) => workflow.id), + ); + if (hasHydratedKanbanRouteState(state, route, cachedWorkflows, hydratedWorkflowIds)) return; let cancelled = false; @@ -204,6 +218,7 @@ function useKanbanRouteBootstrap(route: Extract) { const settingsWorkspaceId = settingsResponse?.settings?.workspace_id || null; const settingsWorkflowId = settingsResponse?.settings?.workflow_filter_id || null; const workspaceItems = workspacesResponse.workspaces.map(mapWorkspaceItem); + queryClient.setQueryData(qk.workspaces.all(), workspaceItems); const kanbanWorkspaceItems = workspaceItems.filter( (workspace) => !workspace.office_workflow_id, ); @@ -215,7 +230,7 @@ function useKanbanRouteBootstrap(route: Extract) { ); store.getState().hydrate({ - workspaces: { items: workspaceItems, activeId: activeWorkspaceId }, + workspaces: { activeId: activeWorkspaceId }, userSettings: { ...mapUserSettingsResponse(settingsResponse), workspaceId: activeWorkspaceId, @@ -247,18 +262,24 @@ function useKanbanRouteBootstrap(route: Extract) { workflowId, }, workflows: { - items: workflowsResponse.workflows.map(mapWorkflowItem), activeId: workflowId, }, }); - store.getState().setRepositories(activeWorkspaceId, repositoriesResponse.repositories); + queryClient.setQueryData( + qk.workflows.all(activeWorkspaceId, { includeHidden: true }), + workflowsResponse.workflows, + ); + queryClient.setQueryData( + qk.workspaces.repositories(activeWorkspaceId), + repositoriesResponse.repositories, + ); } void bootstrap(); return () => { cancelled = true; }; - }, [route.workspaceId, route.workflowId, store]); + }, [queryClient, route.workspaceId, route.workflowId, store]); } function DataBackedRoute({ @@ -393,11 +414,8 @@ function useRouteData({ const [workflows, setRouteWorkflows] = useState([]); const [steps, setSteps] = useState([]); const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); - const repositories = useAppStore((state) => - activeWorkspaceId - ? (state.repositories.itemsByWorkspaceId[activeWorkspaceId] ?? EMPTY_REPOSITORIES) - : EMPTY_REPOSITORIES, - ); + const repositories = useCachedRepositories(activeWorkspaceId); + const queryClient = useQueryClient(); useEffect(() => { if (bootstrappedRef.current) return; @@ -417,7 +435,12 @@ function useRouteData({ const storeWorkspaceId = store.getState().workspaces.activeId; const cookieWorkspaceId = readActiveWorkspaceCookie(); const workspaceItems = - workspacesResponse?.workspaces.map(mapWorkspaceItem) ?? store.getState().workspaces.items; + workspacesResponse?.workspaces.map(mapWorkspaceItem) ?? + queryClient.getQueryData(qk.workspaces.all()) ?? + []; + if (workspacesResponse) { + queryClient.setQueryData(qk.workspaces.all(), workspaceItems); + } const workspaceId = workspaceItems.length > 0 ? resolveActiveId( @@ -428,14 +451,16 @@ function useRouteData({ ) : firstKnownWorkspaceId(storeWorkspaceId, cookieWorkspaceId, settingsWorkspaceId); store.getState().hydrate({ - workspaces: { items: workspaceItems, activeId: workspaceId }, - workflows: { items: store.getState().workflows.items, activeId: settingsWorkflowId }, + workspaces: { activeId: workspaceId }, + workflows: { activeId: settingsWorkflowId }, userSettings: { ...mapUserSettingsResponse(settingsResponse), workspaceId }, }); if (!workspaceId) return; const [workflowsResponse, repositoriesResponse, stepsResponse] = await Promise.all([ - listWorkflows(workspaceId, { cache: "no-store" }).catch(() => ({ workflows: [] })), + listWorkflows(workspaceId, { cache: "no-store", includeHidden: true }).catch(() => ({ + workflows: [], + })), listRepositories(workspaceId, undefined, { cache: "no-store" }).catch(() => ({ repositories: [], })), @@ -443,17 +468,23 @@ function useRouteData({ ]); if (cancelled) return; - const workflowItems = workflowsResponse.workflows.map(mapWorkflowItem); const activeWorkflowId = resolveDesiredWorkflowId({ activeWorkflowId: store.getState().workflows.activeId, settingsWorkflowId, - workspaceWorkflows: workflowItems, + workspaceWorkflows: workflowsResponse.workflows, }); store.getState().hydrate({ - workflows: { items: workflowItems, activeId: activeWorkflowId }, + workflows: { activeId: activeWorkflowId }, }); - store.getState().setRepositories(workspaceId, repositoriesResponse.repositories); + queryClient.setQueryData( + qk.workflows.all(workspaceId, { includeHidden: true }), + workflowsResponse.workflows, + ); + queryClient.setQueryData( + qk.workspaces.repositories(workspaceId), + repositoriesResponse.repositories, + ); setRouteWorkflows(workflowsResponse.workflows); setSteps(stepsResponse.steps); } @@ -463,7 +494,7 @@ function useRouteData({ cancelled = true; bootstrappedRef.current = false; }; - }, [skipBootstrap, store]); + }, [queryClient, skipBootstrap, store]); return { activeWorkspaceId, workflows, steps, repositories }; } @@ -482,19 +513,6 @@ function firstKnownWorkspaceId(...ids: (string | null | undefined)[]): string | return null; } -function mapWorkflowItem(workflow: Workflow) { - return { - id: workflow.id, - workspaceId: workflow.workspace_id, - name: workflow.name, - description: workflow.description ?? null, - sortOrder: workflow.sort_order ?? 0, - ...(workflow.agent_profile_id ? { agent_profile_id: workflow.agent_profile_id } : {}), - ...(workflow.hidden !== undefined ? { hidden: workflow.hidden } : {}), - ...(workflow.style !== undefined ? { style: workflow.style } : {}), - }; -} - function normalizePath(pathname: string): string { if (!pathname || pathname === "/") return "/"; return pathname.length > 1 && pathname.endsWith("/") ? pathname.slice(0, -1) : pathname; diff --git a/apps/web/src/spa-routes.workspace.test.tsx b/apps/web/src/spa-routes.workspace.test.tsx index dd5490a056..54c2fd643b 100644 --- a/apps/web/src/spa-routes.workspace.test.tsx +++ b/apps/web/src/spa-routes.workspace.test.tsx @@ -1,4 +1,5 @@ import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, describe, expect, it, vi } from "vitest"; import { StateProvider } from "@/components/state-provider"; @@ -51,18 +52,11 @@ describe("SpaRoutes data-backed workspace context", () => { it("keeps the currently active workspace when opening GitHub from another workspace", async () => { mockGitHubWorkspaceBootstrap(); - render( - - - , - ); + renderSpaRoutes({ + workspaces: { + activeId: SELECTED_WORKSPACE_ID, + }, + }); await expectSelectedWorkspace(); }); @@ -71,11 +65,7 @@ describe("SpaRoutes data-backed workspace context", () => { document.cookie = `kandev-active-workspace=${SELECTED_WORKSPACE_ID}; path=/`; mockGitHubWorkspaceBootstrap(); - render( - - - , - ); + renderSpaRoutes(); await expectSelectedWorkspace(); }); @@ -84,16 +74,25 @@ describe("SpaRoutes data-backed workspace context", () => { document.cookie = `kandev-active-workspace=${SELECTED_WORKSPACE_ID}; path=/`; mockGitHubWorkspaceBootstrap({ workspacesError: new Error("network down") }); - render( - - - , - ); + renderSpaRoutes(); await expectSelectedWorkspace(); }); }); +function renderSpaRoutes(initialState?: Parameters[0]["initialState"]) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + + + , + ); +} + function mockGitHubWorkspaceBootstrap({ workspacesError }: { workspacesError?: Error } = {}) { window.history.replaceState({}, "", "/github"); if (workspacesError) { @@ -126,6 +125,7 @@ async function expectSelectedWorkspace() { }); expect(mocks.listWorkflows).toHaveBeenCalledWith(SELECTED_WORKSPACE_ID, { cache: "no-store", + includeHidden: true, }); } @@ -145,10 +145,6 @@ function workspace(id: string) { }; } -function workspaceState(id: string) { - return workspace(id); -} - function workflow(id: string, workspaceId: string) { return { id, diff --git a/apps/web/src/spa-routing.test.ts b/apps/web/src/spa-routing.test.ts index 440da0dbd5..c6dfddf4f3 100644 --- a/apps/web/src/spa-routing.test.ts +++ b/apps/web/src/spa-routing.test.ts @@ -4,6 +4,7 @@ import { officeRouteKey, resolveOfficeHomeSetupRedirect } from "./office-routes" import { getInitialPageProps } from "./spa-routing"; import { resolveSpaRoute } from "./spa-routes"; import { settingsRouteKey } from "./settings-routes"; +import { workflowId, workspaceId } from "@/lib/types/ids"; const OFFICE_HOME_PATH = "/office"; const OFFICE_SETUP_PATH = "/office/setup"; @@ -149,10 +150,10 @@ describe("officeRouteKey", () => { expect( resolveOfficeHomeSetupRedirect(OFFICE_HOME_PATH, true, true, [ { - id: "workspace-1", + id: workspaceId("workspace-1"), name: "Workspace", owner_id: "user-1", - office_workflow_id: "workflow-1", + office_workflow_id: workflowId("workflow-1"), created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-01T00:00:00Z", }, diff --git a/docs/debug/task-switching-regression.md b/docs/debug/task-switching-regression.md new file mode 100644 index 0000000000..31ed7bee0b --- /dev/null +++ b/docs/debug/task-switching-regression.md @@ -0,0 +1,115 @@ +# Task Switching Regression Debug Log + +Date: 2026-07-01 +Branch: `feature/tanstack-migration-801` +Initial head: `5c4b244d2c0361f1c4575619b4ccd8f8dd5f12b9` +Comparison target: `origin/main` + +## User Report + +Switching between multiple tasks from the sidebar feels slower on this branch than on `main`. +This branch briefly shows the full-page `Loading task...` spinner when switching task details; +`main` does not visibly show that spinner in the same workflow. + +## Working Hypothesis + +The visible spinner is rendered by `TaskLoadingState` in +`apps/web/components/task/task-page-content.tsx`. + +Early candidate: `useTaskDetails()` fetches `taskQueryOptions(effectiveTaskId)` when +`activeTaskId !== initialTaskId`. During client-side sidebar navigation, the route shell may still +have the previous route's `initialTask`, while `activeTaskId` is already updated to the clicked +task. If Query has no task-detail row yet, `resolveTaskContentState()` can choose the full-page +loading state until the task detail query resolves. + +## Timeline + +- Captured repo state: clean branch, 1 commit over `origin/main`. +- Read frontend guidance in `apps/web/AGENTS.md`. +- Found likely spinner and task-detail query path in `TaskPageContent`. +- Created detached comparison worktree at `/tmp/kandev-main-compare` from `origin/main`. +- Compared `TaskPageContent` between branch and `origin/main`. +- Confirmed `main` used `state.kanban.tasks` as an immediate fallback when the sidebar changed + `activeTaskId` before route props caught up. This branch removed that fallback as part of + Zustand server-state cleanup, so an uncached task-detail query produced the full-page loading + state. +- Implemented Query-owned replacement fallback: + - scan cached workflow snapshots via `workflowSnapshotQueryData(queryClient)`; + - use the matching snapshot task as a temporary task row; + - keep the detail query enabled so full task details still replace the snapshot row when loaded. +- Verified the real sidebar click path with a Playwright regression: + - create two real tasks in the same workflow; + - open task A and wait until task B is visible in the sidebar, proving the sidebar snapshot is + cached; + - block `/api/v1/tasks/:taskB`; + - click task B in the sidebar; + - assert the URL and breadcrumb switch to task B while `task-loading-state` never mounts. +- Did not add temporary frontend/backend debug logs. The branch/main code comparison and the + blocked-detail browser repro isolated the issue to the frontend fallback path. + +## Local Verification Commands + +- Passed: `rtk pnpm --dir apps/web test components/task/task-page-content.test.tsx` + - 1 file, 7 tests. +- Passed: `rtk pnpm --dir apps/web test components/task/task-select-routing-hydration.test.ts components/task/task-select-helpers.test.ts components/task/task-session-sidebar.test.tsx components/task/mobile/session-task-switcher-sheet-hooks.test.tsx hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts` + - 5 files, 32 tests. +- Passed: `rtk pnpm --dir apps/web exec eslint --max-warnings 0 components/task/task-page-content.tsx components/task/task-page-content-helpers.ts components/task/task-page-content.test.tsx e2e/tests/task/task-loading-state.spec.ts e2e/tests/task/task-loading-state-helpers.ts` +- Passed: `rtk pnpm --dir apps/web typecheck` +- Passed: `rtk git diff --check` +- Passed: `rtk pnpm --dir apps/web e2e:run --host --project chromium tests/task/task-loading-state.spec.ts tests/office/tasks.spec.ts tests/office/topbar-breadcrumb.spec.ts tests/office/projects.spec.ts tests/office/project-repository-picker.spec.ts` + - 17 passed, 1 skipped. +- Passed: `rtk pnpm --dir apps/web e2e:run --host --project chromium tests/task/task-loading-state.spec.ts` + - 2 Chromium browser tests. + +## Findings + +- Root cause is frontend-side. No backend delay was needed to explain the extra spinner. +- The regression is not TanStack Query itself; it is the missing immediate fallback after deleting + the old `state.kanban.tasks` mirror. +- Query workflow snapshots already contain full `Task` rows, so they can replace the old fallback + without reintroducing Zustand server state. +- The sidebar task source is already Query workflow snapshots (`useWorkspaceSidebarTasks` -> + `useAllWorkflowSnapshots`), so a visible sidebar task has the same snapshot data needed for this + fallback. + +## Fix Notes + +- Changed `useTaskDetails()` to resolve `taskDetails > initialTask > cachedSnapshotTask`. +- Extended `hasResolvedTaskDetails()` so snapshot fallback suppresses the load-error/loading path. +- Added a focused test for rendering a changed active task from cached workflow snapshot data while + the task detail query is still in flight. +- Added a browser regression that blocks the target task-detail endpoint while switching via the + sidebar and asserts the full-page loading spinner does not appear. + +## Follow-up Audit: Other Fast-Path Detail Pages + +User follow-up: check whether other pages have the same "render from warm cache while detail loads" +mechanism. + +### Already covered + +- Kanban/task detail: now uses `taskDetails > initialTask > cached workflow snapshot task`. +- Office agent detail: reads agent identity from the already-seeded `qk.office.agents(workspaceId)` + list via `useOfficeAgentProfile`; there is no separate agent-detail fetch spinner in the layout. +- Office run detail: the server route loads both the run aggregate and recent-runs sidebar before + render, then seeds Query in `RunDetailView`; the client uses `detailQuery.data ?? initial`. +- Office list/dashboard pages: list pages use `query.data ?? initial* ?? []` where server initial + payloads exist, so refreshes do not blank the page. + +### Gaps found and fixed + +- Office project detail previously read only `qk.office.project(projectId)` and showed + `Loading project...` if the detail query was not warm. It now subscribes to the cached workspace + project list and renders the matching project row while the canonical detail query loads. +- Office task detail previously initialized local task state only from + `officeTaskQueryOptions(workspaceId, taskId)`. It now subscribes to cached office task infinite + pages and maps the matching row through the existing `mapOfficeTaskToTask` fallback while the + canonical detail query, comments, activity, and sessions continue loading. + +### Follow-up Verification Commands + +- Passed: `rtk pnpm --dir apps/web test app/office/projects/[id]/project-query-cache.test.ts app/office/tasks/[id]/task-detail-query-cache.test.ts components/task/task-page-content.test.tsx` + - 3 files, 14 tests. +- Passed: `rtk pnpm --dir apps/web exec eslint --max-warnings 0 app/office/projects/[id]/page.tsx app/office/projects/[id]/project-query-cache.ts app/office/projects/[id]/project-query-cache.test.ts app/office/tasks/[id]/page.tsx app/office/tasks/[id]/task-detail-query-cache.ts app/office/tasks/[id]/task-detail-query-cache.test.ts components/task/task-page-content.tsx components/task/task-page-content-helpers.ts components/task/task-page-content.test.tsx e2e/tests/task/task-loading-state.spec.ts e2e/tests/task/task-loading-state-helpers.ts` +- Passed: `rtk pnpm --dir apps/web typecheck` +- Passed: `rtk git diff --check` diff --git a/docs/plans/tanstack-query-server-state/plan.md b/docs/plans/tanstack-query-server-state/plan.md new file mode 100644 index 0000000000..6bd84b2e75 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/plan.md @@ -0,0 +1,1448 @@ +--- +spec: docs/specs/ui/tanstack-query-server-state.md +created: 2026-06-23 +status: done +--- + +# Implementation Plan: TanStack Query Server State + +## Overview + +Migrate the web frontend's server state from Zustand/fetch-effect ownership to +TanStack Query in one PR. The old PR #1130 provides the useful shape: typed +query keys, domain query options, WS -> query bridges, and E2E WebSocket +accounting. This plan adapts that path to the current Go-served Vite SPA: +`src/main.tsx`, `StateProvider`, `StateHydrator`, `src/*-routes.tsx`, and the +Go boot payload are the hydration boundary now, not Next layout/server +components. + +The PR should land as one branch, but implementation is split into waves so each +domain can be tested and reviewed before removing the old Zustand server-state +handlers. + +--- + +## Backend + +### WebSocket Envelope Accounting + +Files: + +- `apps/backend/pkg/websocket/message.go` +- `apps/backend/internal/gateway/websocket/client.go` +- `apps/backend/internal/gateway/websocket/hub.go` +- `apps/backend/internal/gateway/websocket/hub_session_mode.go` +- new `apps/backend/internal/gateway/websocket/ws_sent_log.go` +- new/updated tests under `apps/backend/internal/gateway/websocket/*_test.go` +- E2E test endpoint in `apps/backend/cmd/kandev/e2e_reset.go` or adjacent test + harness route file + +Changes: + +- Add `connection_id`, `connection_seq`, and optional `session_seq` to outbound + WS messages. +- Stamp messages at the final send boundary so every client receives its own + monotonic connection sequence. +- Maintain per-session sequence counters for session-scoped broadcasts. +- Record stamped envelopes in a bounded backend sent-log ring buffer. +- Add an E2E-only sent-log endpoint gated by the existing E2E mock/test harness. +- Keep the current session-focus/focused-recipient semantics. + +Reason: + +The current runner sets `KANDEV_E2E_WS_ASSERT=1`, but this checkout has no +sent-log, frontend `WsAccount`, or sequence fields. The docs and runner need a +real implementation before strict mode can mean anything. + +--- + +## Frontend + +### Query Foundation + +Files: + +- `apps/web/package.json` +- `apps/pnpm-lock.yaml` +- new `apps/web/lib/query/client.ts` +- new `apps/web/lib/query/provider.tsx` +- new `apps/web/lib/query/keys.ts` +- new `apps/web/lib/query/query-options/*` +- `apps/web/src/main.tsx` +- `apps/web/components/state-provider.tsx` +- `apps/web/components/state-hydrator.tsx` +- `apps/web/src/boot-payload.ts` +- `apps/web/src/spa-routes.tsx` +- `apps/web/src/office-routes.tsx` +- `apps/web/src/task-detail-route.tsx` + +Changes: + +- Add `@tanstack/react-query`, `@tanstack/react-query-devtools`, and + `@tanstack/eslint-plugin-query` at the current registry version observed on + 2026-06-23: `5.101.1`. +- Create a browser-singleton `QueryClient` with Kandev defaults: + `staleTime: 30_000`, `gcTime: 5 * 60_000`, no global focus/reconnect refetch, + auth errors not retried, and non-idempotent mutations not retried. +- Wrap the SPA root in `QueryProvider` from `src/main.tsx`. +- Expose `window.__KANDEV_E2E_QUERY_CLIENT__` only when + `__KANDEV_E2E_EXPOSE_STORE__` is set. +- Add helpers to seed query data from existing boot payload/app-state route data + and from `StateHydrator` calls. + +Reason: + +The old PR wired React Query through `app/layout.tsx` and Next-style hydration. +The current app boots through `src/main.tsx` after `loadBootPayload()`, so query +hydration must integrate with that boundary. + +### Query Keys And Query Options + +Files: + +- `apps/web/lib/query/keys.ts` +- `apps/web/lib/query/query-options/*.ts` +- existing API clients under `apps/web/lib/api/domains/*` +- focused unit tests under `apps/web/lib/query/**` + +Domains: + +- features +- workspace/repositories/branches/scripts +- kanban/workflows/tasks +- session/task sessions/messages/turns/plans/queue +- session runtime/git/status/prepare/context/commands/models/prompt usage +- office/dashboard/tasks/agents/projects/inbox/activity/runs/routing/costs/skills +- settings/executors/agents/editors/prompts/secrets/sprites/user settings +- integrations shell plus Jira, Linear, GitHub, GitLab, Slack/Sentry as present +- automations/system data where currently fetched into Zustand + +Rules: + +- Every query has a typed key factory and a query option factory. +- Infinite/paginated resources use `useInfiniteQuery` and keep cursor fields out + of the stable filter key. +- Presentational sort/grouping stays out of fetch keys unless the backend query + itself changes. +- Session message/turn queries use longer stale windows and explicit recovery + invalidation so live streams are not clobbered. + +### WebSocket Query Bridge + +Files: + +- `apps/web/lib/ws/connection.ts` +- `apps/web/lib/ws/client.ts` +- `apps/web/lib/ws/router.ts` +- new `apps/web/lib/query/bridge/index.ts` +- new `apps/web/lib/query/bridge/.ts` +- new bridge tests under `apps/web/lib/query/bridge/**` + +Changes: + +- Add `subscribeWebSocketClient(listener)` so QueryBridge can register even when + it mounts before the WS client exists. +- Register a query bridge beside or instead of existing Zustand handlers. +- Wrap every bridge handler in `wrapBridgeHandler` for E2E audit. +- Export `BRIDGE_SKIPPED_ACTIONS` and `BRIDGE_SKIPPED_PREFIXES` with inline + rationale for control-plane responses, client-only handlers, and high-volume + streams. +- Remove each old Zustand WS handler only after all readers for that domain + read TanStack Query. + +Reason: + +PR #1130's own Phase 2 notes found the key failure mode: "bridge wrote, UI did +not read." This plan requires UI readers and bridge writers to converge per +domain before the old path is removed. + +### E2E WebSocket Accounting + +Files: + +- new `apps/web/lib/ws/ws-account.ts` +- new `apps/web/lib/ws/ws-account.test.ts` +- new `apps/web/e2e/helpers/ws-account.ts` +- `apps/web/e2e/fixtures/test-base.ts` +- `apps/web/e2e/helpers/api-client.ts` +- `apps/web/e2e/scripts/run-e2e.sh` +- new `apps/web/e2e/tests/system/ws-event-accounting.spec.ts` + +Changes: + +- Record parsed WS envelopes at `WebSocketClient.handleParsedMessage` before + response/error/notification dispatch. +- Track both connection-wide and per-session sequence windows. +- At test teardown, compare backend sent-log entries with frontend parsed + entries when `KANDEV_E2E_WS_ASSERT=1`. +- Add bridge audit comparison for parsed server-state events with `session_id` + or `task_id`. +- Keep the existing targeted `routeWebSocket` gap tests; they validate recovery + from an intentionally dropped `session.message.added` frame. + +--- + +## Tests + +### Unit Tests + +- **What:** query key stability and serializability + **File:** `apps/web/lib/query/keys.test.ts` + **How:** table-driven Vitest cases for representative keys and partial + invalidation prefixes. + +- **What:** query option factories map API responses without redundant fetches + **File:** `apps/web/lib/query/query-options/*.test.ts` + **How:** mocked domain API functions plus `queryClient.fetchQuery`. + +- **What:** WS bridge handlers patch/invalidate correct keys + **File:** `apps/web/lib/query/bridge/**.test.ts` + **How:** create `QueryClient`, register bridge handler with fake WS client, + emit payloads, assert query cache changes and bridge audit rows. + +- **What:** session message merge/backfill preserves live WS messages and + synthetic empty-turn notices + **File:** `apps/web/lib/query/query-options/session.test.ts` + **How:** unit tests for merge helpers and query function behavior. + +- **What:** office paginated task queries keep filter/cursor semantics + **File:** `apps/web/lib/query/query-options/office.test.ts` + **How:** infinite-query tests for first page, next page, filter reset, and WS + invalidation against every filter entry. + +- **What:** backend WS sequence stamping and sent-log eviction + **File:** `apps/backend/internal/gateway/websocket/*_test.go` + **How:** Go unit tests for per-client connection seq, per-session seq, clone + isolation, ring eviction, and unsubscribe cleanup. + +- **What:** frontend `WsAccount` detects connection and session gaps + **File:** `apps/web/lib/ws/ws-account.test.ts` + **How:** feed ordered, missing, duplicated, and mixed-session envelopes. + +### Integration Tests + +- **What:** boot payload seeds query cache before child hooks fetch + **File:** `apps/web/src/boot-payload.test.ts`, `apps/web/src/spa-routing.test.ts` + **How:** mount route under `QueryProvider`, assert query data exists and API + fetch mocks are not called when payload includes route data. + +- **What:** `StateHydrator` seeds query cache on task-detail route transitions + **File:** `apps/web/components/state-hydrator.test.tsx` or route-specific + tests + **How:** render with initial state and assert session/task query keys are + populated before dependent hook assertions. + +- **What:** old Zustand server-state handlers are removed safely + **File:** existing `apps/web/lib/ws/handlers/*.test.ts` migrated to bridge + tests + **How:** move assertions from store mutation to query cache mutation before + deleting the old handler. + +--- + +## E2E Tests + +Run all migration E2E gates from `apps/web`. + +For normal browser E2E, use `pnpm e2e:docker ...`, not bare +`pnpm e2e:run ...`, so local validation uses the CI runtime image and cannot +silently fall back to host mode. The managed Docker runner enables +`KANDEV_E2E_WS_ASSERT=1` by default. + +For Docker/SSH executor coverage, use the Playwright `containers` project with +host Docker access: + +```bash +KANDEV_E2E_CONTAINERS=1 pnpm e2e --project=containers +``` + +Do not run the `containers` project through `pnpm e2e:docker`; those specs need +to control the host Docker daemon. + +- **Scenario:** boot-hydrated task page renders messages and sessions with no + spinner-only first paint + **File:** `apps/web/e2e/tests/session/session-hydration.spec.ts` + **What to verify:** route loads, chat messages visible, no manual refresh. + +- **Scenario:** missed `session.message.added` for a sent prompt still renders + accepted prompt + **File:** existing `apps/web/e2e/tests/chat/message-add-ws-gap.spec.ts` and + `apps/web/e2e/tests/chat/mobile-message-add-ws-gap.spec.ts` + **What to verify:** keep both desktop and mobile tests green after migration. + +- **Scenario:** office dashboard updates from WS/query invalidation + **File:** existing office realtime specs plus + `apps/web/e2e/tests/office/realtime-dashboard.spec.ts` + **What to verify:** dashboard metric/activity changes without reload. + +- **Scenario:** office tasks pagination survives WS updates + **File:** `apps/web/e2e/tests/office/realtime-tasks.spec.ts` + **What to verify:** filters remain selected, loaded pages are not duplicated, + changed task appears. + +- **Scenario:** strict WS accounting detects no receipt or bridge gaps + **File:** `apps/web/e2e/tests/system/ws-event-accounting.spec.ts` + **What to verify:** two concurrent sessions produce no connection/session + gaps and at least one bridged query cache mutation per relevant event. + +- **Scenario:** mobile parity for migrated chat/task/office paths + **File:** existing `mobile-*.spec.ts` files and targeted additions only where + a migrated workflow lacks mobile coverage + **What to verify:** touch-accessible controls and same data freshness. + +--- + +## E2E Wave Gates + +Each wave is incomplete until its Docker-backed E2E gate has run locally and the +task output records the exact command, result, and artifact path for any +failure. Host-mode E2E is useful for debugging but does not satisfy these gates. + +Wave 1 (foundation and WS accounting): + +```bash +cd apps/web && pnpm e2e:docker -- tests/system/ws-event-accounting.spec.ts tests/chat/message-add-ws-gap.spec.ts tests/task/task-list.spec.ts tests/kanban/kanban-board.spec.ts +cd apps/web && pnpm e2e:docker --no-build --project mobile-chrome -- tests/chat/mobile-message-add-ws-gap.spec.ts +``` + +Wave 2 (query taxonomy and WS bridge): + +```bash +cd apps/web && pnpm e2e:docker tests/system/ws-event-accounting.spec.ts tests/chat/message-add-ws-gap.spec.ts +cd apps/web && pnpm e2e:docker --project mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts +``` + +Wave 3 (domain migrations): + +```bash +cd apps/web && pnpm e2e:docker tests/task/task-list.spec.ts tests/task/task-list-filters.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/workflow-filter.spec.ts tests/settings/config-management.spec.ts +cd apps/web && pnpm e2e:docker tests/office/realtime-dashboard.spec.ts tests/office/realtime-tasks.spec.ts tests/office/dashboard.spec.ts tests/office/tasks.spec.ts +cd apps/web && pnpm e2e:docker tests/chat/message-add-ws-gap.spec.ts tests/chat/message-pagination.spec.ts tests/session/session-tab-management.spec.ts tests/session/session-recovery.spec.ts +cd apps/web && pnpm e2e:docker tests/terminal/terminal-hanging-on-boot.spec.ts tests/terminal/terminal-dockview-ui.spec.ts tests/git/git-changes-panel.spec.ts +cd apps/web && pnpm e2e:docker tests/github tests/integrations tests/system/status-page.spec.ts tests/system/database-page.spec.ts +cd apps/web && pnpm e2e:docker --project mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-task-list-search.spec.ts tests/chat/mobile-message-add-ws-gap.spec.ts tests/office/mobile-onboarding.spec.ts tests/github/mobile-github-sidebar.spec.ts tests/integrations/mobile-linear-watcher-profile.spec.ts tests/terminal/mobile-terminal-keybar.spec.ts +cd apps/web && pnpm e2e:docker --project routing +``` + +Wave 4 (cleanup and full regression): + +```bash +cd apps/web && pnpm e2e:docker --shards 3 +cd apps/web && pnpm e2e:docker --project mobile-chrome +cd apps/web && pnpm e2e:docker --project routing +cd apps/web && KANDEV_E2E_CONTAINERS=1 pnpm e2e --project=containers +``` + +If Docker is unavailable, the wave is blocked rather than waived. Record the +Docker failure and run host-mode E2E only as diagnostic evidence while fixing +the local Docker/runtime issue. + +--- + +## Implementation Waves + +Wave 1 (foundation): + +- [x] [task-01-query-foundation](task-01-query-foundation.md) +- [x] [task-02-ws-accounting](task-02-ws-accounting.md) + +Wave 2 (cache taxonomy and bridge): + +- [x] [task-03-query-options-taxonomy](task-03-query-options-taxonomy.md) +- [x] [task-04-query-bridge-audit](task-04-query-bridge-audit.md) + +Wave 3 (domain migrations, can run in parallel by area after Wave 2): + +- [x] [task-05-workspace-kanban-settings](task-05-workspace-kanban-settings.md) +- [x] [task-06-office-domain](task-06-office-domain.md) +- [x] [task-07-session-domain](task-07-session-domain.md) +- [x] [task-08-session-runtime-streams](task-08-session-runtime-streams.md) +- [x] [task-09-integrations-automations-system](task-09-integrations-automations-system.md) + +Task 05 verification completed locally: + +- `cd apps && pnpm --filter @kandev/web test -- hooks src lib/query` passed + 89 files / 672 tests. +- `cd apps/web && pnpm typecheck` passed. +- `cd apps/web && pnpm e2e:docker --no-build -- tests/task/task-list.spec.ts tests/task/task-list-filters.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/workflow-filter.spec.ts tests/settings/config-management.spec.ts` + passed 35 desktop tests. +- `cd apps/web && pnpm e2e:docker --no-build --project mobile-chrome -- tests/kanban/mobile-kanban.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed 12 mobile tests. + +Task 06 verification completed locally: + +- `cd apps && pnpm --filter @kandev/web test -- app/office hooks/domains/office lib/query` + passed 25 files / 124 tests. +- `cd apps/web && pnpm typecheck` passed. +- `cd apps/web && pnpm e2e:docker -- tests/office/realtime-dashboard.spec.ts tests/office/realtime-tasks.spec.ts tests/office/dashboard.spec.ts tests/office/tasks.spec.ts` + passed 21 desktop Docker tests. +- `cd apps/web && pnpm e2e:docker -- --project=mobile-chrome tests/office/mobile-onboarding.spec.ts` + passed 6 mobile Docker tests. +- `cd apps/web && e2e/scripts/run-e2e.sh --docker --no-build --project routing` + passed 7 routing Docker tests. +- Final reopened Office cleanup also passed: + - `rtk pnpm --dir apps/web test hooks/domains/office/use-office-data.test.tsx app/office/agents/[id]/components/agent-configuration-tab.test.tsx app/office/agents/[id]/components/agent-runs-tab.test.tsx app/office/components/new-task-dialog.test.tsx app/office/workspace/org/org-tree-layout.test.ts app/office/page-client.test.tsx lib/query/seed.test.ts components/state-hydrator.test.tsx lib/query/bridge/index.test.ts components/task/simple/components/pending-approval-badge.test.tsx` + passed 10 files / 68 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web lint` passed. + - Stale scans for removed Office store fields/actions returned no production + server-state readers/writers; remaining `office.tasks.*` matches are + client-only task filter/sort/view/grouping/nesting state. + - `rtk pnpm --dir apps/web e2e:docker tests/office/agents.spec.ts tests/office/agent-subroutes.spec.ts tests/office/agent-roles.spec.ts tests/office/agent-skills-ui.spec.ts tests/office/permissions.spec.ts tests/office/projects.spec.ts tests/office/project-repository-picker.spec.ts tests/office/routines.spec.ts tests/office/routines-ui.spec.ts tests/office/routine-fire.spec.ts tests/office/skills.spec.ts tests/office/system-skills.spec.ts tests/office/skills-readonly.spec.ts tests/office/org-chart.spec.ts tests/office/execution-stages.spec.ts tests/office/costs.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 67 Docker tests / 5 skipped with strict WS accounting. + +Task 07 verification completed locally: + +- `cd apps && pnpm --filter @kandev/web test -- lib/query/keys.test.ts lib/query/seed.test.ts lib/query/query-options/query-options.test.ts lib/query/bridge/index.test.ts hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-session-search.test.ts hooks/domains/session/use-session-state.test.ts hooks/domains/session/use-session-actions.test.ts hooks/domains/session/use-ensure-task-session.test.ts components/task/chat/message-list-shared.test.tsx components/task/chat/queued-ghost-list.test.tsx hooks/use-plan-panel-auto-open.test.ts hooks/use-task-removal.test.ts components/task/passthrough-chat-composer.test.ts` + passed 14 files / 148 tests. +- `cd apps && pnpm --filter @kandev/web test -- hooks/domains/session components/task/chat lib/query` + passed 58 files / 451 tests. +- `cd apps/web && pnpm typecheck` passed. +- Direct API-read scan for migrated session/task-plan/queue reads passed; only + query-option factories and the server boot loader still call those APIs. +- `cd apps/web && pnpm e2e:docker tests/chat/message-add-ws-gap.spec.ts tests/chat/message-pagination.spec.ts tests/session/session-tab-management.spec.ts tests/session/session-recovery.spec.ts tests/chat/message-queue.spec.ts tests/task/plan-checkpointing.spec.ts tests/chat/implement-plan-fresh.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 35 desktop Docker tests. +- `cd apps/web && e2e/scripts/run-e2e.sh --docker --no-build --project mobile-chrome -- tests/chat/mobile-message-add-ws-gap.spec.ts tests/session/mobile-transient-retry.spec.ts` + passed 2 mobile Docker tests. + +Task 08 verification completed locally: + +- `cd apps/web && rtk pnpm typecheck` passed. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/session components/session components/task lib/query` + passed 153 files / 1264 tests / 4 skipped. +- `cd apps/web && rtk pnpm e2e:docker tests/terminal/terminal-hanging-on-boot.spec.ts tests/terminal/terminal-dockview-ui.spec.ts tests/git/git-changes-panel.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 30 desktop Docker tests. +- `cd apps/web && rtk e2e/scripts/run-e2e.sh --docker --no-build --project mobile-chrome -- tests/terminal/mobile-terminal-keybar.spec.ts tests/terminal/mobile-terminal-scroll.spec.ts` + passed 16 mobile Docker tests. + +Task 09 verification completed locally: + +- `cd apps/web && rtk pnpm typecheck` passed. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/settings hooks/domains/system hooks/domains/github hooks/domains/gitlab hooks/domains/jira hooks/domains/linear hooks/domains/sentry hooks/domains/slack hooks/domains/integrations components/github components/gitlab components/jira components/linear components/slack components/sentry components/automations components/settings/system lib/query` + passed 55 files / 487 tests. +- `cd apps && rtk pnpm --filter @kandev/web test -- lib/ws/ws-account-e2e-helper.test.ts lib/ws/ws-account.test.ts lib/ws/client.test.ts e2e/helpers` + passed 3 files / 15 tests. +- `cd apps/web && rtk pnpm exec eslint --max-warnings 0 app/jira/jira-page-client.tsx app/linear/linear-page-client.tsx` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/system/status-page.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 5 desktop Docker tests after fixing the strict WS accounting reload + race found by the gate. +- `cd apps/web && rtk pnpm e2e:docker tests/system/ws-event-accounting.spec.ts tests/integrations/jira-settings.spec.ts tests/integrations/linear-settings.spec.ts tests/integrations/sentry-settings.spec.ts tests/integrations/github-watch-reset.spec.ts tests/integrations/jira-import.spec.ts tests/integrations/linear-import.spec.ts tests/github/pr-list-task-indicator.spec.ts tests/github/github-scope-bar.spec.ts tests/pr/ci-automation-options.spec.ts tests/automations-settings.spec.ts tests/system/status-page.spec.ts tests/system/database-page.spec.ts tests/system/disk-usage.spec.ts tests/system/backups-page.spec.ts tests/system/updates-page.spec.ts tests/system/logs-page.spec.ts` + passed 60 desktop Docker tests / 1 skipped. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/integrations/mobile-linear-watcher-profile.spec.ts tests/github/mobile-github-sidebar.spec.ts tests/settings/mobile-general-settings.spec.ts tests/mobile-automations-scroll.spec.ts` + passed 4 mobile Docker tests. + +Task 10 cleanup progress: + +- System cleanup removed the system Zustand slice and `system-events` WS handler. + System hooks and topbar metrics now read Query caches/options directly. +- GitHub cleanup removed server-backed GitHub Zustand fields and the old GitHub + WS handler. Local-only `pendingPrUrlByTaskId` and `prFeedbackCache` remain. +- GitLab cleanup removed the GitLab Zustand slice entirely. Status, stats, + workspace/task MRs, review watches, issue watches, and action presets now + read and mutate TanStack Query caches directly. `/gitlab` and GitLab settings + use `useGitLabStatus` instead of direct status fetch effects. +- Settings leaf-list cleanup converted prompts, secrets, sprites, + notification providers, available agents, agent discovery, and editors hooks + to Query-only readers. Prompts, secrets, editor mutations, agent refreshes, + profile status panels, and prompt previews now patch/read Query caches instead + of these Zustand mirrors. +- Settings catalog/bootstrap cleanup converted executors, settings agents, + derived agent profiles, install jobs, settings route bootstrap, settings + layout, and session boot preload to Query seed/query reads. Settings agent and + executor write paths now patch `qk.settings.*` caches directly through sync + helpers. The old `agents`, `executors`, and `executor-profiles` WS handlers + were removed after their store readers/writers were gone. The settings + Zustand slice now contains only `userSettings`. +- Workspace/kanban direct-fetch cleanup added a workflow-step Query key and + query option, moved the shared `useWorkflowSteps` hook, task-create workflow + step effect, and automations config workflow-step picker to Query, and made + `workflow.step.*` bridge events invalidate the workflow-step cache. Unused + workspace metadata maps (`repositories` loaded/loading flags, repository + branch loaded/loading/fetchedAt/fetchError flags, and repository script + loaded/loading flags) were removed from the Zustand slice, boot payloads, SSR + session state, and old writer actions. The actual workspace, workflow, + `kanban`, and `kanbanMulti` entity mirrors remain + because mounted UI readers still use them as migration fallbacks. The mobile + task switcher workspace-change path now fetches workflows and the first + workflow snapshot through Query options instead of direct API imports. The + task-detail route's active-task switch path now reads `taskQueryOptions` + instead of running a component-owned `fetchTask` effect. Command-panel task + search now reads `workspaceTasksQueryOptions` instead of importing + `listTasksByWorkspace` directly, preserving active-task filtering and + archived-last search ordering. Archive/delete dialog subtask counts now fetch + through `queryClient.fetchQuery(subtaskCountQueryOptions(...))` while keeping + the existing no-stale-count-on-reopen behavior. +- Session dead-code cleanup removed production-unused `clearTaskPlan` and + `clearQueueStatus` Zustand actions and their action-only tests, and removed + the legacy no-op `task.plan.reverted` registration from the old `task-plans` + WS handler. The Query bridge remains the owner for `task.plan.reverted`. + Session/chat/plan/queue mirrors remain because mounted readers still use + them. +- Office task list/detail cleanup moved task rows/loading out of + `office.tasks.items` / `office.tasks.isLoading`, removed the task page's + SSR-to-store hydration, removed task detail's store fallback, and dropped the + last production `useOfficeRefetch` callers. Task filter/sort/view/grouping/ + nesting state remains in Zustand as client-only UI state. +- Office task helper/scaffold cleanup moved project task sections, agent run + linked task labels, and simple-pane parent/blocker task candidates to Query. + The unused `useOfficeRefetch` hook, legacy Office WS handler registration/test, + `office.refetchTrigger`, and the unused Office task server-state fields/actions + were removed. +- Office simple-pane reference-data cleanup moved task detail chat/activity/ + session labels, assignee/project/reviewer/approver pickers, pending approval + badges, and run-error labels from `office.agentProfiles`/`office.projects` + store mirrors to active-workspace Office Query caches. +- Final Office store cleanup moved agent detail/routes, project detail and + writes, create-agent/create-project flows, routines, workspace skills, org + chart, new-task reference selectors, Office route bootstrap, and costs boot + data to Office Query caches. The Office Zustand slice now retains only task + filter/sort/view/grouping/nesting UI state. +- Queue mirror cleanup moved `useQueue` to read/write `qk.session.queue` + directly, with Query-cache optimistic removal and mutation-local loading + state. The old queue Zustand state/actions, default-state/root-store + declarations, action-only queue tests, and duplicate + `message.queue.status_changed` registration in the old `agent-session` WS + handler were removed. Queue status WS updates now flow through the Query + bridge only. +- Plan context cleanup moved the `@Plan` context preview and passthrough + composer plan expansion to Query-only reads. `LazyPlanPreview` no longer + requires `StateProvider` or mirrors fetched plans into `taskPlans`, and + passthrough message composition reads `taskPlanQueryOptions` cache before + fetching instead of consulting `state.taskPlans` for plan context content. + The main plan panel hook and old `task-plans` WS handler remain because they + still own plan editing/seen-state behavior. +- Session todos/prompt usage cleanup moved `useSessionTodoItems` to the + `qk.sessionRuntime.todos` Query cache only and removed the old + `session.todos_updated` and `session.prompt_usage` Zustand WS handlers, + state fields, mutators, default/root-store declarations, and boot seed paths. + Todo and prompt usage WS updates now flow through the Query bridge only. +- Agent capabilities/poll mode cleanup moved the task-list debug poll badge to + the `qk.sessionRuntime.pollMode` Query cache only and removed the unused + auth-methods indicator plus its orphaned frontend authenticate helper. The old + `session.agent_capabilities` and `session.poll_mode_changed` Zustand WS + handlers, state fields, mutators, default/root-store declarations, and boot + seed paths were removed; these runtime WS updates now flow through the Query + bridge only. +- Improve Kandev/workflow cleanup moved the Improve Kandev bootstrap follow-up + fetches for workflow steps and workspace repositories through shared Query + option factories, while keeping the temporary repository store sync for + existing task-create readers. The old `workflow.*` and `workflow.step.*` + Zustand WS handler registration and legacy workflow handler/test were + removed; workflow cache invalidation now flows through the Query bridge only. + `kanban.update` and `task.*` legacy handlers remain because mounted + kanban/task readers and task-delete cleanup side effects still depend on + them. +- Session runtime dead-plumbing cleanup removed production-unused + `pendingModel`, `sessionRuntime.agents`, and the legacy `terminal.output` + buffer/action/WS branch from Zustand and store hydration/re-export plumbing. + The active `session.shell.output`, `session.process.output`, and + `session.process.status` terminal flows remain. Backend protocol types and + bridge audit skip metadata for `terminal.output` remain because the protocol + still defines that stream event, but it no longer writes frontend store state. +- Session mode cleanup moved the session mode selector to Query-only live mode + data, retaining its existing session snapshot/profile fallback before any live + mode event arrives. The old `sessionMode` Zustand state/actions, boot + seed/store plumbing, and legacy `session.mode_changed` WS handler were + removed. Session mode changes now update `qk.sessionRuntime.mode` through the + Query bridge only; the `setSessionMode` API remains the user action for + changing mode. +- Repository scripts cleanup moved `useRepositoryScripts` to Query-only data + and removed the old `repositoryScripts` Zustand state/actions, + AppState/default-state plumbing, and store re-exports. SSR task data still + carries repository scripts as a query-seed-only boot shape, and settings + repository script saves now update `qk.workspaces.repositoryScripts(repoId)` + directly instead of clearing a store mirror. +- Task-plan revision cleanup moved revision list/content reads to Query-only + data, removed `revisionsByTaskId`, `revisionsLoadingByTaskId`, + `revisionsLoadedByTaskId`, `revisionContentCache`, and their mutator actions + from the session slice, and deleted the legacy + `task.plan.revision.created` WS handler. The Query bridge now patches the + revision list and invalidates individual revision detail caches. The main + task-plan mirror remains for plan editing, last-seen/indicator, and layout + auto-open behavior. +- Available commands cleanup moved inline slash commands, TipTap slash + suggestions, chat-panel agent command data, and empty-turn command + recognition to Query-only reads. The old `availableCommands` Zustand + state/actions and legacy `session.available_commands` WS handler were + removed, and E2E command seeding now writes directly to + `qk.sessionRuntime.availableCommands(sessionId)`. Empty-turn local notices + are preserved across API refetch snapshots so Query invalidation cannot drop + the synthetic hint message. The mobile E2E page object now scopes chat editor + and send-button locators to the visible `session-chat` panel instead of + relying on page-wide TipTap DOM order. +- Repository branches cleanup moved `useBranches` to Query-only reads and + removed the old `repositoryBranches` Zustand state/action, root-store/default + state declarations, hydration, overrides, and re-export plumbing. Row-level + branch lists now cold-load through the workspace branch query so + provider-backed URL repositories still list branches when re-picked from the + workspace dropdown. Manual refresh forces the repository `?refresh=true` + endpoint and copies the result into the active workspace branch cache. Stale + task-create comments and `apps/web/AGENTS.md` were updated so the docs no + longer describe a removed branch store fallback. +- Workspace repositories cleanup moved workspace repository list reads to + TanStack Query cache only and removed the old `repositories` Zustand + state/action, root-store/default-state declarations, hydration, overrides, + and re-export plumbing. Boot/route repository lists are now query-seed-only + data. Shared repository lookup hooks read all workspace repository query + caches, and repository-heavy UI surfaces now read Query cache instead of the + workspace slice. +- Workflow list cleanup moved workspace workflow list reads to TanStack Query + cache only and removed `workflows.items`, `setWorkflows`, and + `reorderWorkflowItems` from the kanban slice/root store. Boot/route workflow + lists now seed `qk.workflows.all(workspaceId, { includeHidden: true })` + through `workflowLists.itemsByWorkspaceId`, while `workflows.activeId` + remains in Zustand as UI/navigation state. Workflow selection, move targets, + swimlane sorting, settings workflow editing, task-create workflow resolution, + task mentions, watch dialogs, recent/mobile task switchers, and route + hydration now read workflow lists from Query cache. +- All-workflow snapshot/loading cleanup moved all-workflow board/sidebar + snapshot reads and loading state to workflow snapshot Query caches. The + Kanban board now passes Query-owned snapshots to swimlanes, multi-select, and + the bulk toolbar; `useWorkspaceSidebarTasks` reads Query snapshots without the + old active single-workflow store fallback. Removed `kanbanMulti.isLoading` + plus the unused `setKanbanMultiLoading`, `updateMultiTask`, and + `removeMultiTask` store surface. `kanbanMulti.snapshots`, + `setWorkflowSnapshot`, and `clearKanbanMulti` remain temporary write-through + compatibility for legacy direct readers and WS handlers outside this + sub-slice. +- Active workflow snapshot cleanup moved the active board read path to workflow + snapshot Query caches. `useWorkflowSnapshot` no longer falls back to + `state.kanban`; `useTasks`, `useKanbanData`, `KanbanBoard`, and + `KanbanWithPreview` now derive active tasks, steps, loading, multi-select, + and preview lookup from `qk.workflows.snapshot(...)` / + `useAllWorkflowSnapshots`. Boot and route hydration now seed workflow + snapshot query keys from existing `kanban` / `kanbanMulti` payload shapes, + and `session.state_changed` patches Query-owned workflow snapshot/task-detail + `primary_session_state`. +- Active board writer cleanup moved dialog create/edit, delete/archive, and + swimlane drag/drop optimistic writes to workflow snapshot Query caches. It + also deleted the unused legacy `useDragAndDrop` hook after moving + `MoveTaskError` to `lib/kanban`, and removed the orphaned + `swimlane-graph-content` file. +- Sidebar filter options cleanup moved workflow, workflow-step, and + executor-type filter option lists to workflow snapshot Query caches via + `useAllWorkflowSnapshots(activeWorkspaceId)`. The hook no longer reads + `kanbanMulti.snapshots`; Zustand remains only for active workspace UI + selection in this path. +- Task mention metadata cleanup moved `@task` mention menu construction and + referenced-task prompt context expansion to workflow snapshot Query caches. + `buildTaskMentionItems`, `buildTaskMentionsContext`, the TipTap chat input, + normal chat send, and passthrough composer no longer read `state.kanban` / + `kanbanMulti.snapshots` for task title/workflow/step metadata. The normal + send path still writes created chat messages to the local session store for + missed-frame resilience. +- Recent task switcher cleanup moved live display metadata resolution to + workflow snapshot Query caches. The hook no longer reads active + `state.kanban` tasks/steps/workflow id or `kanbanMulti.snapshots`; display + titles, workflow names, step titles, task states, repositories, and session + status now derive from Query snapshots plus the remaining session/client + stores. +- Mobile session switcher cleanup moved the mobile task switcher sheet's step + data, task selection/archive/delete metadata lookups, task-created cache + upsert, and workspace-switch snapshot fetch to workflow snapshot Query + caches. The sheet hook no longer reads or writes active `state.kanban` or + `kanbanMulti.snapshots`; it keeps only client UI/session stores for active + task/session selection and pending message/session badges. +- Mobile repo/session metadata cleanup moved mobile repo count/rows, repo pill + active repo name, session primary marker, repo display name, and + base-branch-by-repo readers to task-detail and repository Query caches. These + mobile metadata paths no longer read active `state.kanban.tasks` or + `kanbanMulti.snapshots`; legacy stores can be empty while cached task detail + drives repository/session labels. +- Desktop sidebar/removal cleanup moved active sidebar task-create defaults, + subtask parent labels, task-section workflow selection, sidebar archive/delete + metadata, sidebar task selection metadata, sidebar move-to-step optimistic + writes, and `useTaskRemoval` next-task/removal logic to Query-owned workflow + snapshot and task-detail data. These paths no longer read or write active + `state.kanban` / `kanbanMulti.snapshots`; the sidebar still keeps client UI + state in Zustand. +- Task-page/session chrome cleanup moved task-detail route metadata, workflow + steps, session panel step resolution, header/sidebar new-task context, + session primary markers, base-branch repository lookup, subtask defaults, + plan-mode layout detection, and changes-panel multi-repo labels to Query + caches. These task-page/session chrome paths no longer synthesize or override + task metadata from active `state.kanban.tasks` / `kanbanMulti.snapshots`. +- Board/command/dialog utility cleanup moved card context-menu workflow move + targets, command-panel step filtering/badges, and session command subtask + parent titles to Query caches. `useKanbanCardMoveTargets` reads workflow + snapshot query cache, command-panel step derivation uses stable Query-owned + snapshot data, and `SessionCommands` reads `useTaskById` instead of active + `kanban.tasks`. +- Task-create/card/GitHub indicator/board-grid cleanup moved session-mode + task-create repository naming, task-create workflow snapshot defaults, kanban + card subtask parent badges, GitHub PR indicator step tooltips, and board-grid + loading decisions to Query-owned task detail, repository, workflow list, and + workflow snapshot caches. These UI readers no longer depend on active + `state.kanban` or `kanbanMulti.snapshots` mirrors being populated. +- Legacy kanban WS mirror cleanup removed the old `kanban.update` Zustand + handler registration and handler/test file. The remaining `task.*` WS handler + keeps only client-side effects: active primary-session adoption, deleted-task + local storage cleanup, sidebar preference cleanup, and context file cleanup. + `session.state_changed` and `workspace.deleted` no longer patch or clear + active `state.kanban` / `kanbanMulti.snapshots`. +- Final kanban store compatibility cleanup removed the top-level `kanban` / + `kanbanMulti` store fields, `setWorkflowSnapshot`, legacy route/boot seed + compatibility shapes, active snapshot fallback paths, stale test fixtures, + and dead active-board fallback helpers. Route readiness now checks Query + snapshot hydration directly, SSR/boot seeds use + `workflowSnapshots.itemsByWorkflowId`, and stale scans find no production + references to the removed store API. +- Feature flags and session worktrees cleanup moved feature flag reads and + session worktree indexes to Query. `useFeature` now reads `qk.features()`, + `useSessionWorktrees` reads `qk.sessionRuntime.worktrees(sessionId)`, boot + payload/query seed handles feature and worktree data, and + `session.agentctl_ready` patches the session worktree query cache through the + bridge. The old feature slice, `worktrees` / + `sessionWorktreesBySessionId` store fields/actions, unused `use-worktree` + hook, and legacy agent-session worktree store writer were removed. +- Retained Zustand state is documented in `apps/web/AGENTS.md`: client + navigation/UI state, live session indexes for stream ordering and + missed-frame recovery, runtime indexes for high-frequency terminal/process/ + git/context/model streams, persisted `userSettings`, and local-only GitHub/ + office UI indexes. Query owns server snapshots for workspace repositories, + repository branches/scripts, workflow lists, workflow snapshots, task details, + session worktrees, feature flags, settings catalogs, integrations, office + data, and system data. +- No GitLab-specific Docker E2E specs exist in this checkout. The GitLab + sub-wave used focused unit coverage plus the shared integration/settings, + sidebar, and strict-WS Docker gate; add GitLab browser specs before treating + GitLab as fully domain-covered by E2E. + +Task 10 partial verification completed locally: + +- `cd apps/web && rtk pnpm typecheck` passed after system cleanup. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/system components/system-metrics lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts` + passed 47 tests. +- `cd apps/web && rtk pnpm typecheck` passed after GitHub cleanup. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/github components/github lib/state/slices/github lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts` + passed 29 files / 298 tests. +- `cd apps/web && rtk pnpm e2e:docker tests/github/pr-list-task-indicator.spec.ts tests/github/github-scope-bar.spec.ts tests/integrations/github-watch-reset.spec.ts tests/pr/ci-automation-options.spec.ts tests/git/git-changes-panel.spec.ts` + passed 22 desktop Docker tests. +- `cd apps/web && rtk pnpm typecheck` passed after GitLab cleanup. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/gitlab components/gitlab app/gitlab lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts` + passed 8 files / 81 tests. +- `cd apps/web && rtk pnpm e2e:docker tests/system/sidebar-navigation.spec.ts tests/integrations/jira-settings.spec.ts tests/integrations/linear-settings.spec.ts tests/integrations/sentry-settings.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 21 desktop Docker tests. +- `cd apps/web && rtk pnpm typecheck` passed after the settings small-mirror + cleanup. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/settings/use-settings-query-hooks.test.tsx hooks/domains/settings components/settings lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts` + passed 16 files / 95 tests. +- `cd apps/web && rtk pnpm e2e:docker tests/settings/prompts-settings.spec.ts tests/settings/agent-profile-acp.spec.ts tests/settings/agent-profile-cli-flags.spec.ts tests/settings/config-management.spec.ts tests/settings/utility-agents.spec.ts tests/settings/agent-install-streaming.spec.ts tests/settings/docker-profile-persistence.spec.ts` + passed 46 desktop Docker tests. +- `cd apps/web && rtk pnpm typecheck` passed after settings catalog/bootstrap + cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/settings/use-executors-query-sync.test.ts hooks/domains/settings/use-agents-query-sync.test.ts hooks/domains/settings/use-settings-query-hooks.test.tsx lib/query/seed.test.ts src/settings-routes.test.ts components/agent/cli-profile-editor.test.tsx lib/query/bridge/index.test.ts` + passed 7 files / 39 tests. +- `cd apps/web && rtk pnpm test components/task/handoff-profile-menu-items.test.ts app/office/agents/[id]/components/agent-configuration-tab.test.tsx components/app-sidebar/sections/settings/settings-tree-render.test.tsx components/task/model-selector.test.ts components/task/executor-settings-button.test.tsx components/quick-chat/use-quick-chat-modal.test.ts components/quick-chat/quick-chat-modal.test.ts components/agent/cli-profile-editor.test.tsx hooks/domains/settings/use-settings-query-hooks.test.tsx hooks/domains/settings/use-executors-query-sync.test.ts hooks/domains/settings/use-agents-query-sync.test.ts` + passed 11 files / 50 tests. +- `rtk rg -n "registerAgentsHandlers|registerExecutorsHandlers|registerExecutorProfileHandlers|handlers/(agents|executors|executor-profiles)|lib/ws/handlers/(agents|executors|executor-profiles)" apps/web --glob '!dist/**'` + returned no matches for the deleted settings catalog WS handlers. +- `cd apps/web && rtk pnpm e2e:docker tests/settings/agent-profile-acp.spec.ts tests/settings/agent-profile-delete.spec.ts tests/settings/config-management.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 36 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=containers tests/ssh/executor-crud.spec.ts` + passed 5 container-backed SSH executor tests / 1 skipped. +- `cd apps/web && rtk pnpm typecheck` passed after the final lint-driven + refactors. +- `cd apps/web && rtk pnpm lint` passed after the final lint-driven refactors. +- `cd apps/web && rtk pnpm test hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-visibility-backfill.test.ts hooks/domains/session/use-session-commits.test.ts hooks/domains/settings/use-settings-query-hooks.test.tsx hooks/domains/settings/use-executors-query-sync.test.ts lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts lib/query/seed.test.ts components/state-hydrator.test.tsx app/office/page-client.test.tsx app/office/tasks/use-paginated-tasks.test.tsx` + passed 12 files / 94 tests. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/message-add-ws-gap.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 7 desktop Docker tests. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repository-scripts.test.tsx lib/query/seed.test.ts lib/state/slices/workspace/workspace-slice.test.ts` + first failed on the old Zustand dependency/missing query seed/remaining slice + field, then passed 3 files / 8 tests after the repository scripts cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the repository scripts + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/domains/workspace/use-repository-scripts.ts hooks/domains/workspace/use-repository-scripts.test.tsx lib/query/seed.ts lib/query/seed.test.ts lib/state/slices/workspace/types.ts lib/state/slices/workspace/workspace-slice.ts lib/state/slices/workspace/workspace-slice.test.ts lib/state/default-state.ts lib/state/store-overrides.ts lib/state/store.ts lib/state/slices/index.ts lib/state/store-reexports.ts app/settings/workspace/workspace-repositories-client.tsx lib/ssr/session-page-state.ts` + passed. +- `rtk rg -n -P "state\\.repositoryScripts|m\\.repositoryScripts|defaultWorkspaceState\\.repositoryScripts|\\bsetRepositoryScripts\\b|\\bclearRepositoryScripts\\b|\\bRepositoryScriptsState\\b|repositoryScripts: \\(typeof defaultWorkspaceState\\)|itemsByRepositoryId\\[repositoryId\\].*repositoryScripts" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the repository scripts absence assertions in + `workspace-slice.test.ts`. +- `rtk git diff --check` passed after the repository scripts cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/session/dockview-repository-scripts.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 9 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/session/mobile-handoff.spec.ts` + passed 1 mobile Docker test. No repository-script-specific mobile E2E exists + in this checkout; this validates the mobile task/session shell after the + Query-only hook change. +- `cd apps/web && rtk pnpm test lib/state/slices/session/task-plans.test.ts lib/ws/handlers/task-plans.test.ts lib/query/bridge/index.test.ts` + first failed on the retained revision store fields/handler and missing Query + detail invalidation, then passed 3 files / 27 tests after the task-plan + revision cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the task-plan revision + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/domains/session/use-task-plan.ts lib/state/slices/session/types.ts lib/state/slices/session/session-slice.ts lib/state/slices/session/task-plans.test.ts lib/ws/handlers/task-plans.ts lib/ws/handlers/task-plans.test.ts lib/query/bridge/session.ts lib/query/bridge/index.test.ts lib/state/store.ts` + passed. +- `rtk rg -n "revisionsByTaskId|revisionsLoadingByTaskId|revisionsLoadedByTaskId|revisionContentCache|setPlanRevisions|upsertPlanRevision|setPlanRevisionsLoading|cachePlanRevisionContent" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the revision absence assertions in + `lib/state/slices/session/task-plans.test.ts`. +- `rtk git diff --check` passed after the task-plan revision cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/plan-checkpointing.spec.ts tests/layout/plan-panel-indicator.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 15 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/terminal/mobile-passthrough-rendering.spec.ts tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 2 mobile Docker tests. +- `cd apps/web && rtk pnpm typecheck` passed after the workspace/kanban + workflow-step and metadata cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-workflow-steps.ts hooks/use-workflow-steps.test.ts components/automations/config-section.tsx components/automations/config-section.test.tsx components/task-create-dialog-effects.ts components/task-create-dialog-effects.test.ts lib/query/query-options/kanban.ts lib/query/query-options/query-options.test.ts lib/query/bridge/workspace.ts lib/query/bridge/index.test.ts lib/query/bridge/workspace.test.ts lib/query/keys.ts lib/query/keys.test.ts hooks/domains/workspace/use-repositories.ts hooks/domains/workspace/use-repository-branches.ts hooks/domains/workspace/use-repository-scripts.ts hooks/domains/kanban/use-kanban-actions.ts lib/state/slices/workspace/types.ts lib/state/slices/workspace/workspace-slice.ts lib/state/slices/workspace/workspace-slice.test.ts app/page.tsx app/tasks/page.tsx app/github/page.tsx lib/ssr/session-page-state.ts lib/state/store.ts` + passed. +- `cd apps/web && rtk pnpm test hooks/use-workflow-steps.test.ts components/task-create-dialog-effects.test.ts components/automations/config-section.test.tsx lib/query/query-options/query-options.test.ts lib/query/keys.test.ts lib/query/bridge/index.test.ts lib/query/bridge/workspace.test.ts lib/state/slices/workspace/workspace-slice.test.ts hooks/use-workflow-snapshot.test.ts hooks/use-tasks.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts lib/ws/handlers/kanban.test.ts lib/ws/handlers/tasks.test.ts lib/routing/kanban-route-hydration.test.ts` + passed 16 files / 116 tests. +- `cd apps/web && rtk rg -n "loadedByWorkspaceId|loadingByWorkspaceId|setRepositoriesLoading|loadingByRepositoryId|loadedByRepositoryId|fetchedAtByRepositoryId|fetchErrorByRepositoryId|setRepositoryBranchesLoading|setRepositoryBranchesFetchError|setRepositoryScriptsLoading|invalidateRepositories" apps/web --glob '!dist/**'` + returned no matches for the deleted workspace metadata. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/workflow-filter.spec.ts tests/kanban/kanban-board.spec.ts tests/task/task-list.spec.ts tests/settings/config-management.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 35 desktop Docker tests. +- `cd apps/web && rtk rg -n "fetchWorkflowSnapshot|listWorkflows" apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts` + returned no matches after the mobile switcher Query cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the mobile switcher Query + cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/mobile/session-task-switcher-sheet-hooks.ts` + passed. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/task/mobile-sidebar-subtasks.spec.ts tests/kanban/mobile-kanban.spec.ts` + passed 12 mobile Docker tests. +- `cd apps/web && rtk rg -n "fetchTask" apps/web/components/task/task-page-content.tsx` + returned no matches after the task-detail route Query cleanup. +- `cd apps/web && rtk pnpm test components/task/task-page-content.test.tsx` + passed 1 file / 2 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the task-detail route Query + cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/task-page-content.tsx components/task/task-page-content.test.tsx` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/task/sessionless-task-switch.spec.ts tests/task/task-list.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 9 desktop Docker tests. +- `cd apps/web && rtk rg -n "listTasksByWorkspace\\(" apps/web --glob '!dist/**' --glob '!e2e/**' --glob '!lib/api/**' --glob '!app/actions/**'` + returned matches only in Query option factories, confirming command-panel no + longer imports the task-list API directly. +- `cd apps/web && rtk pnpm test components/command-panel.test.ts` passed 1 file + / 2 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the command-panel Query + cleanup. +- `cd apps/web && rtk pnpm exec eslint components/command-panel.tsx components/command-panel.test.ts` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/command-panel.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 10 desktop Docker tests. +- RED board/command/dialog utility gate: + `rtk pnpm --dir apps/web test components/kanban-card-menu-items.test.tsx components/command-panel.test.ts components/session-commands.test.tsx` + failed because card move targets still required `StateProvider` / + `kanbanMulti.snapshots`, command-panel step metadata ignored Query-owned + workflow snapshots, and session subtask creation passed a blank parent title + when only `qk.tasks.detail(...)` was populated. +- `rtk make fmt` passed after the board/command/dialog utility cleanup. +- `rtk pnpm --dir apps/web test components/kanban-card-menu-items.test.tsx components/command-panel.test.ts components/session-commands.test.tsx` + passed 3 files / 8 tests after the utility cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the utility cleanup. +- `rtk pnpm --dir apps/web exec eslint components/kanban-card-menu-items.tsx components/kanban-card-menu-items.test.tsx components/command-panel.tsx components/command-panel.test.ts components/session-commands.tsx components/session-commands.test.tsx components/task/session-tab.tsx components/task/session-tab.test.tsx` + passed with duplicate-string warnings only in existing test fixtures. +- `rtk rg -n "state\\.kanban|s\\.kanban|getState\\(\\)\\.kanban|kanbanMulti|setWorkflowSnapshot|findTaskInSnapshots|\\.kanban\\.(tasks|steps|workflowId|loading|error)" apps/web/components/kanban-card-menu-items.tsx apps/web/components/command-panel.tsx apps/web/components/session-commands.tsx apps/web/components/task/session-tab.tsx` + returned no matches after the utility cleanup. +- `rtk git diff --check` passed after the utility cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/command-panel.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/task/sidebar-send-to-workflow.spec.ts tests/task/subtask.spec.ts tests/system/ws-event-accounting.spec.ts` + first exposed a command-panel React update loop when snapshot-derived steps + were unstable, then passed 28 desktop Docker tests after stabilizing the + Query-derived step array. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/session/mobile-handoff.spec.ts` + passed 13 mobile Docker tests after the utility cleanup. +- RED task-create/card/GitHub indicator/board-grid gate: + `rtk pnpm --dir apps/web test components/task-create-dialog-state.test.ts components/kanban-card-content.test.tsx components/github/my-github/pr-row-task-indicator.test.tsx components/kanban-board-grid.test.tsx` + failed because session-mode task-create repo naming, task-create workflow + snapshot defaults, kanban card subtask parent badges, GitHub PR indicator + step tooltips, and board-grid loading still depended on legacy `kanban` / + `kanbanMulti` mirrors when Query caches were populated. +- `rtk make fmt` passed after the task-create/card/GitHub indicator/board-grid + cleanup. +- `rtk pnpm --dir apps/web test components/task-create-dialog-state.test.ts components/kanban-card-content.test.tsx components/github/my-github/pr-row-task-indicator.test.tsx components/kanban-board-grid.test.tsx` + passed 4 files / 27 tests after the cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the cleanup. +- Targeted eslint for the task-create, kanban-card, GitHub indicator, and + board-grid files/tests passed after the cleanup. +- The migrated-file stale scan for active `state.kanban`, `kanbanMulti`, + `setWorkflowSnapshot`, and `findTaskInSnapshots` returned no matches. +- `rtk git diff --check` passed after the cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/task/create-task.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/github/pr-list-task-indicator.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 22 desktop Docker tests after the cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/task/mobile-create-task-remote-repo.spec.ts tests/kanban/mobile-kanban.spec.ts` + passed 12 mobile Docker tests after the cleanup. +- `cd apps/web && rtk rg -n "getSubtaskCount" apps/web --glob '!dist/**' --glob '!e2e/**' --glob '!lib/api/**'` + returned only Query option/test references after the subtask-count hook stopped + importing the API directly. +- `cd apps/web && rtk pnpm test hooks/use-subtask-count.test.ts components/task/task-archive-confirm-dialog.test.tsx components/task/task-delete-confirm-dialog.test.tsx` + passed 3 files / 19 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the subtask-count Query + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-subtask-count.ts hooks/use-subtask-count.test.ts components/task/task-archive-confirm-dialog.test.tsx components/task/task-delete-confirm-dialog.test.tsx` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/task/subtask.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 15 desktop Docker tests. +- `rtk rg -n "clearTaskPlan|clearQueueStatus|task\\.plan\\.reverted" apps/web --glob '!dist/**'` + returned no `clearTaskPlan`/`clearQueueStatus` references and only backend + type plus Query bridge references for `task.plan.reverted`. +- `cd apps/web && rtk pnpm test lib/state/slices/session/task-plans.test.ts lib/state/slices/session/session-slice.upsert.test.ts lib/ws/handlers/task-plans.test.ts lib/query/bridge/index.test.ts` + passed 4 files / 35 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the session dead-code + cleanup. +- `cd apps/web && rtk pnpm exec eslint lib/state/slices/session/types.ts lib/state/slices/session/session-slice.ts lib/state/slices/session/task-plans.test.ts lib/state/slices/session/session-slice.upsert.test.ts lib/state/store.ts lib/ws/handlers/task-plans.ts` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/task/plan-checkpointing.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 16 desktop Docker tests. +- `cd apps/web && rtk pnpm test hooks/domains/session/use-queue.test.ts` + first failed because `useQueue` still required `StateProvider`, then passed + after the queue Query migration. +- `cd apps/web && rtk pnpm test hooks/domains/session/use-queue.test.ts components/task/chat/queued-ghost-list.test.tsx lib/query/bridge/index.test.ts lib/ws/handlers/agent-session.test.ts lib/state/slices/session/session-slice.upsert.test.ts` + passed 5 files / 59 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the queue mirror cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/domains/session/use-queue.ts hooks/domains/session/use-queue.test.ts lib/ws/handlers/agent-session.ts lib/state/slices/session/types.ts lib/state/slices/session/session-slice.ts lib/state/slices/session/session-slice.upsert.test.ts lib/state/store.ts lib/state/default-state.ts lib/state/store-overrides.ts lib/state/slices/index.ts` + passed. +- `rtk rg -n "QueueMeta|QueueState|defaultSessionState\\.queue|initialState\\.queue|m\\.queue|state\\.queue|setQueueEntries|removeQueueEntry:|setQueueLoading:" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the local `setQueueLoading` helper type in `use-queue.ts`; no + queue Zustand mirror fields/actions remain. +- `rtk git diff --check` passed after the queue mirror cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/message-queue.spec.ts tests/workflow/workflow-manual-move-queue.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 10 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/workflow/mobile-workflow-manual-move-queue.spec.ts` + passed 1 mobile Docker test. +- `cd apps/web && rtk pnpm test components/task/chat/context-items/lazy-plan-preview.test.tsx components/task/passthrough-chat-composer.test.ts` + first failed because `LazyPlanPreview` still required `StateProvider` and the + composer still read the store for cached plan content, then passed after the + Query-only plan-context cleanup. +- `cd apps/web && rtk pnpm test components/task/chat/context-items/lazy-plan-preview.test.tsx components/task/passthrough-chat-composer.test.ts lib/query/bridge/index.test.ts` + passed 3 files / 23 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the plan-context cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/chat/context-items/lazy-plan-preview.tsx components/task/chat/context-items/lazy-plan-preview.test.tsx components/task/passthrough-chat-composer.tsx components/task/passthrough-chat-composer.test.ts` + passed. +- `rtk rg -n "cachedTaskPlanContent|state\\.taskPlans|setTaskPlan\\(|useAppStore|useAppStoreApi" apps/web/components/task/chat/context-items/lazy-plan-preview.tsx apps/web/components/task/passthrough-chat-composer.tsx` + returned only the existing passthrough composer `useAppStoreApi` usage for + submit/task-mention state; no plan-context store fallback remains. +- `rtk git diff --check` passed after the plan-context cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/cli-mode/passthrough-toolbar.spec.ts tests/layout/plan-panel-indicator.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 13 desktop Docker tests. +- `cd apps/web && rtk pnpm test lib/ws/router.test.ts lib/query/bridge/session-runtime.test.ts lib/query/bridge/index.test.ts lib/state/slices/session-runtime/purge-session.test.ts components/task/task-item.test.tsx` + passed 5 files / 31 tests after the session runtime mirror cleanups. +- `cd apps/web && rtk pnpm typecheck` passed after the session runtime mirror + cleanups. +- `cd apps/web && rtk pnpm exec eslint components/task/task-item.tsx lib/api/domains/session-api.ts lib/ws/router.ts lib/ws/router.test.ts lib/query/bridge/session-runtime.test.ts lib/query/bridge/index.test.ts lib/state/default-state.ts lib/state/store.ts lib/state/store-overrides.ts lib/state/slices/session-runtime/types.ts lib/state/slices/session-runtime/session-runtime-slice.ts lib/state/slices/session-runtime/purge-session.test.ts lib/query/seed.ts components/task/chat/use-chat-panel-state.ts` + passed. +- `rtk rg -n "sessionTodos|setSessionTodos|PromptUsageState|SessionTodosState|promptUsage|setPromptUsage|agentCapabilities|setAgentCapabilities|AgentCapabilitiesState|sessionPollMode|setSessionPollMode|SessionPollModeState|registerSessionTodosHandlers|registerPromptUsageHandlers|registerAgentCapabilitiesHandlers|registerSessionPollModeHandlers|handlers/session-todos|handlers/prompt-usage|handlers/agent-capabilities|handlers/session-poll-mode|AuthMethodsIndicator|auth-methods-indicator|authenticateSession" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only Query key/bridge/query-option references, absence assertions, + and unrelated settings hook naming; no old Zustand runtime mirror fields, + actions, handlers, or unused auth indicator/helper remain. +- `rtk git diff --check` passed after the session runtime mirror cleanups. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/chat-status-bar.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 8 desktop Docker tests for session todos/prompt usage cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 1 mobile Docker test for the chat hook surface. +- `cd apps/web && rtk pnpm e2e:docker tests/task/task-list.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 7 desktop Docker tests for agent capabilities/poll mode cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/task/mobile-task-list-search.spec.ts` + passed 1 mobile Docker test for the task item surface. +- `cd apps/web && rtk pnpm test lib/ws/router.test.ts lib/query/bridge/workspace.test.ts lib/query/bridge/index.test.ts hooks/use-workflow-snapshot.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts components/improve-kandev-dialog.test.tsx` + first failed on the still-registered workflow router handler / missing Query + cache hydration, then passed 7 files / 35 tests after the Improve Kandev and + workflow-handler cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the Improve Kandev and + workflow-handler cleanup. +- `cd apps/web && rtk pnpm exec eslint components/improve-kandev-dialog.tsx components/improve-kandev-dialog.test.tsx lib/ws/router.ts lib/ws/router.test.ts lib/query/bridge/workspace.ts lib/query/bridge/workspace.test.ts hooks/use-workflow-snapshot.ts hooks/use-workflow-snapshot.test.ts hooks/domains/kanban/use-all-workflow-snapshots.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts` + passed. +- `rtk rg -n "registerWorkflowsHandlers|handlers/workflows|lib/ws/handlers/workflows|workflow\\.created handler|workflow\\.updated handler" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned no matches after deleting the legacy workflow WS handler. +- `cd apps/web && rtk pnpm e2e:docker tests/workflow/workflow-steps.spec.ts tests/workflow/workflow-settings.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 13 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed 12 mobile Docker tests. +- `cd apps/web && rtk pnpm test lib/ws/router.test.ts lib/state/slices/session/session-slice.upsert.test.ts lib/state/slices/session-runtime/purge-session.test.ts lib/state/slices/session-runtime/output-caps.test.ts` + first failed on the retained legacy state/handler assertions, then passed 4 + files / 15 tests after removing the stale runtime plumbing. +- `cd apps/web && rtk pnpm typecheck` passed after the session-runtime + dead-plumbing cleanup. +- `cd apps/web && rtk pnpm exec eslint lib/ws/router.ts lib/ws/router.test.ts lib/ws/handlers/terminals.ts lib/state/default-state.ts lib/state/store.ts lib/state/store-overrides.ts lib/state/store-reexports.ts lib/state/hydration/hydrator.ts lib/state/slices/index.ts lib/state/slices/session/types.ts lib/state/slices/session/session-slice.ts lib/state/slices/session/session-slice.upsert.test.ts lib/state/slices/session-runtime/types.ts lib/state/slices/session-runtime/session-runtime-slice.ts lib/state/slices/session-runtime/purge-session.test.ts lib/state/slices/session-runtime/output-caps.test.ts e2e/tests/terminal/terminal-agent.spec.ts` + passed. +- `rtk rg -n "pendingModel|setPendingModel|clearPendingModel|PendingModel|TerminalState|setTerminalOutput|terminal\\.terminals|terminal.output|AgentState|defaultSessionRuntimeState\\.agents|draft\\.agents|state\\.agents|m\\.agents|initialState\\.agents" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only intentional backend protocol/audit metadata, absence tests, and + unrelated terminal UI names. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/chat-status-bar.spec.ts tests/terminal/terminal-agent.spec.ts tests/search/terminal-search.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 17 desktop Docker tests after fixing the spec to use + `errors.TimeoutError` from Playwright. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 1 mobile Docker test. +- `rtk git diff --check` passed after the session-runtime dead-plumbing + cleanup. +- `cd apps/web && rtk pnpm test lib/ws/router.test.ts lib/state/slices/session-runtime/purge-session.test.ts components/task/mode-selector.test.tsx lib/query/bridge/session-runtime.test.ts lib/query/bridge/index.test.ts` + first failed on the retained legacy `session.mode_changed` handler and + `sessionMode` store field, then passed 5 files / 21 tests after the + session-mode cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the session-mode cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/mode-selector.tsx components/task/mode-selector.test.tsx lib/ws/router.ts lib/ws/router.test.ts lib/state/default-state.ts lib/state/store.ts lib/state/store-overrides.ts lib/state/slices/session-runtime/types.ts lib/state/slices/session-runtime/session-runtime-slice.ts lib/state/slices/session-runtime/purge-session.test.ts lib/query/seed.ts` + passed. +- `rtk rg -n -P "\\bsessionMode\\b|\\bclearSessionMode\\b|registerSessionModeHandlers\\b|handlers/session-mode(?:\\.|$)|lib/ws/handlers/session-mode(?:\\.|$)|state\\.sessionMode(?!l|s)|m\\.sessionMode(?!l|s)|defaultSessionRuntimeState\\.sessionMode(?!l|s)" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the absence assertions in `purge-session.test.ts`. +- `rtk git diff --check` passed after the session-mode cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/quick-chat.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 11 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 1 mobile Docker test. +- `cd apps/web && rtk pnpm test hooks/use-inline-slash.test.tsx lib/ws/router.test.ts lib/state/slices/session-runtime/purge-session.test.ts` + first failed because `useInlineSlash` still required `StateProvider`, the + router still registered `session.available_commands`, and the runtime slice + still exposed `availableCommands`; this was the RED step for the available + commands cleanup. +- `cd apps/web && rtk pnpm test lib/state/slices/session/session-slice.merge-messages.test.ts` + first failed because API refetch snapshots dropped local empty-turn notices; + the test passed after preserving local `metadata.empty_turn` status messages. +- `cd apps/web && rtk pnpm test lib/state/slices/session/session-slice.merge-messages.test.ts hooks/use-inline-slash.test.tsx components/task/chat/use-chat-input-container.test.ts lib/ws/handlers/empty-turn-notice.test.ts lib/ws/router.test.ts lib/state/slices/session-runtime/purge-session.test.ts lib/query/bridge/session-runtime.test.ts lib/query/seed.test.ts` + passed 8 files / 46 tests after the available commands cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the available commands + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-inline-slash.ts hooks/use-inline-slash.test.tsx components/task/chat/tiptap-input.tsx components/task/chat/use-chat-panel-state.ts lib/ws/router.ts lib/ws/router.test.ts lib/ws/handlers/empty-turn-notice.ts lib/ws/handlers/empty-turn-notice.test.ts lib/state/slices/session-runtime/types.ts lib/state/slices/session-runtime/session-runtime-slice.ts lib/state/slices/session-runtime/purge-session.test.ts lib/state/slices/session/message-signature.ts lib/state/slices/session/session-slice.merge-messages.test.ts lib/state/slices/index.ts lib/state/store.ts lib/state/default-state.ts lib/query/seed.ts e2e/helpers/session-store.ts e2e/pages/session-page.ts` + passed. +- `rtk rg -n -P "state\\.availableCommands|s\\.availableCommands|m\\.availableCommands|defaultSessionRuntimeState\\.availableCommands|\\bsetAvailableCommands\\b|\\bclearAvailableCommands\\b|\\bAvailableCommandsState\\b|availableCommands: \\(typeof defaultSessionRuntimeState\\)|registerAvailableCommandsHandlers|handlers/available-commands|lib/ws/handlers/available-commands" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the available-commands absence assertions in + `purge-session.test.ts`. +- `rtk git diff --check` passed after the available commands cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/empty-turn.spec.ts tests/chat/chat-status-bar.spec.ts tests/system/ws-event-accounting.spec.ts` + first failed because Query invalidation refetched session messages without + the synthetic empty-turn notice, then passed 10 desktop Docker tests after the + local empty-turn merge preservation fix. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts` + first failed because the E2E page object selected a page-wide non-editable + TipTap node on mobile, then passed 1 mobile Docker test after scoping the + editor locator to the visible chat panel. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-empty-turn.spec.ts tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 2 mobile Docker tests after the available commands cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repository-branches.test.tsx lib/state/slices/workspace/workspace-slice.test.ts` + first failed because `useBranches` still required `StateProvider` and + `repositoryBranches` still existed in the workspace slice; this was the RED + step for the repository branches cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repository-branches.test.tsx lib/state/slices/workspace/workspace-slice.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts components/task-create-dialog-repo-chips.test.tsx components/task-create-dialog-branch-utils.test.ts components/task-create-dialog-pill.test.tsx` + passed 7 files / 72 tests after adding coverage for Query-only id/path branch + reads, workspace-endpoint cold loads, and forced repository refresh. +- `cd apps/web && rtk pnpm typecheck` passed after the repository branches + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/domains/workspace/use-repository-branches.ts hooks/domains/workspace/use-repository-branches.test.tsx components/task-create-dialog-state.ts components/task-create-dialog-repo-chips.tsx lib/state/slices/workspace/types.ts lib/state/slices/workspace/workspace-slice.ts lib/state/slices/workspace/workspace-slice.test.ts lib/state/default-state.ts lib/state/store-overrides.ts lib/state/hydration/hydrator.ts lib/state/store-reexports.ts lib/state/slices/index.ts lib/state/store.ts` + passed. +- `rtk rg -n -P "state\\.repositoryBranches|m\\.repositoryBranches|defaultWorkspaceState\\.repositoryBranches|\\bsetRepositoryBranches\\b|\\bRepositoryBranchesState\\b|repositoryBranches: \\(typeof defaultWorkspaceState\\)|itemsByRepositoryId\\[.*\\].*repositoryBranches|repositoryBranches\\.byRepository|Zustand cache.*branch|store dedupes by repositoryId" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the repository-branches absence assertion in + `workspace-slice.test.ts`. +- `rtk git diff --check` passed after the repository branches cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/create-task-branch-selector.spec.ts tests/task/create-task-url-reopen-no-branches.spec.ts tests/system/ws-event-accounting.spec.ts` + first failed on the refresh and URL-reopen regressions, then passed 15 + desktop Docker tests after forcing repository refresh and preserving + workspace-endpoint cold loads. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-create-task-remote-repo.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed 2 mobile Docker tests after the repository branches cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repositories.test.tsx lib/query/seed.test.ts lib/state/slices/workspace/workspace-slice.test.ts` + first failed because repository hooks still required `StateProvider`, + initial-state repository lists did not seed the Query cache, and the + workspace slice still exposed `repositories`/`setRepositories`; this was the + RED step for the workspace repositories cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repositories.test.tsx lib/query/seed.test.ts lib/state/slices/workspace/workspace-slice.test.ts` + passed 3 files / 12 tests after the workspace repositories cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repositories.test.tsx lib/query/seed.test.ts lib/state/slices/workspace/workspace-slice.test.ts components/task-create-dialog-state.test.ts components/command-panel.test.ts components/task/task-changes-panel.test.ts components/task/changes-panel.test.ts components/task/changes-panel-pr-files.test.tsx components/task/task-session-sidebar-aggregate.test.ts components/task/recent-task-switcher-model.test.ts components/review/review-dialog.build-files.test.ts` + passed 11 files / 91 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the workspace repositories + cleanup. +- Targeted eslint for repository hooks, query seed, workspace slice/store + plumbing, route seeders, and repository-heavy UI consumers passed. +- `rtk rg -n -P "state\\.repositories\\.itemsByWorkspaceId|\\bs\\.repositories\\.itemsByWorkspaceId|defaultWorkspaceState\\.repositories|\\brepositories: \\(typeof defaultWorkspaceState\\)|\\bRepositoriesState\\b|setRepositories:\\s*\\(workspaceId|setRepositories\\(workspaceId|state\\.setRepositories|\\bs\\.setRepositories|itemsByWorkspaceId\\[.*\\].*state\\.repositories" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned no matches after the workspace repositories cleanup. +- `rtk git diff --check` passed after the workspace repositories cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/create-task-branch-selector.spec.ts tests/session/dockview-repository-scripts.spec.ts tests/git/git-changes-panel.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 40 desktop Docker tests after the workspace repositories cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-create-task-remote-repo.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/session/mobile-handoff.spec.ts` + passed 3 mobile Docker tests after the workspace repositories cleanup. +- `cd apps/web && rtk pnpm test hooks/use-workflows.test.tsx lib/query/seed.test.ts lib/state/slices/kanban/kanban-slice.test.ts` + first failed because `useWorkflows` still required `StateProvider`, workflow + boot lists did not seed the Query cache, and the kanban slice still exposed + workflow list state/actions; this was the RED step for the workflow list + cleanup. +- `rtk make fmt` passed after the workflow list cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the workflow list cleanup. +- Targeted eslint for workflow hooks/cache helpers, query seed, kanban + slice/store plumbing, route seeders, kanban/task-create/settings/watch-dialog + consumers, route hydration, and mention helpers passed. +- `cd apps/web && rtk pnpm test hooks/use-workflows.test.tsx lib/query/seed.test.ts lib/state/slices/kanban/kanban-slice.test.ts lib/routing/kanban-route-hydration.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts hooks/domains/settings/use-workflow-settings.test.ts components/task-create-dialog-state.test.ts components/task/chat/task-mention-items.test.ts components/task/recent-task-switcher-model.test.ts` + passed 10 files / 60 tests after the workflow list cleanup. +- `rtk rg -n -P "workflows\\.items|setWorkflows\\b|reorderWorkflowItems\\b|WorkflowsState\\[\"items\"\\]|workflows:\\s*\\{\\s*items|items:\\s*workflows\\.map" apps/web --glob '!dist/**' --glob '!e2e/test-results/**' --glob '!**/kanban-slice.test.ts'` + returned no matches after the workflow list cleanup. +- `rtk git diff --check` passed after the workflow list cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/workflow-filter.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/workflow/workflow-sorting.spec.ts tests/task/create-task.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 24 desktop Docker tests after the workflow list cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/workflow/mobile-workflow-manual-move-queue.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-create-task-remote-repo.spec.ts` + passed 14 mobile Docker tests after the workflow list cleanup. +- RED all-workflow snapshot/loading gate: + `cd apps/web && rtk pnpm test hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts lib/state/slices/kanban/kanban-slice.test.ts` + failed on the retained store loading/actions and store-backed sidebar data. +- `cd apps/web && rtk pnpm test hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts components/kanban/task-multi-select-toolbar.test.tsx lib/state/slices/kanban/kanban-slice.test.ts lib/ws/handlers/agent-session-kanban.test.ts lib/ws/handlers/kanban.test.ts lib/ws/handlers/tasks.test.ts lib/routing/kanban-route-hydration.test.ts components/task/chat/task-mention-items.test.ts hooks/use-message-handler.test.ts` + passed 10 files / 45 tests after the all-workflow snapshot/loading cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the all-workflow + snapshot/loading cleanup. +- Targeted eslint for the all-workflow snapshot hook, workspace sidebar hook, + multi-select hook/tests, Kanban board/swimlane, kanban slice/store plumbing, + hydration/default-state, and related WS/routing fixtures passed. +- `rtk rg -n "setKanbanMultiLoading|kanbanMulti\\.isLoading|isMultiLoading|updateMultiTask|removeMultiTask|kanbanMulti: \\{ snapshots: \\{\\}, isLoading: false \\}" apps/web --glob '!dist/**' --glob '!e2e/test-results/**' --glob '!**/*.test.ts' --glob '!**/*.test.tsx'` + returned no production matches. +- `rtk git diff --check` passed after the all-workflow snapshot/loading + cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/workflow-filter.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/workflow/workflow-sorting.spec.ts tests/task/create-task.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 25 desktop Docker tests after the all-workflow snapshot/loading + cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/workflow/mobile-workflow-manual-move-queue.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-create-task-remote-repo.spec.ts` + passed 14 mobile Docker tests after the all-workflow snapshot/loading + cleanup. +- RED task-detail lookup gate: + `cd apps/web && rtk pnpm test hooks/use-task.test.ts` + failed with `useAppStore must be used within StateProvider`, proving + `useTask`/`useTaskById` still depended on the legacy kanban store fallback. +- `cd apps/web && rtk pnpm test hooks/use-task.test.ts components/task/chat/messages/chat-message.test.tsx hooks/domains/session/use-session-state.test.ts components/task/task-page-content.test.tsx` + passed 4 files / 32 tests after the task-detail lookup cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-task.ts hooks/use-task.test.ts hooks/domains/kanban/use-task-by-id.ts hooks/domains/kanban/use-task-repositories.ts components/task/chat/messages/chat-message.test.tsx` + passed after the task-detail lookup cleanup. +- `rtk rg -n "useAppStore|findTaskInSnapshots|state\\.kanban|kanbanMulti\\.snapshots|kanban store|kanban tasks slice" apps/web/hooks/use-task.ts apps/web/hooks/domains/kanban/use-task-by-id.ts apps/web/hooks/domains/kanban/use-task-repositories.ts apps/web/components/task/chat/messages/chat-message.test.tsx` + returned no matches after the task-detail lookup cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the task-detail lookup + cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/task-list.spec.ts tests/session/new-session-dialog.spec.ts tests/chat/message-add-ws-gap.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 8 desktop Docker tests after the task-detail lookup cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-task-list-search.spec.ts tests/session/mobile-session-details.spec.ts` + passed 2 mobile Docker tests after the task-detail lookup cleanup. +- RED task-removal Query-cache gate: + `cd apps/web && rtk pnpm test hooks/use-task-removal.test.ts` + failed because a cached `qk.workflows.snapshot(...)` still contained the + removed task when the legacy `kanbanMulti.snapshots` mirror was empty. +- RED task-removal SPA redirect gate: + `cd apps/web && rtk pnpm test hooks/use-task-removal.test.ts` + failed because last-task removal changed `window.location.pathname` but did + not emit the SPA location-change event, leaving strict WS accounting exposed + to a full-document navigation race. +- `rtk make fmt` passed after the task-removal cleanup. +- `cd apps/web && rtk pnpm test hooks/use-task-removal.test.ts hooks/use-task-multi-select.test.ts components/kanban/task-multi-select-toolbar.test.tsx components/task/task-delete-confirm-dialog.test.tsx components/task/task-archive-confirm-dialog.test.tsx lib/query/bridge/tasks.test.ts lib/ws/handlers/tasks.test.ts lib/ws/handlers/kanban.test.ts` + passed 8 files / 55 tests after the task-removal cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the task-removal cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-task-removal.ts hooks/use-task-removal.test.ts hooks/use-task-multi-select.ts hooks/use-task-multi-select.test.ts lib/query/workflow-snapshot-cache.ts` + passed after the task-removal cleanup. +- `rtk rg -n "kanbanMulti|setWorkflowSnapshot" apps/web/hooks/use-task-removal.ts apps/web/lib/query/workflow-snapshot-cache.ts apps/web/hooks/use-task-multi-select.ts` + returned no production matches after the task-removal cleanup. +- `rtk git diff --check` passed after the task-removal cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/card-menu-delete-archive.spec.ts tests/task/delete-task-redirect.spec.ts tests/task/archive-task-redirect.spec.ts tests/task/task-switcher-status.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/kanban/workflow-filter.spec.ts tests/system/ws-event-accounting.spec.ts` + first failed in `archive-task-redirect` teardown because the old + `window.location.href = "/"` redirect lost the strict WS browser hook, then + passed 17 desktop Docker tests after the SPA-router redirect fix. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-task-list-search.spec.ts tests/kanban/mobile-kanban.spec.ts` + passed 13 mobile Docker tests after the task-removal cleanup. +- RED active workflow snapshot gate: + `cd apps/web && rtk pnpm test hooks/use-workflow-snapshot.test.ts hooks/use-tasks.test.ts lib/query/seed.test.ts lib/query/bridge/index.test.ts` + failed because `useWorkflowSnapshot` still returned the active `state.kanban` + fallback while Query was pending, `useTasks` surfaced legacy + `kanban.tasks`, boot snapshots were not seeded into + `qk.workflows.snapshot(...)`, and `session.state_changed` did not patch + Query-owned card `primary_session_state`. +- `rtk make fmt` passed after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm test hooks/use-workflow-snapshot.test.ts hooks/use-tasks.test.ts hooks/domains/kanban/use-kanban-data.test.tsx lib/query/seed.test.ts lib/query/bridge/index.test.ts` + passed 5 files / 32 tests after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-all-workflow-snapshots.query.test.tsx hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts components/kanban/task-multi-select-toolbar.test.tsx components/task/task-page-content.test.tsx components/kanban-with-preview.test.ts` + passed 6 files / 20 tests after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the active workflow snapshot + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-workflow-snapshot.ts hooks/use-workflow-snapshot.test.ts hooks/use-tasks.ts hooks/use-tasks.test.ts hooks/domains/kanban/use-kanban-data.ts hooks/domains/kanban/use-kanban-data.test.tsx components/kanban-board.tsx components/kanban-with-preview.tsx lib/query/seed.ts lib/query/seed.test.ts lib/query/bridge/session.ts lib/query/bridge/index.test.ts` + passed with one existing max-lines warning in `lib/query/bridge/index.test.ts`. +- `rtk rg -n "state\\.kanban|state\\.kanbanMulti|kanbanMulti\\.snapshots|snapshotState\\?\\.isLoading|kanban\\.isLoading" apps/web/hooks/use-workflow-snapshot.ts apps/web/hooks/use-tasks.ts apps/web/hooks/domains/kanban/use-kanban-data.ts apps/web/components/kanban-board.tsx apps/web/components/kanban-with-preview.tsx apps/web/lib/query/seed.ts apps/web/lib/query/bridge/session.ts` + returned no matches after the active workflow snapshot cleanup. +- `rtk git diff --check` passed after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/kanban-board.spec.ts tests/kanban/workflow-filter.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/task/task-list.spec.ts tests/task/task-switcher-status.spec.ts tests/session/session-tab-management.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 23 desktop Docker tests after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed 13 mobile Docker tests after the active workflow snapshot cleanup. +- RED active board writer gate: + `cd apps/web && rtk pnpm test hooks/domains/kanban/use-kanban-actions.test.tsx hooks/use-task-crud.test.tsx hooks/domains/kanban/use-swimlane-move.test.tsx` + failed because dialog create/edit, task delete/archive, and swimlane moves + still wrote/read legacy `kanban` / `kanbanMulti` mirrors instead of workflow + snapshot Query caches. +- `rtk make fmt` passed after the active board writer cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/kanban/use-kanban-actions.test.tsx hooks/use-task-crud.test.tsx hooks/domains/kanban/use-swimlane-move.test.tsx hooks/use-task-multi-select.test.ts components/kanban/task-multi-select-toolbar.test.tsx lib/query/bridge/tasks.test.ts lib/ws/handlers/tasks.test.ts lib/ws/handlers/kanban.test.ts` + passed 8 files / 37 tests after the active board writer cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the active board writer + cleanup. +- Targeted eslint for task CRUD, kanban actions, swimlane move/content, + `MoveTaskError`, view registry, and workflow snapshot cache files passed. +- Stale scans for legacy kanban writer references in the touched writer files + and for the deleted `use-drag-and-drop` / `swimlane-graph-content` paths + returned no matches. +- `rtk git diff --check` passed after the active board writer cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/kanban-board.spec.ts tests/kanban/card-menu-delete-archive.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/kanban/pipeline-view.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 19 desktop Docker tests after the active board writer cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts` + passed 11 mobile Docker tests after the active board writer cleanup. +- RED sidebar filter options gate: + `cd apps/web && rtk pnpm test components/task/sidebar-filter/use-filter-value-options.test.ts` + failed because workflow, workflow-step, and executor-type options were empty + when the legacy `kanbanMulti.snapshots` mirror was empty but workflow + snapshot Query caches were populated. +- `rtk make fmt` passed after the sidebar filter options cleanup. +- `cd apps/web && rtk pnpm test components/task/sidebar-filter/use-filter-value-options.test.ts` + passed 1 file / 6 tests after the sidebar filter options cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the sidebar filter options + cleanup. +- Targeted eslint for the sidebar filter options hook/test passed after the + cleanup. +- The stale server-state scan for `kanbanMulti.snapshots`, active + `state.kanban` task/step reads, and `setWorkflowSnapshot` returned no matches + in the sidebar filter options hook. +- `rtk git diff --check` passed after the sidebar filter options cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/sidebar-filter.spec.ts tests/task/task-list-filters.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 22 desktop Docker tests after the sidebar filter options cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-task-list-search.spec.ts` + passed 1 mobile Docker test after the sidebar filter options cleanup. +- RED task mention metadata gate: + `cd apps/web && rtk pnpm test components/task/chat/task-mention-items.test.ts hooks/use-message-handler.test.ts components/task/passthrough-chat-composer.test.ts` + failed because mention helpers still dereferenced `state.kanban` / + `kanbanMulti.snapshots`, and passthrough still required a store `getState` + callback for task mention context. +- `rtk make fmt` passed after the task mention metadata cleanup. +- `cd apps/web && rtk pnpm test components/task/chat/task-mention-items.test.ts hooks/use-message-handler.test.ts components/task/passthrough-chat-composer.test.ts` + passed 3 files / 19 tests after the task mention metadata cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the task mention metadata + cleanup. +- Targeted eslint for the task mention helpers, TipTap chat input, normal chat + send, and passthrough composer files passed after the cleanup. +- The stale server-state scan for `kanbanMulti`, active `state.kanban` task/step + reads, and task-mention context builders receiving `getState()` returned no + matches in the touched mention files. +- `rtk git diff --check` passed after the task mention metadata cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/message-add-ws-gap.spec.ts tests/cli-mode/passthrough-toolbar.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 10 desktop Docker tests after the task mention metadata cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts tests/cli-mode/mobile-passthrough-composer.spec.ts` + passed 3 mobile Docker tests after the task mention metadata cleanup. +- RED recent task switcher gate: + `cd apps/web && rtk pnpm test components/task/recent-task-switcher-model.test.ts` + failed with `ctx.kanbanSteps is not iterable` after the test context removed + legacy kanban fields and provided live task metadata only through workflow + snapshots. +- `rtk make fmt` passed after the recent task switcher cleanup. +- `cd apps/web && rtk pnpm test components/task/recent-task-switcher-model.test.ts components/task/recent-task-switcher-keys.test.ts` + passed 2 files / 17 tests after the recent task switcher cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the recent task switcher + cleanup. +- Targeted eslint for the recent switcher hook/model/tests passed after the + cleanup. +- The stale server-state scan for active `state.kanban`, `kanbanMulti`, and old + recent-switcher `kanban*` context fields returned no matches. +- `rtk git diff --check` passed after the recent task switcher cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/recent-task-switcher.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 4 desktop Docker tests after the recent task switcher cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-task-list-search.spec.ts` + passed 1 mobile Docker test after the recent task switcher cleanup. +- RED mobile session switcher gate: + `rtk pnpm --dir apps/web test components/task/mobile/session-task-switcher-sheet-hooks.test.tsx` + failed because the sheet still ignored Query-owned snapshots when legacy + `kanban` / `kanbanMulti` mirrors were empty, selected no primary session from + Query task metadata, wrote created tasks only into the legacy kanban mirror, + and hydrated `state.kanban` during workspace switch. +- `rtk make fmt` passed after the mobile session switcher cleanup. +- `rtk pnpm --dir apps/web test components/task/mobile/session-task-switcher-sheet-hooks.test.tsx` + passed 1 file / 4 tests after the mobile session switcher cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the mobile session switcher + cleanup. +- Targeted eslint for the mobile session switcher hook/test passed after the + cleanup. +- The stale server-state scan for active `state.kanban`, `kanbanMulti`, + `setWorkflowSnapshot`, and `findTaskInSnapshots` returned no matches in the + mobile session switcher hook. +- `rtk git diff --check` passed after the mobile session switcher cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed 2 mobile Docker tests after the mobile session switcher cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/system/ws-event-accounting.spec.ts` + passed 1 desktop Docker WS accounting test after the mobile session switcher + cleanup. +- RED legacy kanban WS mirror cleanup gate: + `rtk pnpm --dir apps/web test lib/ws/router.test.ts lib/ws/handlers/tasks.test.ts lib/ws/handlers/agent-session-kanban.test.ts` + failed because `kanban.update` was still registered, `task.updated` / + `task.deleted` still mutated legacy kanban mirrors, and + `session.state_changed` still patched task primary state into those mirrors. +- RED workspace deletion mirror gate: + `rtk pnpm --dir apps/web test lib/ws/handlers/workspaces.test.ts` failed + because deleting the active workspace still cleared `state.kanban`. +- Legacy kanban WS mirror cleanup moved `kanban.update`, + `task.updated`/`task.deleted`, `session.state_changed`, and + `workspace.deleted` server-state writes out of Zustand. The remaining + `task.*` WS handler keeps only client-local side effects: active + primary-session adoption, deleted-task local storage cleanup, sidebar + preference cleanup, and context file cleanup. +- `rtk make fmt` passed after the legacy kanban WS mirror cleanup. +- `rtk pnpm --dir apps/web test lib/ws/router.test.ts lib/ws/handlers/tasks.test.ts lib/ws/handlers/agent-session.test.ts lib/ws/handlers/agent-session-kanban.test.ts lib/ws/handlers/workspaces.test.ts lib/query/bridge/tasks.test.ts lib/query/bridge/index.test.ts lib/query/bridge/workspace.test.ts` + passed 8 files / 50 tests after the legacy kanban WS mirror cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the legacy kanban WS mirror + cleanup. +- Targeted eslint for the changed WS handlers/router/tests and bridge tests + passed, with the existing max-lines warning in + `lib/query/bridge/index.test.ts`. +- Production stale scans for `registerKanbanHandlers`, + `lib/ws/handlers/kanban`, direct WS `state.kanban` / + `state.kanbanMulti`, `setWorkflowSnapshot`, and + `syncKanbanPrimarySessionState` returned no production matches; remaining + matches are regression assertions in WS handler tests. +- `rtk git diff --check` passed after the legacy kanban WS mirror cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/task/task-list.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/session/session-tab-management.spec.ts tests/session/session-handoff.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 20 desktop Docker tests after the legacy kanban WS mirror cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/session/mobile-handoff.spec.ts` + passed 13 mobile Docker tests after the legacy kanban WS mirror cleanup. +- RED final store compatibility gate: + `rtk pnpm --dir apps/web test lib/routing/kanban-route-hydration.test.ts lib/state/default-state.test.ts lib/state/slices/kanban/kanban-slice.test.ts lib/query/seed.test.ts` + failed because route readiness, default-state merging, the kanban slice, and + query seed still accepted legacy `kanban` / `kanbanMulti` shapes. +- `rtk make fmt` passed after the final store compatibility cleanup. +- `rtk pnpm --dir apps/web test lib/state/default-state.test.ts lib/kanban/find-task.test.ts components/task/task-session-sidebar-aggregate.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.tsx hooks/domains/kanban/use-kanban-data.test.tsx components/app-sidebar/app-sidebar-new-task-item.test.tsx components/app-sidebar/sections/tasks-section.test.tsx components/session-commands.test.tsx hooks/use-task-removal.test.ts lib/query/seed.test.ts lib/routing/kanban-route-hydration.test.ts lib/state/slices/kanban/kanban-slice.test.ts hooks/use-tasks.test.ts components/task/mobile/session-task-switcher-sheet-hooks.test.tsx lib/ws/handlers/tasks.test.ts lib/ws/handlers/agent-session-kanban.test.ts lib/ws/handlers/workspaces.test.ts components/kanban-board-grid.test.tsx components/kanban-card-content.test.tsx` + passed 18 files / 85 tests after the final store compatibility cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the final store + compatibility cleanup. +- Targeted eslint for the final store cleanup files, Query seed/routing, + sidebar/session utility tests, and touched E2E specs passed. +- Production stale scans for active `state.kanban`, `kanbanMulti`, + `setWorkflowSnapshot`, deleted kanban actions, and E2E store kanban access + returned no production matches; remaining matches are client-only + `kanbanViewMode` / `kanbanPreviewedTaskId` and absence assertions. +- `rtk git diff --check` passed after the final store compatibility cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/task/task-list.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/session/session-tab-management.spec.ts tests/system/ws-event-accounting.spec.ts tests/kanban/preview-primary-session.spec.ts` + passed 20 desktop Docker tests after the final store compatibility cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/session/mobile-handoff.spec.ts` + passed 13 mobile Docker tests after the final store compatibility cleanup. +- `rtk pnpm --dir apps/web test lib/query/seed.test.ts lib/query/bridge/index.test.ts components/task/new-session-dialog.test.tsx components/task/new-subtask-dialog.test.tsx lib/ws/handlers/agent-session.test.ts lib/state/slices/features/features-slice.test.ts components/app-sidebar/app-sidebar-workspace-picker.test.tsx src/boot-payload.test.ts` + passed 8 files / 73 tests after the feature flags and session worktrees + cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the feature flags and session + worktrees cleanup. +- Focused stale scans for removed feature/worktree store symbols, bridge audit + migration wording, removed Office store fields/actions, removed workspace/ + kanban/repository/session-runtime store APIs, and deleted legacy WS handler + registrations returned no production server-state matches. +- `rtk pnpm --dir apps/web test hooks/domains/session/use-queue.test.ts lib/query/bridge/index.test.ts` + passed 2 files / 23 tests after renaming the Query-only queue cache helper to + avoid stale removed-action audit matches. +- `rtk pnpm --dir apps/web exec eslint --max-warnings 0 hooks/domains/session/use-queue.ts` + passed. +- `rtk pnpm --dir apps/web e2e:docker tests/session/new-session-dialog.spec.ts tests/task/file-tree-lazy-load.spec.ts tests/chat/message-queue.spec.ts tests/office/sidebar-office-gating.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 18 Docker tests with strict WS accounting for the final Task 10 + cleanup gate. +- Office routing cleanup: + - `rtk pnpm --dir apps/web test hooks/domains/office/use-workspace-routing.test.tsx hooks/domains/office/use-routing-query-hooks.test.tsx` + passed 2 files / 7 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web exec eslint --max-warnings 0 hooks/domains/office/use-workspace-routing.ts hooks/domains/office/use-provider-health.ts hooks/domains/office/use-routing-preview.ts hooks/domains/office/use-agent-route.ts hooks/domains/office/use-run-attempts.ts hooks/domains/office/use-workspace-routing.test.tsx hooks/domains/office/use-routing-query-hooks.test.tsx app/office/agents/agents-page-client.tsx app/office/agents/components/agent-card.tsx` + passed. + - `rtk apps/web/e2e/scripts/run-e2e.sh --docker --no-build --project routing -- e2e/tests/office/office-routing-disabled.spec.ts e2e/tests/office/office-routing-fallback.spec.ts e2e/tests/office/office-routing-agent-override.spec.ts e2e/tests/office/office-routing-recovery.spec.ts` + passed 6 Docker routing-project tests with strict WS accounting. + - Full verify passed before commit: `rtk make fmt`, + `rtk node apps/web/scripts/generate-release-notes.mjs`, + `rtk node apps/web/scripts/generate-changelog.mjs`, `rtk make typecheck`, + `rtk make test`, and `rtk make lint`. +- Office shell/sidebar cleanup: + - `rtk pnpm --dir apps/web test app/office/page-client.test.tsx components/app-sidebar/app-sidebar-primary-nav.test.tsx components/app-sidebar/sections/office-navigation-section.test.tsx components/app-sidebar/sections/agents-section.test.tsx components/app-sidebar/sections/projects-section.test.tsx` + passed 5 files / 10 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web exec eslint --max-warnings 0 app/office/page-client.tsx app/office/page-client.test.tsx app/office/agents/agents-page-client.tsx components/app-sidebar/app-sidebar-primary-nav.tsx components/app-sidebar/app-sidebar-primary-nav.test.tsx components/app-sidebar/sections/office-navigation-section.tsx components/app-sidebar/sections/office-navigation-section.test.tsx components/app-sidebar/sections/agents-section.tsx components/app-sidebar/sections/agents-section.test.tsx components/app-sidebar/sections/projects-section.tsx components/app-sidebar/sections/projects-section.test.tsx` + passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/dashboard.spec.ts tests/office/agents.spec.ts tests/office/projects.spec.ts tests/office/sidebar-navigation.spec.ts tests/office/realtime-dashboard.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 30 desktop Docker tests / 1 skipped with strict WS accounting. +- Office inbox/activity bridge cleanup: + - `rtk pnpm --dir apps/web test lib/query/bridge/index.test.ts app/office/inbox/inbox-page-client.test.tsx app/office/workspace/activity/activity-row.test.tsx` + passed 3 files / 19 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web exec eslint --max-warnings 0 lib/query/bridge/office.ts lib/query/bridge/index.test.ts app/office/inbox/inbox-page-client.tsx app/office/inbox/inbox-page-client.test.tsx app/office/workspace/activity/activity-feed.tsx` + passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/inbox.spec.ts tests/office/realtime-inbox.spec.ts tests/office/approval-flow.spec.ts tests/office/activity-page.spec.ts tests/office/comment-run-status.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 15 Docker tests with strict WS accounting. +- Office meta cleanup: + - `rtk pnpm --dir apps/web test hooks/domains/office/use-office-data.test.tsx app/office/inbox/inbox-page-client.test.tsx app/office/agents/[id]/components/agent-configuration-tab.test.tsx` + passed 3 files / 7 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web exec eslint --max-warnings 0 hooks/domains/office/use-office-data.ts hooks/domains/office/use-office-data.test.tsx src/office-routes.tsx app/office/setup/step-agent.tsx app/office/setup/step-review.tsx app/office/inbox/inbox-item-row.tsx app/office/inbox/inbox-page-client.test.tsx app/office/tasks/use-tasks-tree.ts app/office/tasks/task-board.tsx app/office/tasks/task-filters.tsx app/office/projects/create-project-dialog.tsx app/office/projects/project-card.tsx app/office/workspace/skills/skill-detail.tsx app/office/workspace/skills/create-skill-form.tsx app/office/agents/[id]/components/agent-configuration-tab.tsx app/office/agents/[id]/components/agent-configuration-tab.test.tsx app/office/agents/[id]/components/agent-permissions-tab.tsx app/office/agents/[id]/components/agent-overview-tab.tsx app/office/agents/components/agent-status-dot.tsx app/office/agents/components/agent-role-badge.tsx app/office/agents/components/create-agent-dialog.tsx app/office/routines/run-row.tsx app/office/components/new-task-bottom-bar.tsx lib/state/store.ts lib/state/slices/office/types.ts lib/state/slices/office/office-slice.ts` + passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/onboarding.spec.ts tests/office/task-filters.spec.ts tests/office/projects.spec.ts tests/office/agents.spec.ts tests/office/skills.spec.ts tests/office/routines.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 35 Docker tests / 1 skipped with strict WS accounting. +- Office bridge parity cleanup: + - `rtk pnpm --dir apps/web test lib/query/bridge/index.test.ts` + passed 1 file / 21 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web exec eslint --max-warnings 0 lib/query/bridge/office.ts lib/query/bridge/index.test.ts` + passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/comment-run-status.spec.ts tests/office/agent-dashboard.spec.ts tests/office/agent-run-detail.spec.ts tests/office/realtime-tasks.spec.ts tests/office/projects.spec.ts tests/office/approval-flow.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 18 Docker tests / 1 skipped with strict WS accounting. + - `rtk apps/web/e2e/scripts/run-e2e.sh --docker --no-build --project routing -- e2e/tests/office/office-routing-disabled.spec.ts e2e/tests/office/office-routing-fallback.spec.ts e2e/tests/office/office-routing-agent-override.spec.ts e2e/tests/office/office-routing-recovery.spec.ts` + passed 6 Docker routing-project tests with strict WS accounting. +- Office page refetch cleanup: + - `rtk pnpm --dir apps/web test lib/query/bridge/index.test.ts` + passed 1 file / 21 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web exec eslint --max-warnings 0 lib/query/bridge/office.ts lib/query/bridge/index.test.ts app/office/projects/projects-page-client.tsx app/office/agents/[id]/dashboard/dashboard-view.tsx app/office/agents/[id]/components/agent-runs-tab.tsx app/office/agents/[id]/layout.tsx app/office/routines/routines-page-client.tsx` + passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/agent-dashboard.spec.ts tests/office/agent-run-detail.spec.ts tests/office/projects.spec.ts tests/office/routines.spec.ts tests/office/realtime-tasks.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 15 Docker tests / 1 skipped with strict WS accounting. +- Office task list/detail cleanup: + - `rtk pnpm --dir apps/web test app/office/tasks/use-paginated-tasks.test.tsx hooks/use-optimistic-task-mutation.test.tsx lib/query/bridge/index.test.ts` + passed 3 files / 26 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web lint` passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/tasks.spec.ts tests/office/realtime-tasks.spec.ts tests/office/task-filters.spec.ts tests/office/task-sorting.spec.ts tests/office/topbar-breadcrumb.spec.ts tests/office/comment-input.spec.ts tests/office/simple-advanced-toggle.spec.ts tests/office/regression-fixes.spec.ts tests/office/property-pickers.spec.ts` + passed 36 Docker tests with strict WS accounting. +- Office task helper/scaffold cleanup: + - `rtk pnpm --dir apps/web test components/task/simple/components/blockers-picker.test.tsx hooks/use-optimistic-task-mutation.test.tsx app/office/tasks/use-paginated-tasks.test.tsx lib/query/bridge/index.test.ts lib/ws/router.test.ts lib/ws/handlers/agent-session.test.ts components/state-hydrator.test.tsx` + passed 7 files / 52 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web lint` passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/tasks.spec.ts tests/office/realtime-tasks.spec.ts tests/office/task-filters.spec.ts tests/office/task-sorting.spec.ts tests/office/topbar-breadcrumb.spec.ts tests/office/comment-input.spec.ts tests/office/simple-advanced-toggle.spec.ts tests/office/regression-fixes.spec.ts tests/office/property-pickers.spec.ts tests/office/projects.spec.ts tests/office/agent-run-detail.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 43 Docker tests / 1 skipped with strict WS accounting. +- Office simple-pane reference-data cleanup: + - `rtk pnpm --dir apps/web test components/task/simple/components/agents-multi-picker.test.tsx components/task/simple/components/blockers-picker.test.tsx components/task/simple/components/status-picker.test.tsx components/task/simple/components/priority-picker.test.tsx components/task/simple/components/approval-action-bar.test.tsx` + passed 5 files / 17 tests / 4 skipped. + - `rtk rg -n "office\\.agentProfiles|office\\.projects|office\\.routines|office\\.skills|setOfficeAgentProfiles|setProjects|setRoutines|setSkills" apps/web/components/task/simple --glob '!dist/**' --glob '!**/*.test.*'` + returned no matches. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web lint` passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/property-pickers.spec.ts tests/office/comment-input.spec.ts tests/office/simple-advanced-toggle.spec.ts tests/office/regression-fixes.spec.ts tests/office/tasks.spec.ts tests/office/realtime-tasks.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 30 Docker tests with strict WS accounting. +- Final Office store cleanup: + - Moved agent detail/routes, project detail and writes, create-agent/create-project + flows, routines, workspace skills, org chart, new-task reference selectors, + Office route bootstrap, and costs boot data to Office Query caches. + - The Office Zustand slice now retains only task filter/sort/view/grouping/ + nesting UI state. + - `rtk pnpm --dir apps/web test hooks/domains/office/use-office-data.test.tsx app/office/agents/[id]/components/agent-configuration-tab.test.tsx app/office/agents/[id]/components/agent-runs-tab.test.tsx app/office/components/new-task-dialog.test.tsx app/office/workspace/org/org-tree-layout.test.ts app/office/page-client.test.tsx lib/query/seed.test.ts components/state-hydrator.test.tsx lib/query/bridge/index.test.ts components/task/simple/components/pending-approval-badge.test.tsx` + passed 10 files / 68 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web lint` passed. + - Stale scans for removed Office store fields/actions returned no production + server-state readers/writers; remaining `office.tasks.*` matches are + client-only task filter/sort/view/grouping/nesting state. + - `rtk pnpm --dir apps/web e2e:docker tests/office/agents.spec.ts tests/office/agent-subroutes.spec.ts tests/office/agent-roles.spec.ts tests/office/agent-skills-ui.spec.ts tests/office/permissions.spec.ts tests/office/projects.spec.ts tests/office/project-repository-picker.spec.ts tests/office/routines.spec.ts tests/office/routines-ui.spec.ts tests/office/routine-fire.spec.ts tests/office/skills.spec.ts tests/office/system-skills.spec.ts tests/office/skills-readonly.spec.ts tests/office/org-chart.spec.ts tests/office/execution-stages.spec.ts tests/office/costs.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 67 Docker tests / 5 skipped with strict WS accounting. + +Task 11 strict QA completed locally: + +- `rtk make fmt` passed. +- `rtk make typecheck test lint` passed. +- `rtk pnpm --dir apps/web e2e:docker --shards 3` passed with strict WS + accounting: shard 1 had 363 passed / 1 flaky retry, shard 2 had 370 passed, + and shard 3 had 369 passed. +- `rtk pnpm --dir apps/web e2e:docker --project mobile-chrome` passed 78 + mobile Docker tests with strict WS accounting. +- `rtk pnpm --dir apps/web e2e:docker --project routing` passed 7 routing + Docker tests with strict WS accounting. +- `rtk env KANDEV_E2E_CONTAINERS=1 pnpm --dir apps/web e2e --project=containers` + passed 99 container-backed Docker/SSH tests / 1 skipped after the Linux + `mock-agent` helper was built for the container runtime. +- Focused PR popover regressions passed: + `rtk pnpm --dir apps/web e2e:docker e2e/tests/pr/pr-multi-popover.spec.ts` + passed 3 tests, and the broader PR detail/detection/popover sequence passed + 13 tests. +- The SSH metadata smoke test now polls the `ExecutorRunning` metadata row it + asserts, avoiding a false dependency on chat session completion; the focused + `recovery.spec.ts:97` containers check passed before the final full + containers project passed. + +Wave 4 (cleanup and full verification): + +- [x] [task-10-remove-zustand-server-state](task-10-remove-zustand-server-state.md) — done +- [x] [task-11-e2e-strict-qa](task-11-e2e-strict-qa.md) — done + +## Open Questions + +- None blocking. The user requested a one-shot migration PR; this plan treats + feature-flagged incremental rollout as out of scope unless review discovers a + domain that cannot safely migrate in the same PR. diff --git a/docs/plans/tanstack-query-server-state/task-01-query-foundation.md b/docs/plans/tanstack-query-server-state/task-01-query-foundation.md new file mode 100644 index 0000000000..3f5763a9d5 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-01-query-foundation.md @@ -0,0 +1,76 @@ +--- +id: "01-query-foundation" +title: "Query foundation" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 01: Query Foundation + +## Acceptance + +- `@tanstack/react-query`, `@tanstack/react-query-devtools`, and + `@tanstack/eslint-plugin-query` are installed at `5.101.1` or newer + compatible v5 versions. +- `QueryProvider` wraps the SPA from `apps/web/src/main.tsx`, exposes the query + client only for E2E, and does not break `StateProvider`. +- Query cache seeding helpers exist for boot payload/app-state route data and + `StateHydrator`. + +## Verification + +- `cd apps && pnpm --filter @kandev/web typecheck` +- `cd apps && pnpm --filter @kandev/web test -- apps/web/src/boot-payload.test.ts apps/web/src/spa-routing.test.ts` +- `cd apps/web && pnpm e2e:docker tests/task/task-list.spec.ts tests/kanban/kanban-board.spec.ts` + +## Files Likely Touched + +- `apps/web/package.json` +- `apps/pnpm-lock.yaml` +- `apps/web/src/main.tsx` +- `apps/web/components/state-provider.tsx` +- `apps/web/components/state-hydrator.tsx` +- `apps/web/lib/query/client.ts` +- `apps/web/lib/query/provider.tsx` +- `apps/web/lib/query/seed.ts` +- `apps/web/eslint.config.mjs` + +## Dependencies + +None. + +## Inputs + +- Spec sections: What, API Surface, Persistence Guarantees. +- Old PR patterns: `origin/pr/1130:apps/web/lib/query/client.ts` and + `origin/pr/1130:apps/web/lib/query/provider.tsx`. +- Current boundary: `apps/web/src/main.tsx`, not `app/layout.tsx`. + +## Output Contract + +Update this task to `done`, list files changed, tests run, and any boot-seeding +gaps left for domain tasks. + +## Output + +- Installed TanStack Query v5 foundation dependencies: + `@tanstack/react-query`, `@tanstack/react-query-devtools`, and + `@tanstack/eslint-plugin-query` at `5.101.1`. +- Added `QueryProvider`, browser singleton query-client defaults, query key + factories, and boot/route-state cache seeding helpers. +- Wrapped the SPA root in `QueryProvider` and seeded cache from the Go boot + payload before rendering. +- Updated `StateHydrator` to seed the active provider `QueryClient` during + route transitions, including an injected-client regression test. +- No domain-specific boot-seeding gaps were closed in this task; later domain + tasks still own full query-option coverage and hook migration. + +## Commands Run + +- `cd apps/web && pnpm test -- lib/query/client.test.ts lib/query/seed.test.ts lib/query/provider.test.tsx components/state-hydrator.test.tsx src/boot-payload.test.ts src/spa-routing.test.ts` +- `cd apps/web && pnpm typecheck` +- `cd apps/web && pnpm e2e:docker -- tests/system/ws-event-accounting.spec.ts tests/chat/message-add-ws-gap.spec.ts tests/task/task-list.spec.ts tests/kanban/kanban-board.spec.ts` +- `cd apps/web && pnpm e2e:docker --no-build --project mobile-chrome -- tests/chat/mobile-message-add-ws-gap.spec.ts` diff --git a/docs/plans/tanstack-query-server-state/task-02-ws-accounting.md b/docs/plans/tanstack-query-server-state/task-02-ws-accounting.md new file mode 100644 index 0000000000..0c594a8508 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-02-ws-accounting.md @@ -0,0 +1,86 @@ +--- +id: "02-ws-accounting" +title: "WS accounting" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 02: WS Accounting + +## Acceptance + +- Backend outbound WebSocket messages carry `connection_id`, + `connection_seq`, and session-scoped `session_seq` when available. +- E2E-only `/api/v1/_test/ws-sent` endpoint returns bounded sent events and is + unavailable outside E2E/test harness mode. +- Frontend `WsAccount` records parsed envelopes and E2E teardown fails on + missing connection/session events when `KANDEV_E2E_WS_ASSERT=1`. + +## Verification + +- `make -C apps/backend test` +- `cd apps && pnpm --filter @kandev/web test -- apps/web/lib/ws/ws-account.test.ts` +- `cd apps/web && pnpm e2e:docker tests/system/ws-event-accounting.spec.ts` + +## Files Likely Touched + +- `apps/backend/pkg/websocket/message.go` +- `apps/backend/internal/gateway/websocket/client.go` +- `apps/backend/internal/gateway/websocket/hub.go` +- `apps/backend/internal/gateway/websocket/ws_sent_log.go` +- `apps/backend/cmd/kandev/e2e_reset.go` +- `apps/web/lib/ws/client.ts` +- `apps/web/lib/ws/ws-account.ts` +- `apps/web/e2e/helpers/ws-account.ts` +- `apps/web/e2e/fixtures/test-base.ts` +- `apps/web/e2e/helpers/api-client.ts` +- `apps/web/e2e/tests/system/ws-event-accounting.spec.ts` + +## Dependencies + +None. + +## Inputs + +- Old PR files: `ws_sent_log.go`, `ws-account.ts`, `helpers/ws-account.ts`, + `tests/system/ws-event-accounting.spec.ts`. +- Current docs mismatch: runner/README mention strict WS accounting, but no + current implementation exists. + +## Output Contract + +Update this task to `done`, include commands run, and document any events that +must be excluded from receipt accounting. + +## Output + +- Added backend envelope stamping at the final WebSocket send boundary: + `connection_id`, `connection_seq`, and `session_seq` for + `BroadcastToSession` fan-out. +- Added a per-connection bounded sent-log and E2E-only + `/api/v1/_test/ws-sent` route mounted only with the existing + `KANDEV_E2E_MOCK` test harness. +- Added frontend `WsAccount`, parsing hook, E2E helper diffing, and + `KANDEV_E2E_WS_ASSERT=1` teardown enforcement in the base Playwright fixture. +- Added expected-drop reconciliation for intentional WebSocket fault-injection + tests; those tests register the exact dropped `session.message.added` frames + and still fail on unexpected or missing drops. +- Added the previously missing + `apps/web/e2e/tests/system/ws-event-accounting.spec.ts` Docker smoke test. + +## Commands Run + +- `go test ./internal/gateway/websocket -run 'Test(Client_SendMessageStampsConnectionSequenceAndLog|Hub_BroadcastToSessionStampsSessionSequence|WsSentLogEvictsOldestAndFiltersSince)'` +- `go test ./internal/backendapp -run TestRegisterWsSentTestRoute` +- `go test ./internal/gateway/websocket` +- `make -C apps/backend test` +- `cd apps && pnpm --filter @kandev/web test -- lib/ws/ws-account.test.ts` +- `cd apps/web && pnpm test -- lib/ws/ws-account.test.ts lib/ws/ws-account-e2e-helper.test.ts` +- `cd apps/web && pnpm typecheck` +- `cd apps && pnpm exec prettier --check web/lib/ws/ws-account.ts web/lib/ws/ws-account.test.ts web/e2e/helpers/ws-account.ts web/e2e/fixtures/test-base.ts web/e2e/helpers/api-client.ts web/lib/ws/client.ts web/global.d.ts` +- `git diff --check` +- `cd apps/web && pnpm e2e:docker -- tests/system/ws-event-accounting.spec.ts tests/chat/message-add-ws-gap.spec.ts tests/task/task-list.spec.ts tests/kanban/kanban-board.spec.ts` — 7 passed +- `cd apps/web && pnpm e2e:docker --no-build --project mobile-chrome -- tests/chat/mobile-message-add-ws-gap.spec.ts` — 1 passed diff --git a/docs/plans/tanstack-query-server-state/task-03-query-options-taxonomy.md b/docs/plans/tanstack-query-server-state/task-03-query-options-taxonomy.md new file mode 100644 index 0000000000..f1d594faf2 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-03-query-options-taxonomy.md @@ -0,0 +1,77 @@ +--- +id: "03-query-options-taxonomy" +title: "Query options taxonomy" +status: done +wave: 2 +depends_on: ["01-query-foundation"] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 03: Query Options Taxonomy + +## Acceptance + +- `apps/web/lib/query/keys.ts` defines typed, serializable key factories for + every server-state domain. +- `apps/web/lib/query/query-options/*` defines query option factories over the + existing domain API clients. +- Unit tests cover key stability, representative API mapping, and infinite + query cursor behavior. + +## Verification + +- `cd apps && pnpm --filter @kandev/web test -- apps/web/lib/query` +- `cd apps && pnpm --filter @kandev/web typecheck` +- No standalone E2E gate before readers consume these factories; complete Wave + 2 only after the Docker E2E gate in `plan.md` passes. + +## Files Likely Touched + +- `apps/web/lib/query/keys.ts` +- `apps/web/lib/query/query-options/*.ts` +- `apps/web/lib/query/query-options/*.test.ts` +- `apps/web/lib/api/domains/*` + +## Dependencies + +- Task 01. + +## Inputs + +- Old PR: `origin/pr/1130:apps/web/lib/query/keys.ts` and + `origin/pr/1130:apps/web/lib/query/query-options/*`. +- TanStack docs: query keys, query options, important defaults, testing. + +## Output Contract + +Update this task to `done`, list covered domains, and list any server-state +domain intentionally deferred to a later task. + +## Output + +- Expanded `apps/web/lib/query/keys.ts` with serializable key factories for + boot/features, workspaces/repositories/branches, workflows/tasks, sessions, + settings, office, integrations, and system data. +- Added query option factories under `apps/web/lib/query/query-options/` for + features, workspace, kanban/tasks, session, settings, office, and system + reads. +- Added infinite query factories for workspace task pages, office tasks, and + session messages. Cursor/page values are page params, not stable filter keys. +- Added a query-options barrel export. + +## Deferred Domains + +- Integration-specific query option factories are keyed but deferred to + `task-09-integrations-automations-system` so each integration can migrate its + readers and mutations together. +- Runtime stream surfaces such as terminal/process output are keyed/allowlisted + through the bridge audit work but intentionally remain outside QueryClient + until `task-08-session-runtime-streams` decides which streams should stay + outside TanStack Query. + +## Commands Run + +- `cd apps/web && pnpm test -- lib/query/keys.test.ts lib/query/query-options/query-options.test.ts` +- `cd apps/web && pnpm test -- lib/query lib/ws` — 23 files, 168 tests passed +- `cd apps/web && pnpm typecheck` diff --git a/docs/plans/tanstack-query-server-state/task-04-query-bridge-audit.md b/docs/plans/tanstack-query-server-state/task-04-query-bridge-audit.md new file mode 100644 index 0000000000..e135283b71 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-04-query-bridge-audit.md @@ -0,0 +1,87 @@ +--- +id: "04-query-bridge-audit" +title: "Query bridge audit" +status: done +wave: 2 +depends_on: ["01-query-foundation", "02-ws-accounting", "03-query-options-taxonomy"] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 04: Query Bridge Audit + +## Acceptance + +- `subscribeWebSocketClient(listener)` exists and notifies QueryBridge when the + WS client is installed/replaced. +- `registerQueryBridge` registers per-domain bridge handlers that patch or + invalidate query keys. +- `wrapBridgeHandler` records E2E-only audit rows, and skipped actions/prefixes + are documented inline. + +## Verification + +- `cd apps && pnpm --filter @kandev/web test -- apps/web/lib/query/bridge apps/web/lib/ws` +- `cd apps && pnpm --filter @kandev/web typecheck` +- `cd apps/web && pnpm e2e:docker tests/system/ws-event-accounting.spec.ts tests/chat/message-add-ws-gap.spec.ts` +- `cd apps/web && pnpm e2e:docker --project mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts` + +## Files Likely Touched + +- `apps/web/lib/ws/connection.ts` +- `apps/web/lib/ws/client.ts` +- `apps/web/lib/query/provider.tsx` +- `apps/web/lib/query/bridge/index.ts` +- `apps/web/lib/query/bridge/*.ts` +- `apps/web/lib/query/bridge/**/*.test.ts` +- `apps/web/e2e/helpers/ws-account.ts` + +## Dependencies + +- Tasks 01, 02, 03. + +## Inputs + +- Old PR bridge entrypoint and `wrapBridgeHandler`: + `origin/pr/1130:apps/web/lib/query/bridge/index.ts`. +- Spec scenario: parsed WS events must touch query cache or be allowlisted. + +## Output Contract + +Update this task to `done`, include the allowlist rationale summary, and list +domains whose readers still need migration before old Zustand handlers can be +deleted. + +## Output + +- Added `subscribeWebSocketClient(listener)` and `WebSocketClient.onEnvelope` + so QueryBridge can attach to the current WS client and observe all parsed + envelopes, including responses. +- Registered QueryBridge from `QueryProvider`, with cleanup on WS client + replacement and provider unmount. +- Added bridge registrars for task/workspace/workflow events. Task events patch + task detail queries and invalidate finite and infinite task-list keys. +- Added E2E-gated bridge audit rows for handled and allowlisted envelopes. + +## Allowlist Rationale Summary + +- Control-plane request/response actions are resolved by `WebSocketClient` + pending requests and do not represent durable query cache entries. +- Zustand-temporary actions remain allowlisted until their Wave 3 domain moves + both UI readers and bridge writers to TanStack Query. +- Client-effect actions drive toasts, dialogs, browser notifications, or + imperative refreshes rather than stable server-state cache entries. +- High-volume stream actions remain outside QueryClient to avoid per-chunk + observer churn. + +## Domains Still Needing Reader Migration + +- Settings/agents/executors, office, session runtime/messages/turns/plans, + integrations, automations, system jobs/status, terminal/git streams, and old + Zustand task/workspace readers remain for the later domain migration and + cleanup tasks. + +## Commands Run + +- `cd apps/web && pnpm test -- lib/query lib/ws` — 23 files, 168 tests passed +- `cd apps/web && pnpm typecheck` diff --git a/docs/plans/tanstack-query-server-state/task-05-workspace-kanban-settings.md b/docs/plans/tanstack-query-server-state/task-05-workspace-kanban-settings.md new file mode 100644 index 0000000000..c6f3ce3be7 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-05-workspace-kanban-settings.md @@ -0,0 +1,104 @@ +--- +id: "05-workspace-kanban-settings" +title: "Workspace kanban settings" +status: done +wave: 3 +depends_on: ["03-query-options-taxonomy", "04-query-bridge-audit"] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 05: Workspace Kanban Settings + +## Acceptance + +- Workspace, repository, workflow, kanban, features, and settings server-state + readers use TanStack Query. +- Kanban task/workflow WS handlers write the same query keys mounted UI reads. +- First paint for `/`, `/tasks`, `/github`, `/jira`, `/linear`, and `/gitlab` + still seeds from Go boot/app-state data. + +## Verification + +- `cd apps && pnpm --filter @kandev/web test -- apps/web/hooks apps/web/src apps/web/lib/query` +- `cd apps/web && pnpm e2e:docker tests/task/task-list.spec.ts tests/task/task-list-filters.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/workflow-filter.spec.ts tests/settings/config-management.spec.ts` +- `cd apps/web && pnpm e2e:docker --project mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-task-list-search.spec.ts` + +## Files Likely Touched + +- `apps/web/hooks/use-workflow-snapshot.ts` +- `apps/web/hooks/use-tasks.ts` +- `apps/web/hooks/domains/kanban/*` +- `apps/web/src/spa-routes.tsx` +- `apps/web/lib/query/query-options/kanban.ts` +- `apps/web/lib/query/query-options/workspace.ts` +- `apps/web/lib/query/query-options/settings.ts` +- `apps/web/lib/query/bridge/kanban.ts` +- `apps/web/lib/query/bridge/workspace.ts` +- `apps/web/lib/query/bridge/settings.ts` +- old handlers under `apps/web/lib/ws/handlers/{kanban,tasks,workflows,workspaces,users}.ts` + +## Dependencies + +- Tasks 03 and 04. + +## Inputs + +- Old PR kanban query/bridge files. +- Current boot routing files: `apps/web/src/spa-routes.tsx` and + `apps/web/lib/routing/kanban-route-hydration.ts`. + +## Output Contract + +Status: done. + +Summary: + +- Migrated workspace, repository, workflow, kanban task, task-list, and settings + readers to TanStack Query while keeping Zustand mirrors for UI-only state and + compatibility during the one-shot PR. +- Added the canonical workflow snapshot mapper in + `apps/web/lib/kanban/snapshot.ts` so query data and legacy store mirrors share + one shape. +- Updated task-list query filters to include `page` in the key and request, and + removed the over-specific boot seed that reused workflow-filtered data for + the All Workflows view. +- Extended the query bridge so task/workflow/kanban/workspace events invalidate + or remove the same query keys mounted UI now reads. +- Added a bounded WS teardown drain in the E2E fixture so strict accounting + compares after trailing parsed frames settle instead of flagging browser-side + timing skew as a drop. + +Retained Zustand paths: + +- UI selection and client-only display state remains in Zustand, including + active workspace/workflow ids and kanban display preferences. +- Query-backed hooks still mirror server snapshots into the legacy store where + older components have not yet been removed by Task 10. + +Removed or reduced Zustand ownership: + +- Workspace repository lists, branches, scripts, workflow snapshots, task list + rows, task lookup helpers, user display settings, settings discovery, editor, + prompt, agent/model, and notification-provider readers now source server data + from TanStack Query. + +Temporary bridge dual-write: + +- The WS bridge now invalidates/patches query keys for migrated domains while + existing Zustand handlers remain installed until Task 10 removes legacy + server-state handlers after all readers have moved. + +Verification: + +- `cd apps/web && pnpm test -- hooks/use-workflow-snapshot.test.ts hooks/use-tasks.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts lib/query/keys.test.ts lib/query/query-options/query-options.test.ts lib/query/bridge/index.test.ts` + passed: 7 files, 36 tests. +- `cd apps/web && pnpm test -- hooks/domains/settings hooks/domains/workspace hooks/use-user-display-settings.ts hooks/use-kanban-display-settings.ts` + passed: 2 files, 14 tests. +- `cd apps/web && pnpm typecheck` passed. +- `cd apps && pnpm --filter @kandev/web test -- hooks src lib/query` passed: + 89 files, 672 tests. +- `cd apps/web && pnpm e2e:docker --no-build -- tests/task/task-list.spec.ts tests/task/task-list-filters.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/workflow-filter.spec.ts tests/settings/config-management.spec.ts` + passed: 35 tests. +- `cd apps/web && pnpm e2e:docker --no-build --project mobile-chrome -- tests/kanban/mobile-kanban.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed: 12 tests. diff --git a/docs/plans/tanstack-query-server-state/task-06-office-domain.md b/docs/plans/tanstack-query-server-state/task-06-office-domain.md new file mode 100644 index 0000000000..2723f38413 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-06-office-domain.md @@ -0,0 +1,171 @@ +--- +id: "06-office-domain" +title: "Office domain" +status: done +wave: 3 +depends_on: ["03-query-options-taxonomy", "04-query-bridge-audit"] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 06: Office Domain + +## Acceptance + +- Office dashboard, tasks, projects, agents, inbox, activity, routines, routing, + costs, skills, and run-detail data read from TanStack Query. +- Office tasks list uses `useInfiniteQuery` for keyset pagination and preserves + filters/sort/load-more behavior. +- Office WS events patch/invalidate query keys and no longer rely on + `useOfficeRefetch` once readers are migrated. + +## Verification + +- PASS `cd apps && pnpm --filter @kandev/web test -- app/office hooks/domains/office lib/query` + - 25 files, 124 tests. +- PASS `cd apps/web && pnpm typecheck` +- PASS `cd apps/web && pnpm e2e:docker -- tests/office/realtime-dashboard.spec.ts tests/office/realtime-tasks.spec.ts tests/office/dashboard.spec.ts tests/office/tasks.spec.ts` + - 21 tests. +- PASS `cd apps/web && pnpm e2e:docker -- --project=mobile-chrome tests/office/mobile-onboarding.spec.ts` + - 6 tests. +- PASS `cd apps/web && e2e/scripts/run-e2e.sh --docker --no-build --project routing` + - 7 tests. + - Note: `pnpm e2e:docker -- --project=routing` runs routing first but also leaves + the runner's default chromium project selected. Use the runner-native + `--project routing` flag before `--`. + +## Files Likely Touched + +- `apps/web/src/office-routes.tsx` +- `apps/web/app/office/**/*.tsx` +- `apps/web/hooks/use-office-refetch.ts` +- `apps/web/hooks/domains/office/*` +- `apps/web/lib/query/query-options/office.ts` +- `apps/web/lib/query/bridge/office.ts` +- `apps/web/lib/ws/handlers/office.ts` +- `apps/web/lib/state/slices/office/*` + +## Dependencies + +- Tasks 03 and 04. + +## Inputs + +- Old PR office query/bridge files. +- Existing spec: `docs/specs/office/live-updates.md`. +- Existing current hook: `apps/web/app/office/tasks/use-paginated-tasks.ts`. + +## Output Contract + +Update this task to `done`, list office store fields removed/retained, and call +out any mobile E2E coverage added or reused. + +## Implementation Notes + +- Reopened on 2026-06-26 after a current-state audit found remaining Office + store readers/writers and `useOfficeRefetch` compatibility paths. The routing + sub-wave now reads Query directly, but dashboard, agents, projects, inbox, + meta, routines, costs, skills, and old Office WS fanout still need cleanup + before this task can return to `done`. +- The Office shell/sidebar sub-wave moved the top-level dashboard page, agents + page, app sidebar inbox badge, Office navigation counters, and sidebar + agent/project lists to Query-owned Office caches. Deeper meta/routines/costs/ + skills/project/task helpers and old Office refetch fanout remain. +- The Office inbox/activity bridge sub-wave moved the Inbox page off the + `office.inboxItems` store mirror, removed the Activity page's + `useOfficeRefetch("activity")` subscription, and made + `office.run.queued`/`office.run.processed` invalidate task comments so + comment run-status badges no longer depend on the legacy comments refetch + trigger. +- The Office meta sub-wave moved status/priority/role/executor/project/inbox/ + routine/skill metadata readers to `qk.office.meta()` through + `useOfficeMetaData`, seeds the query cache during Office route bootstrap, and + removed the unused `setMeta` Zustand action. +- The Office bridge parity sub-wave filled remaining invalidation gaps that + blocked removing old refetch triggers: task-linked project counts/details and + agent summaries, task activity for comments/reviews/decisions/runs, agent + summaries/runs/run details, run-driven dashboard data, approval-driven agent + data, and agent route queries for routing events. +- The Office page refetch sub-wave removed `useOfficeRefetch` subscriptions from + Query-backed projects, routines, agent dashboard, agent layout, and agent runs + surfaces. Task list/detail remain for the task-store cleanup wave. +- The Office task list/detail sub-wave moved the task list's fetched rows and + loading state out of `office.tasks.items`/`office.tasks.isLoading`, removed the + task page's SSR-to-store hydration, removed task detail's store fallback, and + dropped the last production `useOfficeRefetch` callers. `usePaginatedTasks` + now returns flattened infinite-query data directly, while task filters/sort/ + grouping/nesting remain in Zustand as client-only UI state. +- The Office task helper/scaffold cleanup moved project task sections, agent run + linked task labels, and simple-pane parent/blocker pickers to Query-backed + task reads. It removed the unused `useOfficeRefetch` hook, legacy Office WS + handler registration/test, `office.refetchTrigger`, and the unused + `office.tasks.items`/loading server-state fields/actions. +- The Office simple-pane reference-data cleanup moved task detail chat/activity/ + session labels, assignee/project/reviewer/approver pickers, pending approval + badges, and run-error labels from `office.agentProfiles`/`office.projects` + store mirrors to active-workspace Office Query caches. +- The final Office store cleanup moved agent detail/routes, project detail and + writes, create-agent/create-project flows, routines, workspace skills, org + chart, new-task reference selectors, Office route bootstrap, and costs boot + data to Office Query caches. Query seed/hydration now treats Office + agents/projects/skills/routines/inbox/dashboard/activity/runs/meta as + query-only boot data, and the Office Zustand slice retains only task UI + controls. +- Migrated office dashboard, tasks, task search, task detail comments/activity, + agents, agent run/detail routes, projects/project tasks, inbox, activity, + routines, routing, costs, budgets, and skills to TanStack Query readers. +- Added office query keys/options for task comments, task activity, task search, + project detail, provider health, routing preview, agent routes, run attempts, + agent summaries/runs/run detail, routines/routine runs/triggers, cost + breakdown, budgets, and skills. +- Added an office WS query bridge that patches task pages/details and provider + health/run attempts where possible, then invalidates the affected query + families for sparse events. +- Removed Office server-state mirrors/actions for `agentProfiles`, `projects`, + `skills`, `routines`, `approvals`, `activity`, `costSummary`, + `budgetPolicies`, `inboxItems`, `inboxCount`, `runs`, `dashboard`, `meta`, + `isLoading`, `routing`, `providerHealth`, `runAttempts`, and + `agentRouting`. +- Retained `office.tasks.filters`, `viewMode`, `sortField`, `sortDir`, `groupBy`, + `nestingEnabled`, and the corresponding task UI actions as client-only UI + state. Dialog and edit state remains local to the owning components. +- Removed the legacy `useOfficeRefetch` hook definition, old Office WS fanout, + Office refetch trigger state, and unused Office server-state mirrors. +- Reused existing mobile coverage through `tests/office/mobile-onboarding.spec.ts` + in Docker. + +## Reopened Wave Evidence + +- Task list/detail cleanup: + - `rtk pnpm --dir apps/web test app/office/tasks/use-paginated-tasks.test.tsx hooks/use-optimistic-task-mutation.test.tsx lib/query/bridge/index.test.ts` + passed 3 files / 26 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web lint` passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/tasks.spec.ts tests/office/realtime-tasks.spec.ts tests/office/task-filters.spec.ts tests/office/task-sorting.spec.ts tests/office/topbar-breadcrumb.spec.ts tests/office/comment-input.spec.ts tests/office/simple-advanced-toggle.spec.ts tests/office/regression-fixes.spec.ts tests/office/property-pickers.spec.ts` + passed 36 Docker tests with strict WS accounting. +- Task helper/scaffold cleanup: + - `rtk pnpm --dir apps/web test components/task/simple/components/blockers-picker.test.tsx hooks/use-optimistic-task-mutation.test.tsx app/office/tasks/use-paginated-tasks.test.tsx lib/query/bridge/index.test.ts lib/ws/router.test.ts lib/ws/handlers/agent-session.test.ts components/state-hydrator.test.tsx` + passed 7 files / 52 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web lint` passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/tasks.spec.ts tests/office/realtime-tasks.spec.ts tests/office/task-filters.spec.ts tests/office/task-sorting.spec.ts tests/office/topbar-breadcrumb.spec.ts tests/office/comment-input.spec.ts tests/office/simple-advanced-toggle.spec.ts tests/office/regression-fixes.spec.ts tests/office/property-pickers.spec.ts tests/office/projects.spec.ts tests/office/agent-run-detail.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 43 Docker tests / 1 skipped with strict WS accounting. +- Simple-pane reference-data cleanup: + - `rtk pnpm --dir apps/web test components/task/simple/components/agents-multi-picker.test.tsx components/task/simple/components/blockers-picker.test.tsx components/task/simple/components/status-picker.test.tsx components/task/simple/components/priority-picker.test.tsx components/task/simple/components/approval-action-bar.test.tsx` + passed 5 files / 17 tests / 4 skipped. + - `rtk rg -n "office\\.agentProfiles|office\\.projects|office\\.routines|office\\.skills|setOfficeAgentProfiles|setProjects|setRoutines|setSkills" apps/web/components/task/simple --glob '!dist/**' --glob '!**/*.test.*'` + returned no matches. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web lint` passed. + - `rtk pnpm --dir apps/web e2e:docker tests/office/property-pickers.spec.ts tests/office/comment-input.spec.ts tests/office/simple-advanced-toggle.spec.ts tests/office/regression-fixes.spec.ts tests/office/tasks.spec.ts tests/office/realtime-tasks.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 30 Docker tests with strict WS accounting. +- Final Office store cleanup: + - `rtk pnpm --dir apps/web test hooks/domains/office/use-office-data.test.tsx app/office/agents/[id]/components/agent-configuration-tab.test.tsx app/office/agents/[id]/components/agent-runs-tab.test.tsx app/office/components/new-task-dialog.test.tsx app/office/workspace/org/org-tree-layout.test.ts app/office/page-client.test.tsx lib/query/seed.test.ts components/state-hydrator.test.tsx lib/query/bridge/index.test.ts components/task/simple/components/pending-approval-badge.test.tsx` + passed 10 files / 68 tests. + - `rtk pnpm --dir apps/web typecheck` passed. + - `rtk pnpm --dir apps/web lint` passed. + - Stale scans for removed Office store fields/actions returned no production + server-state readers/writers; remaining `office.tasks.*` matches are + client-only task filter/sort/view/grouping/nesting state. + - `rtk pnpm --dir apps/web e2e:docker tests/office/agents.spec.ts tests/office/agent-subroutes.spec.ts tests/office/agent-roles.spec.ts tests/office/agent-skills-ui.spec.ts tests/office/permissions.spec.ts tests/office/projects.spec.ts tests/office/project-repository-picker.spec.ts tests/office/routines.spec.ts tests/office/routines-ui.spec.ts tests/office/routine-fire.spec.ts tests/office/skills.spec.ts tests/office/system-skills.spec.ts tests/office/skills-readonly.spec.ts tests/office/org-chart.spec.ts tests/office/execution-stages.spec.ts tests/office/costs.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 67 Docker tests / 5 skipped with strict WS accounting. diff --git a/docs/plans/tanstack-query-server-state/task-07-session-domain.md b/docs/plans/tanstack-query-server-state/task-07-session-domain.md new file mode 100644 index 0000000000..429c222c5d --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-07-session-domain.md @@ -0,0 +1,87 @@ +--- +id: "07-session-domain" +title: "Session domain" +status: done +wave: 3 +depends_on: ["03-query-options-taxonomy", "04-query-bridge-audit"] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 07: Session Domain + +## Acceptance + +- Task sessions, by-id session lookup, messages, turns, task plans/revisions, + and message queue data read from TanStack Query. +- Chat message lists on desktop and mobile read the same query key that + `session.message.*` bridge handlers update. +- Existing missed-message E2E recovery tests remain green without relying on + manual reload. + +## Verification + +- PASS `cd apps && pnpm --filter @kandev/web test -- lib/query/keys.test.ts lib/query/seed.test.ts lib/query/query-options/query-options.test.ts lib/query/bridge/index.test.ts hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-session-search.test.ts hooks/domains/session/use-session-state.test.ts hooks/domains/session/use-session-actions.test.ts hooks/domains/session/use-ensure-task-session.test.ts components/task/chat/message-list-shared.test.tsx components/task/chat/queued-ghost-list.test.tsx hooks/use-plan-panel-auto-open.test.ts hooks/use-task-removal.test.ts components/task/passthrough-chat-composer.test.ts` + - 14 files, 148 tests. +- PASS `cd apps && pnpm --filter @kandev/web test -- hooks/domains/session components/task/chat lib/query` + - 58 files, 451 tests. +- PASS `cd apps/web && pnpm typecheck` +- PASS direct API-read scan for migrated session/task-plan/queue reads: + `rg -n "fetchTaskSession\\(|listTaskSessions\\(|listTaskSessionMessages\\(|listSessionTurns\\(|searchSessionMessages\\(|getTaskPlan\\(|listPlanRevisions\\(|getPlanRevision\\(|getQueueStatus\\(" apps/web/hooks apps/web/components apps/web/app apps/web/lib/query` + - Only query-option factories and the server boot loader remain. +- PASS `cd apps/web && pnpm e2e:docker tests/chat/message-add-ws-gap.spec.ts tests/chat/message-pagination.spec.ts tests/session/session-tab-management.spec.ts tests/session/session-recovery.spec.ts tests/chat/message-queue.spec.ts tests/task/plan-checkpointing.spec.ts tests/chat/implement-plan-fresh.spec.ts tests/system/ws-event-accounting.spec.ts` + - 35 desktop Docker tests. +- PASS `cd apps/web && e2e/scripts/run-e2e.sh --docker --no-build --project mobile-chrome -- tests/chat/mobile-message-add-ws-gap.spec.ts tests/session/mobile-transient-retry.spec.ts` + - 2 mobile Docker tests. + +## Files Likely Touched + +- `apps/web/hooks/domains/session/*` +- `apps/web/components/task/chat/*` +- `apps/web/components/task/mobile/*` +- `apps/web/lib/query/query-options/session.ts` +- `apps/web/lib/query/bridge/session.ts` +- `apps/web/lib/query/bridge/session-state.ts` +- `apps/web/lib/ws/handlers/{messages,turns,task-plans,agent-session}.ts` +- `apps/web/lib/state/slices/session/*` + +## Dependencies + +- Tasks 03 and 04. + +## Inputs + +- Old PR `query-options/session.ts`, `bridge/session.ts`, + `bridge/session-state.ts`. +- Current gap tests under `apps/web/e2e/tests/chat/*message-add-ws-gap.spec.ts`. + +## Output Contract + +Update this task to `done`, summarize chat/message migration, and list any +session UI state intentionally left in Zustand. + +## Implementation Notes + +- Migrated task session lists, session-by-id lookups, latest/page message reads, + turns, active turn state, queue status, task plan detail, plan revisions, and + revision content to TanStack Query readers. +- Added session query keys/options for task plans, plan revisions, queue status, + session message pages with `before`/`after` cursors, and turns with + query-owned `activeTurnId`. +- Added session bridge handlers for `session.message.*`, `session.turn.*`, + `session.state_changed`, `message.queue.status_changed`, and `task.plan.*` + events so the cache keys mounted UI reads are patched or invalidated directly. +- Removed durable session/chat/plan/queue events from the bridge audit skip list; + control-plane request/response events remain intentionally skipped. +- Moved chat/session hooks and plan-preview/composer helpers away from direct + domain API reads. Explicit recovery/backfill paths still force fresh query + fetches with `staleTime: 0` where stale cached snapshots would hide missed + messages. +- Tightened the shared E2E `SessionPage` send helper to wait for TipTap + `contenteditable="true"` before filling. The idle placeholder can render just + before the editor editable effect commits on mobile, and the Docker mobile + rerun passed after that readiness wait. +- Retained Zustand for client-only session UI and compatibility mirrors until + Task 10: active task/session ids, chat drafts/input/context state, + layout/panel state, and mirrored task session/message/turn/task plan/queue + server snapshots for older readers that are removed later in the one-shot PR. diff --git a/docs/plans/tanstack-query-server-state/task-08-session-runtime-streams.md b/docs/plans/tanstack-query-server-state/task-08-session-runtime-streams.md new file mode 100644 index 0000000000..5f5f261513 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-08-session-runtime-streams.md @@ -0,0 +1,102 @@ +--- +id: "08-session-runtime-streams" +title: "Session runtime streams" +status: done +wave: 3 +depends_on: ["03-query-options-taxonomy", "04-query-bridge-audit"] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 08: Session Runtime Streams + +## Acceptance + +- Git status, commits, prepare progress, context window, available commands, + session mode, models, capabilities, prompt usage, todos, and agentctl status + use TanStack Query where they are server-owned. +- High-frequency shell/process/terminal output uses bounded stream buffers or + retained terminal transport state, not per-chunk query cache writes. +- Bridge skipped actions document every stream/control-plane exception. + +## Verification + +- `cd apps/web && rtk pnpm typecheck` passed. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/session components/session components/task lib/query` + passed 153 files / 1264 tests / 4 skipped. +- `cd apps/web && rtk pnpm e2e:docker tests/terminal/terminal-hanging-on-boot.spec.ts tests/terminal/terminal-dockview-ui.spec.ts tests/git/git-changes-panel.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 30 desktop Docker tests. +- `cd apps/web && rtk e2e/scripts/run-e2e.sh --docker --no-build --project mobile-chrome -- tests/terminal/mobile-terminal-keybar.spec.ts tests/terminal/mobile-terminal-scroll.spec.ts` + passed 16 mobile Docker tests. + +No failure artifacts were produced. + +## Files Likely Touched + +- `apps/web/hooks/domains/session/use-session-git*.ts` +- `apps/web/hooks/domains/session/use-terminals*.ts` +- `apps/web/hooks/domains/session/use-prepare-summary.ts` +- `apps/web/lib/query/query-options/session-runtime.ts` +- `apps/web/lib/query/bridge/session-runtime.ts` +- `apps/web/lib/query/bridge/session-runtime-streams.ts` +- `apps/web/lib/query/streams/ring.ts` +- `apps/web/lib/ws/handlers/{git-status,executor-prepare,executors,executor-profiles,session-mode,session-poll-mode,session-models,agent-capabilities,prompt-usage,session-todos,terminals}.ts` +- `apps/web/lib/state/slices/session-runtime/*` + +## Dependencies + +- Tasks 03 and 04. + +## Inputs + +- Old PR session-runtime query/bridge/stream files. +- Current terminal/mobile tests and stream performance constraints. + +## Output Contract + +Done. + +## Implementation Notes + +Server-owned runtime state now has runtime query keys/options, initial query +seeding, and WS bridge coverage for: + +- git status and commit snapshots +- prepare progress +- session context-window metadata +- available commands +- session mode and poll mode +- agent capabilities +- models and current model metadata +- prompt usage +- session todos +- agentctl readiness/error state +- low-frequency process status + +The migrated readers use TanStack Query first and keep Zustand as a compatibility +mirror until Task 10 removes legacy server-state slices. + +## Query Exceptions + +The following streams intentionally stay out of TanStack Query: + +- `terminal.output`: terminal renderer/buffer transport, high-volume per-chunk + stream. +- `session.shell.output`: bounded shell-output buffer, high-volume per-chunk + stream. +- `session.process.output`: bounded process-output buffer, high-volume per-chunk + stream. + +The following request/control-plane actions remain outside query-cache mutation: + +- `session.git.commits`, `session.commit_diff`, and `session.cumulative_diff` + are explicit request/response fetch paths. +- `user_shell.*`, terminal input/subscription actions, and shell/process + subscribe/input actions drive terminal lifecycle or transport state. +- `session.subscribe`, `session.unsubscribe`, `session.focus`, agent request + actions, queue request actions, task/run subscribe actions, and permission/input + effects are not durable server-state notifications. + +Retained Zustand/client-only paths are limited to compatibility mirrors, +aggregate sidebar reads that Task 10 will remove, local terminal UI buffers, and +local transport/lifecycle state. diff --git a/docs/plans/tanstack-query-server-state/task-09-integrations-automations-system.md b/docs/plans/tanstack-query-server-state/task-09-integrations-automations-system.md new file mode 100644 index 0000000000..f8aaa77079 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-09-integrations-automations-system.md @@ -0,0 +1,98 @@ +--- +id: "09-integrations-automations-system" +title: "Integrations automations system" +status: done +wave: 3 +depends_on: ["03-query-options-taxonomy", "04-query-bridge-audit"] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 09: Integrations Automations System + +## Acceptance + +- GitHub, GitLab, Jira, Linear, Slack/Sentry where present, automations, and + system settings/status data read from TanStack Query. +- Auth health poller cadence remains 90 seconds where currently expected. +- Integration/import/watch flows preserve optimistic or post-mutation cache + updates and existing toasts. + +## Verification + +- `cd apps && pnpm --filter @kandev/web test -- apps/web/components/github apps/web/components/gitlab apps/web/components/jira apps/web/components/linear apps/web/components/automations apps/web/components/settings/system apps/web/lib/query` +- `cd apps/web && pnpm e2e:docker tests/github tests/integrations tests/system/status-page.spec.ts tests/system/database-page.spec.ts` +- `cd apps/web && pnpm e2e:docker --project mobile-chrome tests/integrations/mobile-linear-watcher-profile.spec.ts tests/github/mobile-github-sidebar.spec.ts` + +## Files Likely Touched + +- `apps/web/components/github/**` +- `apps/web/components/gitlab/**` +- `apps/web/components/jira/**` +- `apps/web/components/linear/**` +- `apps/web/components/automations/**` +- `apps/web/components/settings/system/**` +- `apps/web/lib/query/query-options/{github,gitlab,jira,linear,integrations,automations,system}.ts` +- `apps/web/lib/query/bridge/{github,gitlab,jira,linear,integrations,automations}.ts` +- old handlers under `apps/web/lib/ws/handlers/{github,secrets,system-events}.ts` +- relevant state slices under `apps/web/lib/state/slices/*` + +## Dependencies + +- Tasks 03 and 04. + +## Inputs + +- Old PR query option/bridge files for integrations and automations. +- Current integration specs and `apps/backend/internal/integrations/AGENTS.md` + if integration contracts are touched. + +## Output Contract + +Update this task to `done`, list migrated domains, and identify any integration +state intentionally left local-only. + +## Completed + +- Added query keys/options and query-backed hooks/components for: + - GitHub status, workspace PRs, task PRs, task CI options, PR watches, + review watches, issue watches, action presets, and rate-limit bridge + updates. + - GitLab status, stats, workspace MRs, task MRs, review watches, issue + watches, action presets, and projects. + - Jira config availability, settings config, issue watches, and app-page + configured-state checks. + - Linear config availability, settings config, issue watches, app-page + configured-state checks, and watcher agent profile flows. + - Slack and Sentry config availability/settings config; Sentry issue watches. + - Automations list/runs and mutation cache updates. + - System health, jobs, metrics, runtime flags, backups, logs, database, disk, + updates, secrets, and sprites status/instances where this wave touched + settings/system surfaces. +- Preserved the 90s auth-health poll cadence through the shared integration + availability hook. +- Kept local-only state local: + - form drafts and inline test-connection results, + - per-browser integration enabled toggles backed by localStorage, + - control-plane mutations/triggers/reset-preview calls, + - high-volume stream/readiness state that is intentionally not server cache. +- Tightened strict E2E WS accounting after the Docker gate found a reload race: + the helper now distinguishes a missing browser hook from an installed hook + with no post-reload frames, and the UI-state reset test waits for the app to + render after reload. + +## Verification Completed + +- `cd apps/web && rtk pnpm typecheck` passed. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/settings hooks/domains/system hooks/domains/github hooks/domains/gitlab hooks/domains/jira hooks/domains/linear hooks/domains/sentry hooks/domains/slack hooks/domains/integrations components/github components/gitlab components/jira components/linear components/slack components/sentry components/automations components/settings/system lib/query` + passed 55 files / 487 tests. +- `cd apps && rtk pnpm --filter @kandev/web test -- lib/ws/ws-account-e2e-helper.test.ts lib/ws/ws-account.test.ts lib/ws/client.test.ts e2e/helpers` + passed 3 files / 15 tests. +- `cd apps/web && rtk pnpm exec eslint --max-warnings 0 app/jira/jira-page-client.tsx app/linear/linear-page-client.tsx` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/system/status-page.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 5 desktop Docker tests after the WS-accounting reload fix. +- `cd apps/web && rtk pnpm e2e:docker tests/system/ws-event-accounting.spec.ts tests/integrations/jira-settings.spec.ts tests/integrations/linear-settings.spec.ts tests/integrations/sentry-settings.spec.ts tests/integrations/github-watch-reset.spec.ts tests/integrations/jira-import.spec.ts tests/integrations/linear-import.spec.ts tests/github/pr-list-task-indicator.spec.ts tests/github/github-scope-bar.spec.ts tests/pr/ci-automation-options.spec.ts tests/automations-settings.spec.ts tests/system/status-page.spec.ts tests/system/database-page.spec.ts tests/system/disk-usage.spec.ts tests/system/backups-page.spec.ts tests/system/updates-page.spec.ts tests/system/logs-page.spec.ts` + passed 60 desktop Docker tests / 1 skipped. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/integrations/mobile-linear-watcher-profile.spec.ts tests/github/mobile-github-sidebar.spec.ts tests/settings/mobile-general-settings.spec.ts tests/mobile-automations-scroll.spec.ts` + passed 4 mobile Docker tests. diff --git a/docs/plans/tanstack-query-server-state/task-10-remove-zustand-server-state.md b/docs/plans/tanstack-query-server-state/task-10-remove-zustand-server-state.md new file mode 100644 index 0000000000..a5e1a2a095 --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-10-remove-zustand-server-state.md @@ -0,0 +1,1114 @@ +--- +id: "10-remove-zustand-server-state" +title: "Remove Zustand server state" +status: done +wave: 4 +depends_on: + [ + "05-workspace-kanban-settings", + "06-office-domain", + "07-session-domain", + "08-session-runtime-streams", + "09-integrations-automations-system", + ] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 10: Remove Zustand Server State + +## Acceptance + +- Old server-state Zustand fields/actions and WS handlers are removed after + their readers have migrated. +- Remaining Zustand fields are documented as client-only or temporary indexes. +- `apps/web/AGENTS.md` accurately describes the new data flow. + +## Progress + +Completed cleanup sub-waves: + +- **Office routing cleanup:** moved provider health, routing preview, + workspace routing, agent route, and run attempts hook reads to TanStack Query + only. `AgentCard` now receives routing data from query-backed page hooks + instead of reading `office.routing.*` mirrors. Verified with focused hook + tests, typecheck/lint, full verify, and the Docker `routing` project gate. +- **Office shell/sidebar cleanup:** moved the app sidebar primary inbox badge, + Office navigation counters, sidebar agent/project sections, Office dashboard + page, and Office agents page off `office.dashboard`, `office.inbox*`, + `office.agentProfiles`, and `office.projects` store mirrors. These paths now + read `qk.office.dashboard`, `qk.office.inbox`, `qk.office.agents`, and + `qk.office.projects`; active workspace/sidebar/session state remains in + Zustand. +- **Office inbox/activity bridge cleanup:** moved the Inbox page off + `office.inboxItems`, removed the Activity page's old `useOfficeRefetch` + subscription, and added Query bridge invalidation for task comments on + `office.run.queued` and `office.run.processed` so comment run-status badges + are covered without the legacy comments trigger. +- **Office meta cleanup:** moved all status/priority/role/executor/project/ + inbox/routine/skill metadata readers to `qk.office.meta()` through + `useOfficeMetaData`, seeded the meta query during Office route bootstrap, and + removed the unused `setMeta` Zustand action. The temporary `office.meta` field + remains only as part of the broader Office state shape/query seed scaffold + until the final Office slice removal wave. +- **Office bridge parity cleanup:** filled Query bridge invalidation gaps for + task-linked project counts/details, task activity, agent summaries, agent run + lists/details, run-driven dashboard data, approval-driven agent data, and + agent route/routing updates. This unblocks later removal of page-level + `useOfficeRefetch` subscriptions that were covering those surfaces. +- **Office page refetch cleanup:** removed old `useOfficeRefetch` + subscriptions from Query-backed projects, routines, agent dashboard, agent + layout, and agent runs surfaces. Remaining trigger consumers are confined to + the task list/detail path until the task-store wave removes those store + mirrors. +- **Office task list/detail cleanup:** moved task list rows/loading to the + `usePaginatedTasks` infinite-query result, removed the task page's SSR + `setTasks` hydration, removed task detail's `office.tasks.items` fallback, + and made optimistic task mutations local-only. The last production + `useOfficeRefetch` callers were removed in preparation for deleting the old + scaffold. Verified by + `rtk pnpm --dir apps/web test app/office/tasks/use-paginated-tasks.test.tsx hooks/use-optimistic-task-mutation.test.tsx lib/query/bridge/index.test.ts`, + `rtk pnpm --dir apps/web typecheck`, `rtk pnpm --dir apps/web lint`, and + `rtk pnpm --dir apps/web e2e:docker tests/office/tasks.spec.ts tests/office/realtime-tasks.spec.ts tests/office/task-filters.spec.ts tests/office/task-sorting.spec.ts tests/office/topbar-breadcrumb.spec.ts tests/office/comment-input.spec.ts tests/office/simple-advanced-toggle.spec.ts tests/office/regression-fixes.spec.ts tests/office/property-pickers.spec.ts` + passing 36 Docker tests with strict WS accounting. +- **Office task helper/scaffold cleanup:** moved project task sections, agent run + linked task labels, and simple-pane parent/blocker task candidates to + Query-backed reads. Removed the unused `useOfficeRefetch` hook, legacy Office + WS handler registration/test, `office.refetchTrigger`, and the unused + `office.tasks.items`/loading fields/actions from the Office slice. Verified by + `rtk pnpm --dir apps/web test components/task/simple/components/blockers-picker.test.tsx hooks/use-optimistic-task-mutation.test.tsx app/office/tasks/use-paginated-tasks.test.tsx lib/query/bridge/index.test.ts lib/ws/router.test.ts lib/ws/handlers/agent-session.test.ts components/state-hydrator.test.tsx`, + `rtk pnpm --dir apps/web typecheck`, `rtk pnpm --dir apps/web lint`, and + `rtk pnpm --dir apps/web e2e:docker tests/office/tasks.spec.ts tests/office/realtime-tasks.spec.ts tests/office/task-filters.spec.ts tests/office/task-sorting.spec.ts tests/office/topbar-breadcrumb.spec.ts tests/office/comment-input.spec.ts tests/office/simple-advanced-toggle.spec.ts tests/office/regression-fixes.spec.ts tests/office/property-pickers.spec.ts tests/office/projects.spec.ts tests/office/agent-run-detail.spec.ts tests/system/ws-event-accounting.spec.ts` + passing 43 Docker tests / 1 skipped with strict WS accounting. +- **Office simple-pane reference-data cleanup:** moved task detail chat/activity/ + session labels, assignee/project/reviewer/approver pickers, pending approval + badges, and run-error labels from `office.agentProfiles`/`office.projects` + store mirrors to active-workspace Office Query caches. Verified by + `rtk pnpm --dir apps/web test components/task/simple/components/agents-multi-picker.test.tsx components/task/simple/components/blockers-picker.test.tsx components/task/simple/components/status-picker.test.tsx components/task/simple/components/priority-picker.test.tsx components/task/simple/components/approval-action-bar.test.tsx`, + `rtk rg -n "office\\.agentProfiles|office\\.projects|office\\.routines|office\\.skills|setOfficeAgentProfiles|setProjects|setRoutines|setSkills" apps/web/components/task/simple --glob '!dist/**' --glob '!**/*.test.*'`, + `rtk pnpm --dir apps/web typecheck`, `rtk pnpm --dir apps/web lint`, and + `rtk pnpm --dir apps/web e2e:docker tests/office/property-pickers.spec.ts tests/office/comment-input.spec.ts tests/office/simple-advanced-toggle.spec.ts tests/office/regression-fixes.spec.ts tests/office/tasks.spec.ts tests/office/realtime-tasks.spec.ts tests/system/ws-event-accounting.spec.ts` + passing 30 Docker tests with strict WS accounting. +- **Final Office store cleanup:** moved agent detail/routes, project detail and + writes, create-agent/create-project flows, routines, workspace skills, org + chart, new-task reference selectors, Office route bootstrap, and costs boot + data to Office Query caches. Removed Office server-state mirrors/actions for + agents, projects, skills, routines, inbox, dashboard, activity, runs, meta, + routing, provider health, run attempts, approvals, budgets, and costs; the + Office Zustand slice now retains only task filter/sort/view/grouping/nesting + UI state. Verified by + `rtk pnpm --dir apps/web test hooks/domains/office/use-office-data.test.tsx app/office/agents/[id]/components/agent-configuration-tab.test.tsx app/office/agents/[id]/components/agent-runs-tab.test.tsx app/office/components/new-task-dialog.test.tsx app/office/workspace/org/org-tree-layout.test.ts app/office/page-client.test.tsx lib/query/seed.test.ts components/state-hydrator.test.tsx lib/query/bridge/index.test.ts components/task/simple/components/pending-approval-badge.test.tsx`, + `rtk pnpm --dir apps/web typecheck`, `rtk pnpm --dir apps/web lint`, + stale scans for removed Office store fields/actions, and + `rtk pnpm --dir apps/web e2e:docker tests/office/agents.spec.ts tests/office/agent-subroutes.spec.ts tests/office/agent-roles.spec.ts tests/office/agent-skills-ui.spec.ts tests/office/permissions.spec.ts tests/office/projects.spec.ts tests/office/project-repository-picker.spec.ts tests/office/routines.spec.ts tests/office/routines-ui.spec.ts tests/office/routine-fire.spec.ts tests/office/skills.spec.ts tests/office/system-skills.spec.ts tests/office/skills-readonly.spec.ts tests/office/org-chart.spec.ts tests/office/execution-stages.spec.ts tests/office/costs.spec.ts tests/system/ws-event-accounting.spec.ts` + passing 67 Docker tests / 5 skipped with strict WS accounting. + +- **System:** removed the system Zustand slice and `system-events` WS handler. + System hooks and topbar metrics now read Query caches/options directly. The + local `systemHealth` UI slice remains. +- **GitHub:** removed server-backed GitHub Zustand fields and the old GitHub WS + handler. Query now owns status, task/workspace PRs, task CI options, watches, + action presets, and rate-limit bridge updates. Local-only + `pendingPrUrlByTaskId` and `prFeedbackCache` remain in Zustand. +- **GitLab:** removed the GitLab Zustand slice entirely. Status, stats, + workspace/task MRs, review watches, issue watches, and action presets now read + and mutate TanStack Query caches directly. `/gitlab` and GitLab settings use + `useGitLabStatus` instead of direct status fetch effects. +- **Settings leaf lists:** converted prompts, secrets, sprites, + notification providers, available agents, agent discovery, and editors hooks + to Query-only readers. Prompts, secrets, editor mutations, agent refreshes, + profile status panels, and prompt previews now patch/read Query caches instead + of these Zustand mirrors. +- **Settings catalog/bootstrap:** converted executors, settings agents, derived + agent profiles, install jobs, settings route bootstrap, settings layout, and + session boot preload to Query seed/query reads. Settings agent and executor + write paths now patch `qk.settings.*` caches directly through small sync + helpers. Removed the old `agents`, `executors`, and `executor-profiles` WS + handlers after their store readers/writers were gone. The settings Zustand + slice now contains only `userSettings`. +- **Workspace/kanban direct-fetch cleanup:** added a workflow-step Query key and + query option, moved the shared `useWorkflowSteps` hook, task-create workflow + step effect, and automations config workflow-step picker to Query, and made + `workflow.step.*` bridge events invalidate the workflow-step cache. Removed + unused workspace metadata maps (`repositories` loaded/loading flags, + repository branch loaded/loading/fetchedAt/fetchError flags, and repository + script loaded/loading flags) from the Zustand slice, boot payloads, SSR + session state, and old writer actions. The actual workspace, workflow, + `kanban`, and `kanbanMulti` entity mirrors remain + because mounted UI readers still use them as migration fallbacks. The mobile + task switcher workspace-change path now fetches workflows and the first + workflow snapshot through Query options instead of direct API imports. The + task-detail route's active-task switch path now reads `taskQueryOptions` + instead of running a component-owned `fetchTask` effect. Command-panel task + search now reads `workspaceTasksQueryOptions` instead of importing + `listTasksByWorkspace` directly, preserving active-task filtering and + archived-last search ordering. Archive/delete dialog subtask counts now + fetch through `queryClient.fetchQuery(subtaskCountQueryOptions(...))` while + keeping the existing no-stale-count-on-reopen behavior. +- **Session dead-code cleanup:** removed production-unused `clearTaskPlan` and + `clearQueueStatus` Zustand actions and their action-only tests, and removed + the legacy no-op `task.plan.reverted` registration from the old `task-plans` + WS handler. The Query bridge remains the owner for `task.plan.reverted`. + Session/chat/plan/queue mirrors remain because mounted readers still use + them. +- **Queue mirror cleanup:** moved `useQueue` to read/write + `qk.session.queue` directly, with Query-cache optimistic removal and + mutation-local loading state. Removed the old queue Zustand state/actions, + default-state/root-store declarations, action-only queue tests, and the + duplicate `message.queue.status_changed` registration from the old + `agent-session` WS handler. Queue status WS updates now flow through the + Query bridge only. +- **Plan context cleanup:** moved the `@Plan` context preview and passthrough + composer plan expansion to Query-only reads. `LazyPlanPreview` no longer + requires `StateProvider` or mirrors fetched plans into `taskPlans`; passthrough + message composition reads `taskPlanQueryOptions` cache before fetching and no + longer consults `state.taskPlans` for plan context content. The main plan + panel hook and old `task-plans` WS handler remain because they still own + plan editing/seen-state behavior. +- **Session todos/prompt usage cleanup:** moved `useSessionTodoItems` to the + `qk.sessionRuntime.todos` Query cache only and removed the old + `session.todos_updated` and `session.prompt_usage` Zustand WS handlers, + state fields, mutators, default/root-store declarations, and boot seed paths. + Todo and prompt usage WS updates now flow through the Query bridge only. +- **Agent capabilities/poll mode cleanup:** moved the task-list debug poll badge + to the `qk.sessionRuntime.pollMode` Query cache only and removed the unused + auth-methods indicator plus its orphaned frontend authenticate helper. Removed + the old `session.agent_capabilities` and `session.poll_mode_changed` Zustand + WS handlers, state fields, mutators, default/root-store declarations, and boot + seed paths. These runtime WS updates now flow through the Query bridge only. +- **Improve Kandev/workflow cleanup:** moved the Improve Kandev bootstrap + follow-up fetches for workflow steps and workspace repositories through the + shared Query option factories, while keeping the temporary repository store + sync for existing task-create readers. Removed the old `workflow.*` and + `workflow.step.*` Zustand WS handler registration and deleted the legacy + workflow handler/test file; workflow cache invalidation now flows through the + Query bridge only. `kanban.update` and `task.*` legacy handlers remain because + mounted kanban/task readers and task-delete cleanup side effects still depend + on them. +- **Session runtime dead-plumbing cleanup:** removed production-unused + `pendingModel`, `sessionRuntime.agents`, and the legacy `terminal.output` + buffer/action/WS branch from Zustand and store hydration/re-export plumbing. + The active `session.shell.output`, `session.process.output`, and + `session.process.status` terminal flows remain. Backend protocol types and + bridge audit skip metadata for `terminal.output` remain because the protocol + still defines that stream event, but it no longer writes frontend store state. +- **Session mode cleanup:** moved the session mode selector to Query-only live + mode data, retaining its existing session snapshot/profile fallback before any + live mode event arrives. Removed the old `sessionMode` Zustand state/actions, + boot seed/store plumbing, and legacy `session.mode_changed` WS handler. + Session mode changes now update `qk.sessionRuntime.mode` through the Query + bridge only; the `setSessionMode` API remains the user action for changing + mode. +- **Repository scripts cleanup:** moved `useRepositoryScripts` to Query-only + data and removed the old `repositoryScripts` Zustand state/actions, + AppState/default-state plumbing, and store re-exports. SSR task data still + carries repository scripts as a query-seed-only boot shape, and settings + repository script saves now update `qk.workspaces.repositoryScripts(repoId)` + directly instead of clearing a store mirror. +- **Task-plan revision cleanup:** moved task-plan revision list/content reads to + TanStack Query only and removed the old `revisionsByTaskId`, + `revisionsLoadingByTaskId`, `revisionsLoadedByTaskId`, + `revisionContentCache`, and revision mutator actions from the session slice. + The legacy `task.plan.revision.created` WS handler was removed; the Query + bridge patches the revisions list and invalidates the individual revision + detail cache. The main task-plan mirror remains for plan editing, + last-seen/indicator, and layout auto-open behavior. +- **Available commands cleanup:** moved inline slash commands, TipTap slash + suggestions, chat-panel agent command data, and empty-turn command + recognition to Query-only reads. Removed the old `availableCommands` + Zustand state/actions and legacy `session.available_commands` WS handler. + E2E command seeding now writes directly to + `qk.sessionRuntime.availableCommands(sessionId)`. Empty-turn local notices + are preserved across API refetch snapshots so Query invalidation cannot drop + the synthetic hint message. The mobile E2E page object now scopes chat editor + and send-button locators to the visible `session-chat` panel instead of + relying on page-wide TipTap DOM order. +- **Repository branches cleanup:** moved `useBranches` to Query-only reads and + removed the old `repositoryBranches` Zustand state/action, root-store/default + state declarations, hydration, overrides, and re-export plumbing. Row-level + branch lists now cold-load through the workspace branch query so + provider-backed URL repositories still list branches when re-picked from the + workspace dropdown. Manual refresh forces the repository + `?refresh=true` endpoint and copies the result into the active workspace + branch cache. Stale task-create comments and `apps/web/AGENTS.md` were + updated so the docs no longer describe a removed branch store fallback. +- **Workspace repositories cleanup:** moved workspace repository list reads to + TanStack Query cache only and removed the old `repositories` Zustand + state/action, root-store/default-state declarations, hydration, overrides, + and re-export plumbing. Boot/route repository lists are now query-seed-only + data. Shared repository lookup hooks read all workspace repository query + caches, and repository-heavy UI surfaces (task-create, mobile task switcher, + recent switcher, kanban cards, changes/review panels, quick chat, dockview + repository scripts, and sidebar filters) now read Query cache instead of the + workspace slice. +- **Workflow list cleanup:** moved workspace workflow list reads to TanStack + Query cache only and removed `workflows.items`, `setWorkflows`, and + `reorderWorkflowItems` from the kanban slice/root store. Boot and route + workflow lists now seed `qk.workflows.all(workspaceId, { includeHidden: +true })` through `workflowLists.itemsByWorkspaceId`, while + `workflows.activeId` remains in Zustand as UI/navigation state. Kanban + workflow selection, move targets, swimlane ordering, task-create workflow + resolution, settings workflow editing, task mentions, integration watch + dialogs, recent/mobile task switchers, and route hydration now read workflow + lists from Query cache. Swimlane workflow drag sorting now optimistically + reorders workflow query caches before calling the backend reorder API. +- **All-workflow snapshot/loading cleanup:** moved all-workflow board/sidebar + snapshot reads and loading state to workflow snapshot Query caches. The + Kanban board now passes Query-owned snapshots to swimlanes, multi-select + logic, and the bulk toolbar; `useWorkspaceSidebarTasks` consumes Query + snapshots without the old active single-workflow store fallback. Removed the + old `kanbanMulti.isLoading`, `setKanbanMultiLoading`, `updateMultiTask`, and + `removeMultiTask` store surface. `kanbanMulti.snapshots`, + `setWorkflowSnapshot`, and `clearKanbanMulti` remain temporary write-through + compatibility for legacy direct readers and WS handlers outside this + sub-slice. +- **Task detail lookup cleanup:** moved `useTask` and `useTaskById` to + Query-only task-detail reads and removed their active `kanban.tasks` / + `kanbanMulti.snapshots` fallback. Sender task badges now resolve live titles + from `qk.tasks.detail(taskId)` and retain the snapshotted metadata fallback + when no task detail is cached/fetched. +- **Task removal cleanup:** moved `useTaskRemoval` removal/switch source data + to workflow snapshot Query caches and introduced a shared + `workflow-snapshot-cache` helper used by both task removal and multi-select + bulk operations. `useTaskRemoval` no longer reads `kanbanMulti.snapshots` or + calls `setWorkflowSnapshot`; it keeps only a temporary active `kanban.tasks` + cleanup write for remaining single-workflow mirror readers. Last-task removal + now redirects home through the SPA router instead of `window.location.href`, + preserving strict WS accounting hooks during E2E teardown. +- **Active workflow snapshot cleanup:** moved the active board read path to + workflow snapshot Query caches. `useWorkflowSnapshot` no longer falls back to + `state.kanban`; `useTasks`, `useKanbanData`, `KanbanBoard`, and + `KanbanWithPreview` now derive active tasks, steps, loading, multi-select, + and preview task lookup from `qk.workflows.snapshot(...)` / + `useAllWorkflowSnapshots`. Boot and route hydration now seed workflow + snapshot query keys from existing `kanban` / `kanbanMulti` payload shapes so + first paint remains populated while the legacy mirror still exists. The + Query bridge now patches workflow snapshot and task-detail + `primary_session_state` on `session.state_changed`, replacing the active + board's dependency on the old `syncKanbanPrimarySessionState` store merge. +- **Active board writer cleanup:** moved the live board optimistic writers to + workflow snapshot Query caches. Dialog create/edit success now patches + `qk.tasks.detail(...)` and `qk.workflows.snapshot(task.workflow_id)`; + delete/archive success removes tasks from cached workflow snapshots; swimlane + drag/drop uses the Query snapshot with rollback on failed moves. The unused + legacy `useDragAndDrop` hook was deleted after moving its shared + `MoveTaskError` type to `lib/kanban`, and the orphaned + `swimlane-graph-content` file was removed after import scans confirmed it was + dead. The used Kanban and Pipeline views now share the Query-backed + `useSwimlaneMove` path. +- **Sidebar filter options cleanup:** moved workflow, workflow-step, and + executor-type filter option lists to workflow snapshot Query caches via + `useAllWorkflowSnapshots(activeWorkspaceId)`. The sidebar filter hook no + longer reads `kanbanMulti.snapshots`; Zustand remains only for the active + workspace UI selection in this path. +- **Task mention metadata cleanup:** moved `@task` mention menu construction + and referenced-task prompt context expansion to workflow snapshot Query + caches. `buildTaskMentionItems`, `buildTaskMentionsContext`, the TipTap chat + input, normal chat send, and passthrough composer no longer read + `state.kanban` / `kanbanMulti.snapshots` for task title/workflow/step + metadata. The normal send path still writes created chat messages to the + local session store for missed-frame resilience. +- **Recent task switcher cleanup:** moved recent task switcher live metadata + resolution to workflow snapshot Query caches. The hook no longer reads active + `state.kanban` tasks/steps/workflow id or `kanbanMulti.snapshots`; display + titles, workflow names, step titles, task states, repositories, and session + status are derived from Query snapshots plus the remaining session/client + stores. +- **Mobile session switcher cleanup:** moved the mobile task switcher sheet's + step data, task selection/archive/delete metadata lookups, task-created cache + upsert, and workspace-switch snapshot fetch to workflow snapshot Query + caches. The sheet hook no longer reads or writes active `state.kanban` or + `kanbanMulti.snapshots`; it keeps only client UI/session stores for active + task/session selection and pending message/session badges. +- **Mobile repo/session metadata cleanup:** moved mobile repo count/rows, repo + pill active repo name, session primary marker, repo display name, and + base-branch-by-repo readers to task-detail and repository Query caches. These + mobile metadata paths no longer read active `state.kanban.tasks` or + `kanbanMulti.snapshots`; legacy stores can be empty while cached task detail + drives repository/session labels. +- **Desktop sidebar/removal cleanup:** moved active sidebar creation defaults, + subtask parent labels, task-section workflow selection, sidebar archive/delete + metadata, sidebar task selection metadata, sidebar move-to-step optimistic + writes, and `useTaskRemoval` next-task/removal logic to Query-owned workflow + snapshot and task-detail data. These paths no longer read or write active + `state.kanban` / `kanbanMulti.snapshots`; the sidebar still keeps client UI + state in Zustand. +- **Task-page/session chrome cleanup:** moved task-detail route metadata, + workflow steps, session panel step resolution, header/sidebar new-task + context, session primary markers, base-branch repository lookup, subtask + defaults, plan-mode layout detection, and changes-panel multi-repo labels to + Query caches. These task-page/session chrome paths no longer synthesize or + override task metadata from active `state.kanban.tasks` / + `kanbanMulti.snapshots`. +- **Board/command/dialog utility cleanup:** moved card context-menu workflow + move targets, command-panel step filtering/badges, and session command + subtask parent titles to Query caches. `useKanbanCardMoveTargets` reads + workflow snapshot query cache, command-panel step derivation uses stable + Query-owned snapshot data, and `SessionCommands` reads `useTaskById` instead + of active `kanban.tasks`. +- **Task-create/card/GitHub indicator/board-grid cleanup:** moved session-mode + task-create repository naming, task-create workflow snapshot defaults, kanban + card subtask parent badges, GitHub PR indicator step tooltips, and board-grid + loading decisions to Query-owned task detail, repository, workflow list, and + workflow snapshot caches. These UI readers no longer depend on active + `state.kanban` or `kanbanMulti.snapshots` mirrors being populated. +- **Legacy kanban WS mirror cleanup:** removed the old `kanban.update` Zustand + handler registration and handler/test file. The remaining `task.*` WS handler + now keeps only client-side side effects: active primary-session adoption, + deleted-task local storage cleanup, sidebar preference cleanup, and context + file cleanup. `session.state_changed` no longer patches active + `state.kanban` / `kanbanMulti.snapshots`, and `workspace.deleted` no longer + clears the legacy kanban mirror. Query bridge handlers now own workflow + snapshot invalidation/patching for these server-state events. +- **Final kanban store compatibility cleanup:** removed the top-level + `kanban` / `kanbanMulti` store fields, `setWorkflowSnapshot`, route/boot + seed compatibility shapes, active snapshot fallback paths, stale test + fixtures, and dead active-board fallback helpers. Route readiness now checks + Query snapshot hydration directly, SSR/boot seeds use + `workflowSnapshots.itemsByWorkflowId`, `mergeInitialState` allowlists known + store fields, and stale scans find no production references to the removed + store API. +- **Feature flags and session worktrees cleanup:** moved feature flag reads and + session worktree indexes to TanStack Query. `useFeature` now reads + `qk.features()`, `useSessionWorktrees` reads + `qk.sessionRuntime.worktrees(sessionId)`, boot/route payloads seed those query + keys, and `session.agentctl_ready` patches the worktree query cache through + the bridge. Removed the old feature slice, `worktrees` / + `sessionWorktreesBySessionId` store fields/actions, the unused + `use-worktree` hook, and the legacy agent-session worktree store writer. +- **Final retained-state audit:** corrected `apps/web/AGENTS.md`, removed stale + bridge-audit wording, reclassified `run.event.appended` as component-local, + and confirmed final stale scans have no production matches for removed + feature/worktree, kanban/workspace/repository, office, settings/integration, + queue, plan revision, command, todo, prompt usage, capability, poll-mode, or + legacy handler store APIs. Remaining scan hits are documented retained state + or non-Zustand false positives such as route-local `state.repositories`, the + public `setSessionMode` API, retained `sessionModels`, and client-only + `kanbanPreviewedTaskId`. + +Retained Zustand inventory: + +- Client navigation/UI state: `tasks.activeTaskId`, `tasks.activeSessionId`, + `workflows.activeId`, `workspaces.activeId`, preview/sidebar/mobile/dialog + state, persisted user settings, and local preferences. +- Live session indexes: `messages`, `turns`, `taskSessions`, + `taskSessionsByTask`, `taskPlans`, `sessionAgentctl`, and + `activeModel.bySessionId` remain for stream ordering, missed-frame recovery, + plan editing/seen-state UI, active-session chrome, model UI, and local panel + behavior while Query owns server snapshots and invalidation. +- Runtime stream indexes: `shell`, `processes`, `gitStatus`, + `sessionCommits`, `contextWindow`, `prepareProgress`, `sessionModels`, + `userShells`, and `environmentIdBySessionId` remain for high-frequency + terminal/process/git/context/model updates and environment-scoped cleanup. +- Server-backed persisted preference state: `userSettings` remains in Zustand + for local preference persistence and UI merge behavior; server-state settings + consumers are seeded through `qk.settings.user()`. +- Local integration/client indexes: GitHub `pendingPrUrlByTaskId` and + `prFeedbackCache` remain local-only; `office.tasks.filters`, + `office.tasks.viewMode`, `office.tasks.sortField`, `office.tasks.sortDir`, + `office.tasks.groupBy`, and `office.tasks.nestingEnabled` remain local UI + state. + +No GitLab-specific Docker E2E specs exist in this checkout. The GitLab sub-wave +used focused unit coverage plus the integration/settings/sidebar/strict-WS +Docker gate below; add GitLab browser specs before treating GitLab as fully +covered by domain-specific E2E. + +## Verification + +- `cd apps && pnpm --filter @kandev/web typecheck` +- `cd apps && pnpm --filter @kandev/web lint` +- `cd apps && pnpm --filter @kandev/web test` +- `cd apps/web && pnpm e2e:docker tests/task/task-list.spec.ts tests/kanban/kanban-board.spec.ts tests/chat/message-add-ws-gap.spec.ts tests/session/session-tab-management.spec.ts tests/office/dashboard.spec.ts tests/github/github-scope-bar.spec.ts tests/integrations/jira-settings.spec.ts tests/system/status-page.spec.ts` +- Repeat any Wave 3 Docker E2E gate whose migrated domain still had retained + Zustand paths at the start of this cleanup task. + +Completed Wave 4 sub-wave verification: + +- `cd apps/web && rtk pnpm typecheck` passed after system cleanup. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/system components/system-metrics lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts` + passed 47 tests. +- `cd apps/web && rtk pnpm typecheck` passed after GitHub cleanup. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/github components/github lib/state/slices/github lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts` + passed 29 files / 298 tests. +- `cd apps/web && rtk pnpm e2e:docker tests/github/pr-list-task-indicator.spec.ts tests/github/github-scope-bar.spec.ts tests/integrations/github-watch-reset.spec.ts tests/pr/ci-automation-options.spec.ts tests/git/git-changes-panel.spec.ts` + passed 22 desktop Docker tests. +- `cd apps/web && rtk pnpm typecheck` passed after GitLab cleanup. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/gitlab components/gitlab app/gitlab lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts` + passed 8 files / 81 tests. +- `cd apps/web && rtk pnpm e2e:docker tests/system/sidebar-navigation.spec.ts tests/integrations/jira-settings.spec.ts tests/integrations/linear-settings.spec.ts tests/integrations/sentry-settings.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 21 desktop Docker tests. +- `cd apps/web && rtk pnpm typecheck` passed after the settings small-mirror + cleanup. +- `cd apps && rtk pnpm --filter @kandev/web test -- hooks/domains/settings/use-settings-query-hooks.test.tsx hooks/domains/settings components/settings lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts` + passed 16 files / 95 tests. +- `cd apps/web && rtk pnpm e2e:docker tests/settings/prompts-settings.spec.ts tests/settings/agent-profile-acp.spec.ts tests/settings/agent-profile-cli-flags.spec.ts tests/settings/config-management.spec.ts tests/settings/utility-agents.spec.ts tests/settings/agent-install-streaming.spec.ts tests/settings/docker-profile-persistence.spec.ts` + passed 46 desktop Docker tests. +- `cd apps/web && rtk pnpm typecheck` passed after settings catalog/bootstrap + cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/settings/use-executors-query-sync.test.ts hooks/domains/settings/use-agents-query-sync.test.ts hooks/domains/settings/use-settings-query-hooks.test.tsx lib/query/seed.test.ts src/settings-routes.test.ts components/agent/cli-profile-editor.test.tsx lib/query/bridge/index.test.ts` + passed 7 files / 39 tests. +- `cd apps/web && rtk pnpm test components/task/handoff-profile-menu-items.test.ts app/office/agents/[id]/components/agent-configuration-tab.test.tsx components/app-sidebar/sections/settings/settings-tree-render.test.tsx components/task/model-selector.test.ts components/task/executor-settings-button.test.tsx components/quick-chat/use-quick-chat-modal.test.ts components/quick-chat/quick-chat-modal.test.ts components/agent/cli-profile-editor.test.tsx hooks/domains/settings/use-settings-query-hooks.test.tsx hooks/domains/settings/use-executors-query-sync.test.ts hooks/domains/settings/use-agents-query-sync.test.ts` + passed 11 files / 50 tests. +- `rtk rg -n "registerAgentsHandlers|registerExecutorsHandlers|registerExecutorProfileHandlers|handlers/(agents|executors|executor-profiles)|lib/ws/handlers/(agents|executors|executor-profiles)" apps/web --glob '!dist/**'` + returned no matches for the deleted settings catalog WS handlers. +- `cd apps/web && rtk pnpm e2e:docker tests/settings/agent-profile-acp.spec.ts tests/settings/agent-profile-delete.spec.ts tests/settings/config-management.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 36 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=containers tests/ssh/executor-crud.spec.ts` + passed 5 container-backed SSH executor tests / 1 skipped. +- `cd apps/web && rtk pnpm typecheck` passed after the final lint-driven + refactors. +- `cd apps/web && rtk pnpm lint` passed after the final lint-driven refactors. +- `cd apps/web && rtk pnpm test hooks/domains/session/use-session-messages.test.ts hooks/domains/session/use-visibility-backfill.test.ts hooks/domains/session/use-session-commits.test.ts hooks/domains/settings/use-settings-query-hooks.test.tsx hooks/domains/settings/use-executors-query-sync.test.ts lib/query/bridge/index.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts lib/query/seed.test.ts components/state-hydrator.test.tsx app/office/page-client.test.tsx app/office/tasks/use-paginated-tasks.test.tsx` + passed 12 files / 94 tests. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/message-add-ws-gap.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 7 desktop Docker tests. +- `cd apps/web && rtk pnpm typecheck` passed after the workspace/kanban + workflow-step and metadata cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-workflow-steps.ts hooks/use-workflow-steps.test.ts components/automations/config-section.tsx components/automations/config-section.test.tsx components/task-create-dialog-effects.ts components/task-create-dialog-effects.test.ts lib/query/query-options/kanban.ts lib/query/query-options/query-options.test.ts lib/query/bridge/workspace.ts lib/query/bridge/index.test.ts lib/query/bridge/workspace.test.ts lib/query/keys.ts lib/query/keys.test.ts hooks/domains/workspace/use-repositories.ts hooks/domains/workspace/use-repository-branches.ts hooks/domains/workspace/use-repository-scripts.ts hooks/domains/kanban/use-kanban-actions.ts lib/state/slices/workspace/types.ts lib/state/slices/workspace/workspace-slice.ts lib/state/slices/workspace/workspace-slice.test.ts app/page.tsx app/tasks/page.tsx app/github/page.tsx lib/ssr/session-page-state.ts lib/state/store.ts` + passed. +- `cd apps/web && rtk pnpm test hooks/use-workflow-steps.test.ts components/task-create-dialog-effects.test.ts components/automations/config-section.test.tsx lib/query/query-options/query-options.test.ts lib/query/keys.test.ts lib/query/bridge/index.test.ts lib/query/bridge/workspace.test.ts lib/state/slices/workspace/workspace-slice.test.ts hooks/use-workflow-snapshot.test.ts hooks/use-tasks.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts lib/ws/handlers/kanban.test.ts lib/ws/handlers/tasks.test.ts lib/ws/handlers/workflows.test.ts lib/routing/kanban-route-hydration.test.ts` + passed 16 files / 116 tests. +- `cd apps/web && rtk rg -n "loadedByWorkspaceId|loadingByWorkspaceId|setRepositoriesLoading|loadingByRepositoryId|loadedByRepositoryId|fetchedAtByRepositoryId|fetchErrorByRepositoryId|setRepositoryBranchesLoading|setRepositoryBranchesFetchError|setRepositoryScriptsLoading|invalidateRepositories" apps/web --glob '!dist/**'` + returned no matches for the deleted workspace metadata. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/workflow-filter.spec.ts tests/kanban/kanban-board.spec.ts tests/task/task-list.spec.ts tests/settings/config-management.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 35 desktop Docker tests. +- `cd apps/web && rtk rg -n "fetchWorkflowSnapshot|listWorkflows" apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts` + returned no matches after the mobile switcher Query cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the mobile switcher Query + cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/mobile/session-task-switcher-sheet-hooks.ts` + passed. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/task/mobile-sidebar-subtasks.spec.ts tests/kanban/mobile-kanban.spec.ts` + passed 12 mobile Docker tests. +- `cd apps/web && rtk rg -n "fetchTask" apps/web/components/task/task-page-content.tsx` + returned no matches after the task-detail route Query cleanup. +- `cd apps/web && rtk pnpm test components/task/task-page-content.test.tsx` + passed 1 file / 2 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the task-detail route Query + cleanup. +- RED workflow-list gate: + `cd apps/web && rtk pnpm test hooks/use-workflows.test.tsx lib/query/seed.test.ts lib/state/slices/kanban/kanban-slice.test.ts` + failed on the retained Zustand dependency/state and missing workflow query + seed path. +- `rtk make fmt` passed after the workflow-list cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the workflow-list cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-workflows.ts hooks/use-workflows.test.tsx hooks/use-workflow-cache.ts lib/query/seed.ts lib/query/seed.test.ts lib/state/slices/kanban/types.ts lib/state/slices/kanban/kanban-slice.ts lib/state/slices/kanban/kanban-slice.test.ts lib/state/default-state.ts lib/state/hydration/hydrator.ts lib/state/store.ts lib/state/slices/index.ts lib/state/store-reexports.ts app/page.tsx app/tasks/page.tsx app/github/page.tsx app/jira/page.tsx app/linear/page.tsx lib/ssr/session-page-state.ts src/spa-routes.tsx lib/routing/kanban-route-hydration.ts lib/routing/kanban-route-hydration.test.ts hooks/domains/settings/use-workflow-settings.ts hooks/domains/settings/use-workflow-settings.test.ts hooks/domains/kanban/use-all-workflow-snapshots.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts components/kanban-board.tsx hooks/domains/kanban/use-kanban-actions.ts hooks/domains/kanban/use-kanban-data.ts components/kanban/swimlane-container.tsx components/kanban-card-menu-items.tsx components/task-create-dialog-state.ts components/task-create-dialog-types.ts components/automations/config-section.tsx components/sentry/sentry-issue-watch-dialog.tsx components/jira/jira-issue-watch-dialog.tsx components/linear/linear-issue-watch-dialog.tsx components/github/issue-watch-dialog.tsx components/github/review-watch-dialog.tsx components/task/recent-task-switcher-hooks.ts components/task/mobile/session-task-switcher-sheet-hooks.ts components/task/chat/task-mention-items.ts components/task/chat/task-mention-items.test.ts components/task/chat/tiptap-input.tsx lib/ws/handlers/workspaces.ts components/kanban-display-dropdown.tsx components/kanban/mobile-menu-sheet.tsx hooks/use-kanban-display-settings.ts lib/kanban/resolve-workflow.ts lib/kanban/resolve-workflow.test.ts hooks/use-message-handler.test.ts` + passed after the workflow-list cleanup. +- `cd apps/web && rtk pnpm test hooks/use-workflows.test.tsx lib/query/seed.test.ts lib/state/slices/kanban/kanban-slice.test.ts lib/routing/kanban-route-hydration.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts hooks/domains/settings/use-workflow-settings.test.ts components/task-create-dialog-state.test.ts components/task/chat/task-mention-items.test.ts components/task/recent-task-switcher-model.test.ts` + passed 10 files / 60 tests after the workflow-list cleanup. +- `rtk rg -n -P "workflows\\.items|setWorkflows\\b|reorderWorkflowItems\\b|WorkflowsState\\[\"items\"\\]|workflows:\\s*\\{\\s*items|items:\\s*workflows\\.map" apps/web --glob '!dist/**' --glob '!e2e/test-results/**' --glob '!**/kanban-slice.test.ts'` + returned no matches after the workflow-list cleanup. +- `rtk git diff --check` passed after the workflow-list cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/workflow-filter.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/workflow/workflow-sorting.spec.ts tests/task/create-task.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 24 desktop Docker tests after the workflow-list cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/workflow/mobile-workflow-manual-move-queue.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-create-task-remote-repo.spec.ts` + passed 14 mobile Docker tests after the workflow-list cleanup. +- RED all-workflow snapshot/loading gate: + `cd apps/web && rtk pnpm test hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts lib/state/slices/kanban/kanban-slice.test.ts` + failed on the retained store loading/actions and store-backed sidebar data. +- `cd apps/web && rtk pnpm test hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts components/kanban/task-multi-select-toolbar.test.tsx lib/state/slices/kanban/kanban-slice.test.ts lib/ws/handlers/agent-session-kanban.test.ts lib/ws/handlers/kanban.test.ts lib/ws/handlers/tasks.test.ts lib/routing/kanban-route-hydration.test.ts components/task/chat/task-mention-items.test.ts hooks/use-message-handler.test.ts` + passed 10 files / 45 tests after the all-workflow snapshot/loading cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the all-workflow + snapshot/loading cleanup. +- Targeted eslint for the all-workflow snapshot hook, workspace sidebar hook, + multi-select hook/tests, Kanban board/swimlane, kanban slice/store plumbing, + hydration/default-state, and related WS/routing fixtures passed. +- `rtk rg -n "setKanbanMultiLoading|kanbanMulti\\.isLoading|isMultiLoading|updateMultiTask|removeMultiTask|kanbanMulti: \\{ snapshots: \\{\\}, isLoading: false \\}" apps/web --glob '!dist/**' --glob '!e2e/test-results/**' --glob '!**/*.test.ts' --glob '!**/*.test.tsx'` + returned no production matches. +- `rtk git diff --check` passed after the all-workflow snapshot/loading + cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/workflow-filter.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/workflow/workflow-sorting.spec.ts tests/task/create-task.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 25 desktop Docker tests after the all-workflow snapshot/loading + cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/workflow/mobile-workflow-manual-move-queue.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-create-task-remote-repo.spec.ts` + passed 14 mobile Docker tests after the all-workflow snapshot/loading + cleanup. +- RED task-detail lookup gate: + `cd apps/web && rtk pnpm test hooks/use-task.test.ts` + failed with `useAppStore must be used within StateProvider`, proving + `useTask`/`useTaskById` still depended on the legacy kanban store fallback. +- `cd apps/web && rtk pnpm test hooks/use-task.test.ts components/task/chat/messages/chat-message.test.tsx hooks/domains/session/use-session-state.test.ts components/task/task-page-content.test.tsx` + passed 4 files / 32 tests after the task-detail lookup cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-task.ts hooks/use-task.test.ts hooks/domains/kanban/use-task-by-id.ts hooks/domains/kanban/use-task-repositories.ts components/task/chat/messages/chat-message.test.tsx` + passed after the task-detail lookup cleanup. +- `rtk rg -n "useAppStore|findTaskInSnapshots|state\\.kanban|kanbanMulti\\.snapshots|kanban store|kanban tasks slice" apps/web/hooks/use-task.ts apps/web/hooks/domains/kanban/use-task-by-id.ts apps/web/hooks/domains/kanban/use-task-repositories.ts apps/web/components/task/chat/messages/chat-message.test.tsx` + returned no matches after the task-detail lookup cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the task-detail lookup + cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/task-list.spec.ts tests/session/new-session-dialog.spec.ts tests/chat/message-add-ws-gap.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 8 desktop Docker tests after the task-detail lookup cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-task-list-search.spec.ts tests/session/mobile-session-details.spec.ts` + passed 2 mobile Docker tests after the task-detail lookup cleanup. +- RED task-removal Query-cache gate: + `cd apps/web && rtk pnpm test hooks/use-task-removal.test.ts` + failed because a cached `qk.workflows.snapshot(...)` still contained the + removed task when the legacy `kanbanMulti.snapshots` mirror was empty. +- RED task-removal SPA redirect gate: + `cd apps/web && rtk pnpm test hooks/use-task-removal.test.ts` + failed because last-task removal changed `window.location.pathname` but did + not emit the SPA location-change event, leaving strict WS accounting exposed + to a full-document navigation race. +- `rtk make fmt` passed after the task-removal cleanup. +- `cd apps/web && rtk pnpm test hooks/use-task-removal.test.ts hooks/use-task-multi-select.test.ts components/kanban/task-multi-select-toolbar.test.tsx components/task/task-delete-confirm-dialog.test.tsx components/task/task-archive-confirm-dialog.test.tsx lib/query/bridge/tasks.test.ts lib/ws/handlers/tasks.test.ts lib/ws/handlers/kanban.test.ts` + passed 8 files / 55 tests after the task-removal cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the task-removal cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-task-removal.ts hooks/use-task-removal.test.ts hooks/use-task-multi-select.ts hooks/use-task-multi-select.test.ts lib/query/workflow-snapshot-cache.ts` + passed after the task-removal cleanup. +- `rtk rg -n "kanbanMulti|setWorkflowSnapshot" apps/web/hooks/use-task-removal.ts apps/web/lib/query/workflow-snapshot-cache.ts apps/web/hooks/use-task-multi-select.ts` + returned no production matches after the task-removal cleanup. +- `rtk git diff --check` passed after the task-removal cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/card-menu-delete-archive.spec.ts tests/task/delete-task-redirect.spec.ts tests/task/archive-task-redirect.spec.ts tests/task/task-switcher-status.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/kanban/workflow-filter.spec.ts tests/system/ws-event-accounting.spec.ts` + first failed in `archive-task-redirect` teardown because the old + `window.location.href = "/"` redirect lost the strict WS browser hook, then + passed 17 desktop Docker tests after the SPA-router redirect fix. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-task-list-search.spec.ts tests/kanban/mobile-kanban.spec.ts` + passed 13 mobile Docker tests after the task-removal cleanup. +- RED active workflow snapshot gate: + `cd apps/web && rtk pnpm test hooks/use-workflow-snapshot.test.ts hooks/use-tasks.test.ts lib/query/seed.test.ts lib/query/bridge/index.test.ts` + failed because `useWorkflowSnapshot` still returned the active `state.kanban` + fallback while Query was pending, `useTasks` surfaced legacy + `kanban.tasks`, boot snapshots were not seeded into + `qk.workflows.snapshot(...)`, and `session.state_changed` did not patch + Query-owned card `primary_session_state`. +- `rtk make fmt` passed after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm test hooks/use-workflow-snapshot.test.ts hooks/use-tasks.test.ts hooks/domains/kanban/use-kanban-data.test.tsx lib/query/seed.test.ts lib/query/bridge/index.test.ts` + passed 5 files / 32 tests after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-all-workflow-snapshots.query.test.tsx hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts components/kanban/task-multi-select-toolbar.test.tsx components/task/task-page-content.test.tsx components/kanban-with-preview.test.ts` + passed 6 files / 20 tests after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the active workflow snapshot + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-workflow-snapshot.ts hooks/use-workflow-snapshot.test.ts hooks/use-tasks.ts hooks/use-tasks.test.ts hooks/domains/kanban/use-kanban-data.ts hooks/domains/kanban/use-kanban-data.test.tsx components/kanban-board.tsx components/kanban-with-preview.tsx lib/query/seed.ts lib/query/seed.test.ts lib/query/bridge/session.ts lib/query/bridge/index.test.ts` + passed with one existing max-lines warning in `lib/query/bridge/index.test.ts`. +- `rtk rg -n "state\\.kanban|state\\.kanbanMulti|kanbanMulti\\.snapshots|snapshotState\\?\\.isLoading|kanban\\.isLoading" apps/web/hooks/use-workflow-snapshot.ts apps/web/hooks/use-tasks.ts apps/web/hooks/domains/kanban/use-kanban-data.ts apps/web/components/kanban-board.tsx apps/web/components/kanban-with-preview.tsx apps/web/lib/query/seed.ts apps/web/lib/query/bridge/session.ts` + returned no matches after the active workflow snapshot cleanup. +- `rtk git diff --check` passed after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/kanban-board.spec.ts tests/kanban/workflow-filter.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/task/task-list.spec.ts tests/task/task-switcher-status.spec.ts tests/session/session-tab-management.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 23 desktop Docker tests after the active workflow snapshot cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed 13 mobile Docker tests after the active workflow snapshot cleanup. +- RED active board writer gate: + `cd apps/web && rtk pnpm test hooks/domains/kanban/use-kanban-actions.test.tsx hooks/use-task-crud.test.tsx hooks/domains/kanban/use-swimlane-move.test.tsx` + failed because dialog create/edit, task delete/archive, and swimlane moves + still wrote/read legacy `kanban` / `kanbanMulti` mirrors instead of workflow + snapshot Query caches. +- `rtk make fmt` passed after the active board writer cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/kanban/use-kanban-actions.test.tsx hooks/use-task-crud.test.tsx hooks/domains/kanban/use-swimlane-move.test.tsx hooks/use-task-multi-select.test.ts components/kanban/task-multi-select-toolbar.test.tsx lib/query/bridge/tasks.test.ts lib/ws/handlers/tasks.test.ts lib/ws/handlers/kanban.test.ts` + passed 8 files / 37 tests after the active board writer cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the active board writer + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-task-crud.ts hooks/use-task-crud.test.tsx hooks/domains/kanban/use-kanban-actions.ts hooks/domains/kanban/use-kanban-actions.test.tsx hooks/domains/kanban/use-swimlane-move.ts hooks/domains/kanban/use-swimlane-move.test.tsx components/kanban/swimlane-kanban-content.tsx components/kanban/swimlane-container.tsx components/kanban-board.tsx lib/query/workflow-snapshot-cache.ts lib/kanban/move-task-error.ts lib/kanban/view-registry.ts` + passed. +- `rtk rg -n "state\\.kanban|kanban\\.tasks|kanban\\.workflowId|hydrate\\(\\{\\s*kanban|kanbanMulti\\.snapshots|setWorkflowSnapshot" apps/web/hooks/domains/kanban/use-kanban-actions.ts apps/web/hooks/use-task-crud.ts apps/web/hooks/domains/kanban/use-swimlane-move.ts apps/web/components/kanban/swimlane-kanban-content.tsx apps/web/lib/query/workflow-snapshot-cache.ts` + returned no production matches after the active board writer cleanup. +- `rtk rg -n "@/hooks/use-drag-and-drop|SwimlaneGraphContent|swimlane-graph-content" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned no matches after deleting the unused legacy hook and orphaned graph + content. +- `rtk git diff --check` passed after the active board writer cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/kanban/kanban-board.spec.ts tests/kanban/card-menu-delete-archive.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/kanban/pipeline-view.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 19 desktop Docker tests after the active board writer cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts` + passed 11 mobile Docker tests after the active board writer cleanup. +- RED sidebar filter options gate: + `cd apps/web && rtk pnpm test components/task/sidebar-filter/use-filter-value-options.test.ts` + failed because workflow, workflow-step, and executor-type options were still + empty when the legacy `kanbanMulti.snapshots` mirror was empty but workflow + snapshot Query caches were populated. +- `rtk make fmt` passed after the sidebar filter options cleanup. +- `cd apps/web && rtk pnpm test components/task/sidebar-filter/use-filter-value-options.test.ts` + passed 1 file / 6 tests after the sidebar filter options cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the sidebar filter options + cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/sidebar-filter/use-filter-value-options.ts components/task/sidebar-filter/use-filter-value-options.test.ts` + passed after the sidebar filter options cleanup. +- `rtk rg -n "kanbanMulti\\.snapshots|state\\.kanban|s\\.kanban\\.tasks|s\\.kanban\\.steps|setWorkflowSnapshot" apps/web/components/task/sidebar-filter/use-filter-value-options.ts` + returned no matches after the sidebar filter options cleanup. +- `rtk git diff --check` passed after the sidebar filter options cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/sidebar-filter.spec.ts tests/task/task-list-filters.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 22 desktop Docker tests after the sidebar filter options cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-task-list-search.spec.ts` + passed 1 mobile Docker test after the sidebar filter options cleanup. +- RED task mention metadata gate: + `cd apps/web && rtk pnpm test components/task/chat/task-mention-items.test.ts hooks/use-message-handler.test.ts components/task/passthrough-chat-composer.test.ts` + failed because mention helpers still dereferenced `state.kanban` / + `kanbanMulti.snapshots`, and passthrough still required a store `getState` + callback for task mention context. +- `rtk make fmt` passed after the task mention metadata cleanup. +- `cd apps/web && rtk pnpm test components/task/chat/task-mention-items.test.ts hooks/use-message-handler.test.ts components/task/passthrough-chat-composer.test.ts` + passed 3 files / 19 tests after the task mention metadata cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the task mention metadata + cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/chat/task-mention-items.ts components/task/chat/task-mention-items.test.ts components/task/chat/tiptap-input.tsx hooks/use-message-handler.ts hooks/use-message-handler.test.ts components/task/chat/chat-input-area.tsx components/task/passthrough-chat-composer.tsx components/task/passthrough-chat-composer.test.ts` + passed after the task mention metadata cleanup. +- `rtk rg -n "kanbanMulti|state\\.kanban|\\.kanban\\.(tasks|steps|workflowId)|buildTaskMentionsContext\\([^\\n]+getState|buildTaskMentionItems\\([^\\n]+getState" apps/web/components/task/chat/task-mention-items.ts apps/web/components/task/chat/tiptap-input.tsx apps/web/hooks/use-message-handler.ts apps/web/components/task/chat/chat-input-area.tsx apps/web/components/task/passthrough-chat-composer.tsx` + returned no matches after the task mention metadata cleanup. +- `rtk git diff --check` passed after the task mention metadata cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/message-add-ws-gap.spec.ts tests/cli-mode/passthrough-toolbar.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 10 desktop Docker tests after the task mention metadata cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts tests/cli-mode/mobile-passthrough-composer.spec.ts` + passed 3 mobile Docker tests after the task mention metadata cleanup. +- RED recent task switcher gate: + `cd apps/web && rtk pnpm test components/task/recent-task-switcher-model.test.ts` + failed with `ctx.kanbanSteps is not iterable` after the test context removed + legacy kanban fields and provided live task metadata only through workflow + snapshots. +- `rtk make fmt` passed after the recent task switcher cleanup. +- `cd apps/web && rtk pnpm test components/task/recent-task-switcher-model.test.ts components/task/recent-task-switcher-keys.test.ts` + passed 2 files / 17 tests after the recent task switcher cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the recent task switcher + cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/recent-task-switcher-hooks.ts components/task/recent-task-switcher-model.ts components/task/recent-task-switcher-model.test.ts components/task/recent-task-switcher-keys.test.ts` + passed after the recent task switcher cleanup. +- `rtk rg -n "state\\.kanban|kanbanMulti|kanbanTasks|kanbanSteps|kanbanWorkflowId" apps/web/components/task/recent-task-switcher-hooks.ts apps/web/components/task/recent-task-switcher-model.ts` + returned no matches after the recent task switcher cleanup. +- `rtk git diff --check` passed after the recent task switcher cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/recent-task-switcher.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 4 desktop Docker tests after the recent task switcher cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-task-list-search.spec.ts` + passed 1 mobile Docker test after the recent task switcher cleanup. +- RED mobile session switcher gate: + `rtk pnpm --dir apps/web test components/task/mobile/session-task-switcher-sheet-hooks.test.tsx` + failed because the sheet still ignored Query-owned snapshots when legacy + `kanban` / `kanbanMulti` mirrors were empty, selected no primary session from + Query task metadata, wrote created tasks only into the legacy kanban mirror, + and hydrated `state.kanban` during workspace switch. +- `rtk make fmt` passed after the mobile session switcher cleanup. +- `rtk pnpm --dir apps/web test components/task/mobile/session-task-switcher-sheet-hooks.test.tsx` + passed 1 file / 4 tests after the mobile session switcher cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the mobile session switcher + cleanup. +- `rtk pnpm --dir apps/web exec eslint components/task/mobile/session-task-switcher-sheet-hooks.ts components/task/mobile/session-task-switcher-sheet-hooks.test.tsx` + passed after the mobile session switcher cleanup. +- `rtk rg -n "state\\.kanban|s\\.kanban|getState\\(\\)\\.kanban|kanbanMulti|setWorkflowSnapshot|findTaskInSnapshots|\\.kanban\\.(tasks|steps|workflowId|loading|error)" apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts` + returned no matches after the mobile session switcher cleanup. +- `rtk git diff --check` passed after the mobile session switcher cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed 2 mobile Docker tests after the mobile session switcher cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/system/ws-event-accounting.spec.ts` + passed 1 desktop Docker WS accounting test after the mobile session switcher + cleanup. +- RED mobile repo/session metadata gate: + `rtk pnpm --dir apps/web test components/task/mobile/mobile-repos-section.test.tsx hooks/domains/session/use-base-branch-by-repo.test.tsx hooks/domains/session/use-repo-display-name.test.tsx` + failed because the mobile repo count, base-branch map, and repo display name + readers still required legacy `kanban` metadata when Query-owned task detail + and repository caches were populated. +- `rtk make fmt` passed after the mobile repo/session metadata cleanup. +- `rtk pnpm --dir apps/web test components/task/mobile/mobile-repos-section.test.tsx hooks/domains/session/use-base-branch-by-repo.test.tsx hooks/domains/session/use-repo-display-name.test.tsx` + passed 3 files / 3 tests after the mobile repo/session metadata cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the mobile repo/session + metadata cleanup. +- `rtk pnpm --dir apps/web exec eslint components/task/mobile/mobile-repos-section.tsx components/task/mobile/mobile-repos-section.test.tsx components/task/mobile/mobile-repo-pill.tsx components/task/mobile/mobile-sessions-section.tsx hooks/domains/session/use-repo-display-name.ts hooks/domains/session/use-repo-display-name.test.tsx hooks/domains/session/use-base-branch-by-repo.ts hooks/domains/session/use-base-branch-by-repo.test.tsx` + passed after the mobile repo/session metadata cleanup. +- `rtk rg -n "state\\.kanban|s\\.kanban|getState\\(\\)\\.kanban|kanbanMulti|setWorkflowSnapshot|\\.kanban\\.(tasks|steps|workflowId|loading|error)" apps/web/components/task/mobile/mobile-repos-section.tsx apps/web/components/task/mobile/mobile-repo-pill.tsx apps/web/components/task/mobile/mobile-sessions-section.tsx apps/web/hooks/domains/session/use-repo-display-name.ts apps/web/hooks/domains/session/use-base-branch-by-repo.ts` + returned no matches after the mobile repo/session metadata cleanup. +- `rtk git diff --check` passed after the mobile repo/session metadata cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/task/mobile-create-task-remote-repo.spec.ts tests/task/mobile-changes-panel.spec.ts tests/session/mobile-handoff.spec.ts` + passed 7 mobile Docker tests after the mobile repo/session metadata cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/system/ws-event-accounting.spec.ts` + passed 1 desktop Docker WS accounting test after the mobile repo/session + metadata cleanup. +- RED desktop sidebar/removal gate: + `rtk pnpm --dir apps/web test hooks/use-task-removal.test.ts components/app-sidebar/app-sidebar-new-task-item.test.tsx components/app-sidebar/sections/tasks-section.test.tsx components/task/task-session-sidebar.test.tsx` + failed because `useTaskRemoval` still mutated and selected from active + `kanban.tasks`, the app sidebar still ignored Query workflow/task metadata, + and the task section still passed legacy `kanban.workflowId`. After fixing the + test harness, `components/task/task-session-sidebar.test.tsx` also failed + because archive metadata fell back to "this task" and sidebar move-to-step was + a no-op with empty `kanbanMulti`. +- `rtk make fmt` passed after the desktop sidebar/removal cleanup. +- `rtk pnpm --dir apps/web test hooks/use-task-removal.test.ts components/app-sidebar/app-sidebar-new-task-item.test.tsx components/app-sidebar/sections/tasks-section.test.tsx components/task/task-session-sidebar.test.tsx` + passed 4 files / 33 tests after the desktop sidebar/removal cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the desktop sidebar/removal + cleanup. +- `rtk pnpm --dir apps/web exec eslint hooks/use-task-removal.ts hooks/use-task-removal.test.ts components/app-sidebar/app-sidebar-new-task-item.tsx components/app-sidebar/app-sidebar-new-task-item.test.tsx components/app-sidebar/sections/tasks-section.tsx components/app-sidebar/sections/tasks-section.test.tsx components/task/task-session-sidebar.tsx components/task/task-session-sidebar.test.tsx` + passed after the desktop sidebar/removal cleanup. +- `rtk rg -n "state\\.kanban|s\\.kanban|getState\\(\\)\\.kanban|kanbanMulti|setWorkflowSnapshot|findTaskInSnapshots|\\.kanban\\.(tasks|steps|workflowId|loading|error)" apps/web/hooks/use-task-removal.ts apps/web/components/app-sidebar/app-sidebar-new-task-item.tsx apps/web/components/app-sidebar/sections/tasks-section.tsx apps/web/components/task/task-session-sidebar.tsx` + returned no matches after the desktop sidebar/removal cleanup. +- `rtk git diff --check` passed after the desktop sidebar/removal cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/task/sidebar-delete-confirm.spec.ts tests/task/delete-task-redirect.spec.ts tests/task/archive-task-redirect.spec.ts tests/kanban/card-menu-delete-archive.spec.ts tests/task/sidebar-send-to-workflow.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 15 desktop Docker tests after the desktop sidebar/removal cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/task/mobile-sidebar-subtasks.spec.ts tests/task/mobile-task-list-search.spec.ts tests/kanban/mobile-kanban.spec.ts` + passed 13 mobile Docker tests after the desktop sidebar/removal cleanup. +- RED task-page/session chrome gate: + `rtk pnpm --dir apps/web test components/task/task-page-content.test.tsx components/task/dockview-header-actions.test.tsx components/task/sessions-dropdown.test.tsx components/task/session-reopen-menu.test.tsx components/task/session-tab.test.tsx components/task/base-branch-picker.test.tsx components/task/new-session-dialog.test.tsx components/task/new-subtask-dialog.test.tsx components/task/changes-panel-data.test.tsx components/task/chat/use-chat-panel-state.test.tsx hooks/domains/kanban/use-plan-actions.test.tsx` + failed because task-page/session chrome readers still required or preferred + active `kanban` mirrors for workflow steps, task titles, primary-session + markers, repository metadata, subtask defaults, plan-mode step detection, and + multi-repo PR labels when Query caches were populated. +- `rtk make fmt` passed after the task-page/session chrome cleanup. +- `rtk pnpm --dir apps/web test components/task/task-page-content.test.tsx components/task/dockview-header-actions.test.tsx components/task/sessions-dropdown.test.tsx components/task/session-reopen-menu.test.tsx components/task/session-tab.test.tsx components/task/base-branch-picker.test.tsx components/task/new-session-dialog.test.tsx components/task/new-subtask-dialog.test.tsx components/task/changes-panel-data.test.tsx components/task/chat/use-chat-panel-state.test.tsx hooks/domains/kanban/use-plan-actions.test.tsx` + passed 11 files / 16 tests after the task-page/session chrome cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the task-page/session chrome + cleanup. +- `rtk pnpm --dir apps/web exec eslint components/task/task-page-content.tsx components/task/task-page-content.test.tsx components/task/dockview-header-actions.tsx components/task/dockview-header-actions.test.tsx components/task/sessions-dropdown.tsx components/task/sessions-dropdown.test.tsx components/task/session-reopen-menu.tsx components/task/session-reopen-menu.test.tsx components/task/session-tab.tsx components/task/session-tab.test.tsx components/task/base-branch-picker.tsx components/task/base-branch-picker.test.tsx components/task/new-session-dialog.tsx components/task/new-session-dialog.test.tsx components/task/new-subtask-dialog.tsx components/task/new-subtask-dialog.test.tsx components/task/changes-panel-data.tsx components/task/changes-panel-data.test.tsx components/task/chat/use-chat-panel-state.ts components/task/chat/use-chat-panel-state.test.tsx hooks/domains/kanban/use-plan-actions.ts hooks/domains/kanban/use-plan-actions.test.tsx` + passed after the task-page/session chrome cleanup. +- `rtk rg -n "state\\.kanban|s\\.kanban|getState\\(\\)\\.kanban|kanbanMulti|setWorkflowSnapshot|findTaskInSnapshots|\\.kanban\\.(tasks|steps|workflowId|loading|error)" apps/web/components/task/task-page-content.tsx apps/web/components/task/dockview-header-actions.tsx apps/web/components/task/sessions-dropdown.tsx apps/web/components/task/session-reopen-menu.tsx apps/web/components/task/session-tab.tsx apps/web/components/task/base-branch-picker.tsx apps/web/components/task/new-session-dialog.tsx apps/web/components/task/new-subtask-dialog.tsx apps/web/components/task/changes-panel-data.tsx apps/web/components/task/chat/use-chat-panel-state.ts apps/web/hooks/domains/kanban/use-plan-actions.ts apps/web/components/task/dockview-desktop-layout.tsx apps/web/components/task/sidebar-filter/use-filter-value-options.ts apps/web/components/task/task-session-sidebar-aggregate.ts` + returned no matches after the task-page/session chrome cleanup. +- `rtk git diff --check` passed after the task-page/session chrome cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/session/session-layout.spec.ts tests/session/session-tab-management.spec.ts tests/session/new-session-dialog.spec.ts tests/session/session-handoff.spec.ts tests/task/subtask.spec.ts tests/task/session-isolation.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 36 desktop Docker tests after the task-page/session chrome cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/task/mobile-changes-panel.spec.ts tests/session/mobile-handoff.spec.ts` + passed 6 mobile Docker tests after the task-page/session chrome cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/task-page-content.tsx components/task/task-page-content.test.tsx` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/task/sessionless-task-switch.spec.ts tests/task/task-list.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 9 desktop Docker tests. +- `cd apps/web && rtk rg -n "listTasksByWorkspace\\(" apps/web --glob '!dist/**' --glob '!e2e/**' --glob '!lib/api/**' --glob '!app/actions/**'` + returned matches only in Query option factories, confirming command-panel no + longer imports the task-list API directly. +- `cd apps/web && rtk pnpm test components/command-panel.test.ts` passed 1 file + / 2 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the command-panel Query + cleanup. +- `cd apps/web && rtk pnpm exec eslint components/command-panel.tsx components/command-panel.test.ts` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/command-panel.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 10 desktop Docker tests. +- RED board/command/dialog utility gate: + `rtk pnpm --dir apps/web test components/kanban-card-menu-items.test.tsx components/command-panel.test.ts components/session-commands.test.tsx` + failed because card move targets still required `StateProvider` / + `kanbanMulti.snapshots`, command-panel step metadata ignored Query-owned + workflow snapshots, and session subtask creation passed a blank parent title + when only `qk.tasks.detail(...)` was populated. +- `rtk make fmt` passed after the board/command/dialog utility cleanup. +- `rtk pnpm --dir apps/web test components/kanban-card-menu-items.test.tsx components/command-panel.test.ts components/session-commands.test.tsx` + passed 3 files / 8 tests after the utility cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the utility cleanup. +- `rtk pnpm --dir apps/web exec eslint components/kanban-card-menu-items.tsx components/kanban-card-menu-items.test.tsx components/command-panel.tsx components/command-panel.test.ts components/session-commands.tsx components/session-commands.test.tsx components/task/session-tab.tsx components/task/session-tab.test.tsx` + passed with duplicate-string warnings only in existing test fixtures. +- `rtk rg -n "state\\.kanban|s\\.kanban|getState\\(\\)\\.kanban|kanbanMulti|setWorkflowSnapshot|findTaskInSnapshots|\\.kanban\\.(tasks|steps|workflowId|loading|error)" apps/web/components/kanban-card-menu-items.tsx apps/web/components/command-panel.tsx apps/web/components/session-commands.tsx apps/web/components/task/session-tab.tsx` + returned no matches after the utility cleanup. +- `rtk git diff --check` passed after the utility cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/command-panel.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/task/sidebar-send-to-workflow.spec.ts tests/task/subtask.spec.ts tests/system/ws-event-accounting.spec.ts` + first exposed a command-panel React update loop when snapshot-derived steps + were unstable, then passed 28 desktop Docker tests after stabilizing the + Query-derived step array. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/session/mobile-handoff.spec.ts` + passed 13 mobile Docker tests after the utility cleanup. +- RED task-create/card/GitHub indicator/board-grid gate: + `rtk pnpm --dir apps/web test components/task-create-dialog-state.test.ts components/kanban-card-content.test.tsx components/github/my-github/pr-row-task-indicator.test.tsx components/kanban-board-grid.test.tsx` + failed because session-mode task-create repo naming, task-create workflow + snapshot defaults, kanban card subtask parent badges, GitHub PR indicator + step tooltips, and board-grid loading still depended on legacy `kanban` / + `kanbanMulti` mirrors when Query caches were populated. +- `rtk make fmt` passed after the task-create/card/GitHub indicator/board-grid + cleanup. +- `rtk pnpm --dir apps/web test components/task-create-dialog-state.test.ts components/kanban-card-content.test.tsx components/github/my-github/pr-row-task-indicator.test.tsx components/kanban-board-grid.test.tsx` + passed 4 files / 27 tests after the cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the cleanup. +- `rtk pnpm --dir apps/web exec eslint components/task-create-dialog-state.ts components/task-create-dialog-state.test.ts components/kanban-card-content.tsx components/kanban-card-content.test.tsx components/github/my-github/pr-row-task-indicator.tsx components/github/my-github/pr-row-task-indicator.test.tsx components/kanban-board-grid.tsx components/kanban-board-grid.test.tsx` + passed after the cleanup. +- `rtk rg -n "state\\.kanban|s\\.kanban|getState\\(\\)\\.kanban|kanbanMulti|setWorkflowSnapshot|findTaskInSnapshots|\\.kanban\\.(tasks|steps|workflowId|loading|error)" apps/web/components/task-create-dialog-state.ts apps/web/components/kanban-card-content.tsx apps/web/components/github/my-github/pr-row-task-indicator.tsx apps/web/components/kanban-board-grid.tsx` + returned no matches after the cleanup. +- `rtk git diff --check` passed after the cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/task/create-task.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/github/pr-list-task-indicator.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 22 desktop Docker tests after the cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/task/mobile-create-task-remote-repo.spec.ts tests/kanban/mobile-kanban.spec.ts` + passed 12 mobile Docker tests after the cleanup. +- `cd apps/web && rtk rg -n "getSubtaskCount" apps/web --glob '!dist/**' --glob '!e2e/**' --glob '!lib/api/**'` + returned only Query option/test references after the subtask-count hook stopped + importing the API directly. +- `cd apps/web && rtk pnpm test hooks/use-subtask-count.test.ts components/task/task-archive-confirm-dialog.test.tsx components/task/task-delete-confirm-dialog.test.tsx` + passed 3 files / 19 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the subtask-count Query + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-subtask-count.ts hooks/use-subtask-count.test.ts components/task/task-archive-confirm-dialog.test.tsx components/task/task-delete-confirm-dialog.test.tsx` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/task/subtask.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 15 desktop Docker tests. +- `rtk rg -n "clearTaskPlan|clearQueueStatus|task\\.plan\\.reverted" apps/web --glob '!dist/**'` + returned no `clearTaskPlan`/`clearQueueStatus` references and only backend + type plus Query bridge references for `task.plan.reverted`. +- `cd apps/web && rtk pnpm test lib/state/slices/session/task-plans.test.ts lib/state/slices/session/session-slice.upsert.test.ts lib/ws/handlers/task-plans.test.ts lib/query/bridge/index.test.ts` + passed 4 files / 35 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the session dead-code + cleanup. +- `cd apps/web && rtk pnpm exec eslint lib/state/slices/session/types.ts lib/state/slices/session/session-slice.ts lib/state/slices/session/task-plans.test.ts lib/state/slices/session/session-slice.upsert.test.ts lib/state/store.ts lib/ws/handlers/task-plans.ts` + passed. +- `cd apps/web && rtk pnpm e2e:docker tests/task/plan-checkpointing.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 16 desktop Docker tests. +- `cd apps/web && rtk pnpm test hooks/domains/session/use-queue.test.ts` + first failed because `useQueue` still required `StateProvider`, then passed + after the queue Query migration. +- `cd apps/web && rtk pnpm test hooks/domains/session/use-queue.test.ts components/task/chat/queued-ghost-list.test.tsx lib/query/bridge/index.test.ts lib/ws/handlers/agent-session.test.ts lib/state/slices/session/session-slice.upsert.test.ts` + passed 5 files / 59 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the queue mirror cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/domains/session/use-queue.ts hooks/domains/session/use-queue.test.ts lib/ws/handlers/agent-session.ts lib/state/slices/session/types.ts lib/state/slices/session/session-slice.ts lib/state/slices/session/session-slice.upsert.test.ts lib/state/store.ts lib/state/default-state.ts lib/state/store-overrides.ts lib/state/slices/index.ts` + passed. +- `rtk rg -n "QueueMeta|QueueState|defaultSessionState\\.queue|initialState\\.queue|m\\.queue|state\\.queue|setQueueEntries|removeQueueEntry:|setQueueLoading:" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the local `setQueueLoading` helper type in `use-queue.ts`; no + queue Zustand mirror fields/actions remain. +- `rtk git diff --check` passed after the queue mirror cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/message-queue.spec.ts tests/workflow/workflow-manual-move-queue.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 10 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/workflow/mobile-workflow-manual-move-queue.spec.ts` + passed 1 mobile Docker test. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repository-scripts.test.tsx lib/query/seed.test.ts lib/state/slices/workspace/workspace-slice.test.ts` + first failed on the old Zustand dependency/missing query seed/remaining slice + field, then passed 3 files / 8 tests after the repository scripts cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the repository scripts + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/domains/workspace/use-repository-scripts.ts hooks/domains/workspace/use-repository-scripts.test.tsx lib/query/seed.ts lib/query/seed.test.ts lib/state/slices/workspace/types.ts lib/state/slices/workspace/workspace-slice.ts lib/state/slices/workspace/workspace-slice.test.ts lib/state/default-state.ts lib/state/store-overrides.ts lib/state/store.ts lib/state/slices/index.ts lib/state/store-reexports.ts app/settings/workspace/workspace-repositories-client.tsx lib/ssr/session-page-state.ts` + passed. +- `rtk rg -n -P "state\\.repositoryScripts|m\\.repositoryScripts|defaultWorkspaceState\\.repositoryScripts|\\bsetRepositoryScripts\\b|\\bclearRepositoryScripts\\b|\\bRepositoryScriptsState\\b|repositoryScripts: \\(typeof defaultWorkspaceState\\)|itemsByRepositoryId\\[repositoryId\\].*repositoryScripts" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the repository scripts absence assertions in + `workspace-slice.test.ts`. +- `rtk git diff --check` passed after the repository scripts cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/session/dockview-repository-scripts.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 9 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/session/mobile-handoff.spec.ts` + passed 1 mobile Docker test. No repository-script-specific mobile E2E exists + in this checkout; this validates the mobile task/session shell after the + Query-only hook change. +- `cd apps/web && rtk pnpm test lib/state/slices/session/task-plans.test.ts lib/ws/handlers/task-plans.test.ts lib/query/bridge/index.test.ts` + first failed on the retained revision store fields/handler and missing Query + detail invalidation, then passed 3 files / 27 tests after the task-plan + revision cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the task-plan revision + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/domains/session/use-task-plan.ts lib/state/slices/session/types.ts lib/state/slices/session/session-slice.ts lib/state/slices/session/task-plans.test.ts lib/ws/handlers/task-plans.ts lib/ws/handlers/task-plans.test.ts lib/query/bridge/session.ts lib/query/bridge/index.test.ts lib/state/store.ts` + passed. +- `rtk rg -n "revisionsByTaskId|revisionsLoadingByTaskId|revisionsLoadedByTaskId|revisionContentCache|setPlanRevisions|upsertPlanRevision|setPlanRevisionsLoading|cachePlanRevisionContent" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the revision absence assertions in + `lib/state/slices/session/task-plans.test.ts`. +- `rtk git diff --check` passed after the task-plan revision cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/plan-checkpointing.spec.ts tests/layout/plan-panel-indicator.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 15 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/terminal/mobile-passthrough-rendering.spec.ts tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 2 mobile Docker tests. +- `cd apps/web && rtk pnpm test components/task/chat/context-items/lazy-plan-preview.test.tsx components/task/passthrough-chat-composer.test.ts` + first failed because `LazyPlanPreview` still required `StateProvider` and the + composer still read the store for cached plan content, then passed after the + Query-only plan-context cleanup. +- `cd apps/web && rtk pnpm test components/task/chat/context-items/lazy-plan-preview.test.tsx components/task/passthrough-chat-composer.test.ts lib/query/bridge/index.test.ts` + passed 3 files / 23 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the plan-context cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/chat/context-items/lazy-plan-preview.tsx components/task/chat/context-items/lazy-plan-preview.test.tsx components/task/passthrough-chat-composer.tsx components/task/passthrough-chat-composer.test.ts` + passed. +- `rtk rg -n "cachedTaskPlanContent|state\\.taskPlans|setTaskPlan\\(|useAppStore|useAppStoreApi" apps/web/components/task/chat/context-items/lazy-plan-preview.tsx apps/web/components/task/passthrough-chat-composer.tsx` + returned only the existing passthrough composer `useAppStoreApi` usage for + submit/task-mention state; no plan-context store fallback remains. +- `rtk git diff --check` passed after the plan-context cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/cli-mode/passthrough-toolbar.spec.ts tests/layout/plan-panel-indicator.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 13 desktop Docker tests. +- `cd apps/web && rtk pnpm test lib/ws/router.test.ts lib/query/bridge/session-runtime.test.ts lib/query/bridge/index.test.ts lib/state/slices/session-runtime/purge-session.test.ts components/task/task-item.test.tsx` + passed 5 files / 31 tests after the session runtime mirror cleanups. +- `cd apps/web && rtk pnpm typecheck` passed after the session runtime mirror + cleanups. +- `cd apps/web && rtk pnpm exec eslint components/task/task-item.tsx lib/api/domains/session-api.ts lib/ws/router.ts lib/ws/router.test.ts lib/query/bridge/session-runtime.test.ts lib/query/bridge/index.test.ts lib/state/default-state.ts lib/state/store.ts lib/state/store-overrides.ts lib/state/slices/session-runtime/types.ts lib/state/slices/session-runtime/session-runtime-slice.ts lib/state/slices/session-runtime/purge-session.test.ts lib/query/seed.ts components/task/chat/use-chat-panel-state.ts` + passed. +- `rtk rg -n "sessionTodos|setSessionTodos|PromptUsageState|SessionTodosState|promptUsage|setPromptUsage|agentCapabilities|setAgentCapabilities|AgentCapabilitiesState|sessionPollMode|setSessionPollMode|SessionPollModeState|registerSessionTodosHandlers|registerPromptUsageHandlers|registerAgentCapabilitiesHandlers|registerSessionPollModeHandlers|handlers/session-todos|handlers/prompt-usage|handlers/agent-capabilities|handlers/session-poll-mode|AuthMethodsIndicator|auth-methods-indicator|authenticateSession" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only Query key/bridge/query-option references, absence assertions, + and unrelated settings hook naming; no old Zustand runtime mirror fields, + actions, handlers, or unused auth indicator/helper remain. +- `cd apps/web && rtk pnpm test lib/ws/router.test.ts lib/query/bridge/workspace.test.ts lib/query/bridge/index.test.ts hooks/use-workflow-snapshot.test.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts components/improve-kandev-dialog.test.tsx` + first failed on the still-registered workflow router handler / missing Query + cache hydration, then passed 7 files / 35 tests after the Improve Kandev and + workflow-handler cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the Improve Kandev and + workflow-handler cleanup. +- `cd apps/web && rtk pnpm exec eslint components/improve-kandev-dialog.tsx components/improve-kandev-dialog.test.tsx lib/ws/router.ts lib/ws/router.test.ts lib/query/bridge/workspace.ts lib/query/bridge/workspace.test.ts hooks/use-workflow-snapshot.ts hooks/use-workflow-snapshot.test.ts hooks/domains/kanban/use-all-workflow-snapshots.ts hooks/domains/kanban/use-all-workflow-snapshots.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts` + passed. +- `rtk rg -n "registerWorkflowsHandlers|handlers/workflows|lib/ws/handlers/workflows|workflow\\.created handler|workflow\\.updated handler" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned no matches after deleting the legacy workflow WS handler. +- `cd apps/web && rtk pnpm e2e:docker tests/workflow/workflow-steps.spec.ts tests/workflow/workflow-settings.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 13 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed 12 mobile Docker tests. +- `cd apps/web && rtk pnpm test lib/ws/router.test.ts lib/state/slices/session/session-slice.upsert.test.ts lib/state/slices/session-runtime/purge-session.test.ts lib/state/slices/session-runtime/output-caps.test.ts` + first failed on the retained legacy state/handler assertions, then passed 4 + files / 15 tests after removing the stale runtime plumbing. +- `cd apps/web && rtk pnpm typecheck` passed after the session-runtime + dead-plumbing cleanup. +- `cd apps/web && rtk pnpm exec eslint lib/ws/router.ts lib/ws/router.test.ts lib/ws/handlers/terminals.ts lib/state/default-state.ts lib/state/store.ts lib/state/store-overrides.ts lib/state/store-reexports.ts lib/state/hydration/hydrator.ts lib/state/slices/index.ts lib/state/slices/session/types.ts lib/state/slices/session/session-slice.ts lib/state/slices/session/session-slice.upsert.test.ts lib/state/slices/session-runtime/types.ts lib/state/slices/session-runtime/session-runtime-slice.ts lib/state/slices/session-runtime/purge-session.test.ts lib/state/slices/session-runtime/output-caps.test.ts e2e/tests/terminal/terminal-agent.spec.ts` + passed. +- `rtk rg -n "pendingModel|setPendingModel|clearPendingModel|PendingModel|TerminalState|setTerminalOutput|terminal\\.terminals|terminal.output|AgentState|defaultSessionRuntimeState\\.agents|draft\\.agents|state\\.agents|m\\.agents|initialState\\.agents" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only intentional backend protocol/audit metadata, absence tests, and + unrelated terminal UI names. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/chat-status-bar.spec.ts tests/terminal/terminal-agent.spec.ts tests/search/terminal-search.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 17 desktop Docker tests after fixing the spec to use + `errors.TimeoutError` from Playwright. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 1 mobile Docker test. +- `rtk git diff --check` passed after the session-runtime dead-plumbing + cleanup. +- `cd apps/web && rtk pnpm test lib/ws/router.test.ts lib/state/slices/session-runtime/purge-session.test.ts components/task/mode-selector.test.tsx lib/query/bridge/session-runtime.test.ts lib/query/bridge/index.test.ts` + first failed on the retained legacy `session.mode_changed` handler and + `sessionMode` store field, then passed 5 files / 21 tests after the + session-mode cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the session-mode cleanup. +- `cd apps/web && rtk pnpm exec eslint components/task/mode-selector.tsx components/task/mode-selector.test.tsx lib/ws/router.ts lib/ws/router.test.ts lib/state/default-state.ts lib/state/store.ts lib/state/store-overrides.ts lib/state/slices/session-runtime/types.ts lib/state/slices/session-runtime/session-runtime-slice.ts lib/state/slices/session-runtime/purge-session.test.ts lib/query/seed.ts` + passed. +- `rtk rg -n -P "\\bsessionMode\\b|\\bclearSessionMode\\b|registerSessionModeHandlers\\b|handlers/session-mode(?:\\.|$)|lib/ws/handlers/session-mode(?:\\.|$)|state\\.sessionMode(?!l|s)|m\\.sessionMode(?!l|s)|defaultSessionRuntimeState\\.sessionMode(?!l|s)" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the absence assertions in `purge-session.test.ts`. +- `rtk git diff --check` passed after the session-mode cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/quick-chat.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 11 desktop Docker tests. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 1 mobile Docker test. +- `rtk git diff --check` passed after the session runtime mirror cleanups. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/chat-status-bar.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 8 desktop Docker tests for session todos/prompt usage cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 1 mobile Docker test for the chat hook surface. +- `cd apps/web && rtk pnpm e2e:docker tests/task/task-list.spec.ts tests/session/new-session-dialog.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 7 desktop Docker tests for agent capabilities/poll mode cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project mobile-chrome tests/task/mobile-task-list-search.spec.ts` + passed 1 mobile Docker test for the task item surface. +- `cd apps/web && rtk pnpm test hooks/use-inline-slash.test.tsx lib/ws/router.test.ts lib/state/slices/session-runtime/purge-session.test.ts` + first failed because `useInlineSlash` still required `StateProvider`, the + router still registered `session.available_commands`, and the runtime slice + still exposed `availableCommands`; this was the RED step for the available + commands cleanup. +- `cd apps/web && rtk pnpm test lib/state/slices/session/session-slice.merge-messages.test.ts` + first failed because API refetch snapshots dropped local empty-turn notices; + the test passed after preserving local `metadata.empty_turn` status messages. +- `cd apps/web && rtk pnpm test lib/state/slices/session/session-slice.merge-messages.test.ts hooks/use-inline-slash.test.tsx components/task/chat/use-chat-input-container.test.ts lib/ws/handlers/empty-turn-notice.test.ts lib/ws/router.test.ts lib/state/slices/session-runtime/purge-session.test.ts lib/query/bridge/session-runtime.test.ts lib/query/seed.test.ts` + passed 8 files / 46 tests after the available commands cleanup. +- `cd apps/web && rtk pnpm typecheck` passed after the available commands + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/use-inline-slash.ts hooks/use-inline-slash.test.tsx components/task/chat/tiptap-input.tsx components/task/chat/use-chat-panel-state.ts lib/ws/router.ts lib/ws/router.test.ts lib/ws/handlers/empty-turn-notice.ts lib/ws/handlers/empty-turn-notice.test.ts lib/state/slices/session-runtime/types.ts lib/state/slices/session-runtime/session-runtime-slice.ts lib/state/slices/session-runtime/purge-session.test.ts lib/state/slices/session/message-signature.ts lib/state/slices/session/session-slice.merge-messages.test.ts lib/state/slices/index.ts lib/state/store.ts lib/state/default-state.ts lib/query/seed.ts e2e/helpers/session-store.ts e2e/pages/session-page.ts` + passed. +- `rtk rg -n -P "state\\.availableCommands|s\\.availableCommands|m\\.availableCommands|defaultSessionRuntimeState\\.availableCommands|\\bsetAvailableCommands\\b|\\bclearAvailableCommands\\b|\\bAvailableCommandsState\\b|availableCommands: \\(typeof defaultSessionRuntimeState\\)|registerAvailableCommandsHandlers|handlers/available-commands|lib/ws/handlers/available-commands" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the available-commands absence assertions in + `purge-session.test.ts`. +- `rtk git diff --check` passed after the available commands cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/chat/empty-turn.spec.ts tests/chat/chat-status-bar.spec.ts tests/system/ws-event-accounting.spec.ts` + first failed because Query invalidation refetched session messages without + the synthetic empty-turn notice, then passed 10 desktop Docker tests after the + local empty-turn merge preservation fix. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-message-add-ws-gap.spec.ts` + first failed because the E2E page object selected a page-wide non-editable + TipTap node on mobile, then passed 1 mobile Docker test after scoping the + editor locator to the visible chat panel. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/chat/mobile-empty-turn.spec.ts tests/chat/mobile-message-add-ws-gap.spec.ts` + passed 2 mobile Docker tests after the available commands cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repository-branches.test.tsx lib/state/slices/workspace/workspace-slice.test.ts` + first failed because `useBranches` still required `StateProvider` and + `repositoryBranches` still existed in the workspace slice; this was the RED + step for the repository branches cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repository-branches.test.tsx lib/state/slices/workspace/workspace-slice.test.ts lib/query/query-options/query-options.test.ts lib/query/keys.test.ts components/task-create-dialog-repo-chips.test.tsx components/task-create-dialog-branch-utils.test.ts components/task-create-dialog-pill.test.tsx` + passed 7 files / 72 tests after adding coverage for Query-only id/path branch + reads, workspace-endpoint cold loads, and forced repository refresh. +- `cd apps/web && rtk pnpm typecheck` passed after the repository branches + cleanup. +- `cd apps/web && rtk pnpm exec eslint hooks/domains/workspace/use-repository-branches.ts hooks/domains/workspace/use-repository-branches.test.tsx components/task-create-dialog-state.ts components/task-create-dialog-repo-chips.tsx lib/state/slices/workspace/types.ts lib/state/slices/workspace/workspace-slice.ts lib/state/slices/workspace/workspace-slice.test.ts lib/state/default-state.ts lib/state/store-overrides.ts lib/state/hydration/hydrator.ts lib/state/store-reexports.ts lib/state/slices/index.ts lib/state/store.ts` + passed. +- `rtk rg -n -P "state\\.repositoryBranches|m\\.repositoryBranches|defaultWorkspaceState\\.repositoryBranches|\\bsetRepositoryBranches\\b|\\bRepositoryBranchesState\\b|repositoryBranches: \\(typeof defaultWorkspaceState\\)|itemsByRepositoryId\\[.*\\].*repositoryBranches|repositoryBranches\\.byRepository|Zustand cache.*branch|store dedupes by repositoryId" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned only the repository-branches absence assertion in + `workspace-slice.test.ts`. +- `rtk git diff --check` passed after the repository branches cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/create-task-branch-selector.spec.ts tests/task/create-task-url-reopen-no-branches.spec.ts tests/system/ws-event-accounting.spec.ts` + first failed on the refresh and URL-reopen regressions, then passed 15 + desktop Docker tests after forcing repository refresh and preserving + workspace-endpoint cold loads. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-create-task-remote-repo.spec.ts tests/task/mobile-task-list-search.spec.ts` + passed 2 mobile Docker tests after the repository branches cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repositories.test.tsx lib/query/seed.test.ts lib/state/slices/workspace/workspace-slice.test.ts` + first failed because repository hooks still required `StateProvider`, + initial-state repository lists did not seed the Query cache, and the + workspace slice still exposed `repositories`/`setRepositories`; this was the + RED step for the workspace repositories cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repositories.test.tsx lib/query/seed.test.ts lib/state/slices/workspace/workspace-slice.test.ts` + passed 3 files / 12 tests after the workspace repositories cleanup. +- `cd apps/web && rtk pnpm test hooks/domains/workspace/use-repositories.test.tsx lib/query/seed.test.ts lib/state/slices/workspace/workspace-slice.test.ts components/task-create-dialog-state.test.ts components/command-panel.test.ts components/task/task-changes-panel.test.ts components/task/changes-panel.test.ts components/task/changes-panel-pr-files.test.tsx components/task/task-session-sidebar-aggregate.test.ts components/task/recent-task-switcher-model.test.ts components/review/review-dialog.build-files.test.ts` + passed 11 files / 91 tests. +- `cd apps/web && rtk pnpm typecheck` passed after the workspace repositories + cleanup. +- Targeted eslint for repository hooks, query seed, workspace slice/store + plumbing, route seeders, and repository-heavy UI consumers passed. +- `rtk rg -n -P "state\\.repositories\\.itemsByWorkspaceId|\\bs\\.repositories\\.itemsByWorkspaceId|defaultWorkspaceState\\.repositories|\\brepositories: \\(typeof defaultWorkspaceState\\)|\\bRepositoriesState\\b|setRepositories:\\s*\\(workspaceId|setRepositories\\(workspaceId|state\\.setRepositories|\\bs\\.setRepositories|itemsByWorkspaceId\\[.*\\].*state\\.repositories" apps/web --glob '!dist/**' --glob '!e2e/test-results/**'` + returned no matches after the workspace repositories cleanup. +- `rtk git diff --check` passed after the workspace repositories cleanup. +- `cd apps/web && rtk pnpm e2e:docker tests/task/create-task-branch-selector.spec.ts tests/session/dockview-repository-scripts.spec.ts tests/git/git-changes-panel.spec.ts tests/task/task-list.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 40 desktop Docker tests after the workspace repositories cleanup. +- `cd apps/web && rtk pnpm e2e:docker --project=mobile-chrome tests/task/mobile-create-task-remote-repo.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/session/mobile-handoff.spec.ts` + passed 3 mobile Docker tests after the workspace repositories cleanup. +- RED legacy kanban WS mirror cleanup gate: + `rtk pnpm --dir apps/web test lib/ws/router.test.ts lib/ws/handlers/tasks.test.ts lib/ws/handlers/agent-session-kanban.test.ts` + failed because `kanban.update` was still registered, `task.updated` / + `task.deleted` still mutated legacy kanban mirrors, and + `session.state_changed` still patched task primary state into those mirrors. +- RED workspace deletion mirror gate: + `rtk pnpm --dir apps/web test lib/ws/handlers/workspaces.test.ts` failed + because deleting the active workspace still cleared `state.kanban`. +- `rtk make fmt` passed after the legacy kanban WS mirror cleanup. +- `rtk pnpm --dir apps/web test lib/ws/router.test.ts lib/ws/handlers/tasks.test.ts lib/ws/handlers/agent-session.test.ts lib/ws/handlers/agent-session-kanban.test.ts lib/ws/handlers/workspaces.test.ts lib/query/bridge/tasks.test.ts lib/query/bridge/index.test.ts lib/query/bridge/workspace.test.ts` + passed 8 files / 50 tests after the legacy kanban WS mirror cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the legacy kanban WS mirror + cleanup. +- Targeted eslint for the changed WS handlers/router/tests and bridge tests + passed, with the existing max-lines warning in + `lib/query/bridge/index.test.ts`. +- Production stale scans for `registerKanbanHandlers`, + `lib/ws/handlers/kanban`, direct WS `state.kanban` / + `state.kanbanMulti`, `setWorkflowSnapshot`, and + `syncKanbanPrimarySessionState` returned no production matches; remaining + matches are regression assertions in WS handler tests. +- `rtk git diff --check` passed after the legacy kanban WS mirror cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/task/task-list.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/session/session-tab-management.spec.ts tests/session/session-handoff.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 20 desktop Docker tests after the legacy kanban WS mirror cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/session/mobile-handoff.spec.ts` + passed 13 mobile Docker tests after the legacy kanban WS mirror cleanup. +- RED final store compatibility gate: + `rtk pnpm --dir apps/web test lib/routing/kanban-route-hydration.test.ts lib/state/default-state.test.ts lib/state/slices/kanban/kanban-slice.test.ts lib/query/seed.test.ts` + failed because route readiness, default-state merging, the kanban slice, and + query seed still accepted legacy `kanban` / `kanbanMulti` shapes. +- `rtk make fmt` passed after the final store compatibility cleanup. +- `rtk pnpm --dir apps/web test lib/state/default-state.test.ts lib/kanban/find-task.test.ts components/task/task-session-sidebar-aggregate.test.ts hooks/domains/kanban/use-workspace-sidebar-tasks.test.tsx hooks/domains/kanban/use-kanban-data.test.tsx components/app-sidebar/app-sidebar-new-task-item.test.tsx components/app-sidebar/sections/tasks-section.test.tsx components/session-commands.test.tsx hooks/use-task-removal.test.ts lib/query/seed.test.ts lib/routing/kanban-route-hydration.test.ts lib/state/slices/kanban/kanban-slice.test.ts hooks/use-tasks.test.ts components/task/mobile/session-task-switcher-sheet-hooks.test.tsx lib/ws/handlers/tasks.test.ts lib/ws/handlers/agent-session-kanban.test.ts lib/ws/handlers/workspaces.test.ts components/kanban-board-grid.test.tsx components/kanban-card-content.test.tsx` + passed 18 files / 85 tests. +- `rtk pnpm --dir apps/web typecheck` passed after the final store + compatibility cleanup. +- Targeted eslint for the final store cleanup files, Query seed/routing, + sidebar/session utility tests, and touched E2E specs passed. +- Production stale scans for active `state.kanban`, `kanbanMulti`, + `setWorkflowSnapshot`, deleted kanban actions, and E2E store kanban access + returned no production matches; remaining matches are client-only + `kanbanViewMode` / `kanbanPreviewedTaskId` and absence assertions. +- `rtk git diff --check` passed after the final store compatibility cleanup. +- `rtk pnpm --dir apps/web e2e:docker tests/task/task-list.spec.ts tests/kanban/kanban-board.spec.ts tests/kanban/cross-workflow-task-move.spec.ts tests/session/session-tab-management.spec.ts tests/system/ws-event-accounting.spec.ts tests/kanban/preview-primary-session.spec.ts` + passed 20 desktop Docker tests after the final store compatibility cleanup. +- `rtk pnpm --dir apps/web e2e:docker --project=mobile-chrome tests/kanban/mobile-kanban.spec.ts tests/task/mobile-sidebar-subtasks.spec.ts tests/session/mobile-handoff.spec.ts` + passed 13 mobile Docker tests after the final store compatibility cleanup. +- `rtk pnpm --dir apps/web test lib/query/seed.test.ts lib/query/bridge/index.test.ts components/task/new-session-dialog.test.tsx components/task/new-subtask-dialog.test.tsx lib/ws/handlers/agent-session.test.ts lib/state/slices/features/features-slice.test.ts components/app-sidebar/app-sidebar-workspace-picker.test.tsx src/boot-payload.test.ts` + passed 8 files / 73 tests after the feature flags and session worktrees + cleanup. +- `rtk pnpm --dir apps/web typecheck` passed after the feature flags and + session worktrees cleanup. +- Focused stale scans for removed feature/worktree store symbols, removed bridge + audit allowlist wording, removed Office store fields/actions, removed + workspace/kanban/repository/session-runtime store APIs, and deleted legacy WS + handler registrations returned no production server-state matches. +- `rtk pnpm --dir apps/web test hooks/domains/session/use-queue.test.ts lib/query/bridge/index.test.ts` + passed 2 files / 23 tests after renaming the Query-only queue cache helper to + avoid stale removed-action audit matches. +- `rtk pnpm --dir apps/web exec eslint --max-warnings 0 hooks/domains/session/use-queue.ts` + passed. +- `rtk pnpm --dir apps/web e2e:docker tests/session/new-session-dialog.spec.ts tests/task/file-tree-lazy-load.spec.ts tests/chat/message-queue.spec.ts tests/office/sidebar-office-gating.spec.ts tests/system/ws-event-accounting.spec.ts` + passed 18 Docker tests with strict WS accounting for the final Task 10 + cleanup gate. + +## Files Likely Touched + +- `apps/web/lib/state/store.ts` +- `apps/web/lib/state/default-state.ts` +- `apps/web/lib/state/hydration/*` +- `apps/web/lib/state/slices/**` +- `apps/web/lib/ws/router.ts` +- `apps/web/lib/ws/handlers/**` +- `apps/web/AGENTS.md` +- `docs/specs/ui/tanstack-query-server-state.md` + +## Dependencies + +- Tasks 05 through 09. + +## Inputs + +- Domain task outputs listing retained paths. +- Current `apps/web/AGENTS.md` "Data Flow Pattern" section. + +## Output Contract + +Update this task to `done`, include a retained-Zustand inventory, and update +the plan checkbox. diff --git a/docs/plans/tanstack-query-server-state/task-11-e2e-strict-qa.md b/docs/plans/tanstack-query-server-state/task-11-e2e-strict-qa.md new file mode 100644 index 0000000000..d7111a0dae --- /dev/null +++ b/docs/plans/tanstack-query-server-state/task-11-e2e-strict-qa.md @@ -0,0 +1,102 @@ +--- +id: "11-e2e-strict-qa" +title: "E2E strict QA" +status: done +wave: 4 +depends_on: ["10-remove-zustand-server-state"] +plan: "plan.md" +spec: "../../specs/ui/tanstack-query-server-state.md" +--- + +# Task 11: E2E Strict QA + +## Acceptance + +- `KANDEV_E2E_WS_ASSERT=1` is meaningful and green for focused migrated paths. +- Desktop and mobile coverage exists for migrated user-facing workflows. +- Full format, typecheck, test, lint, and focused E2E commands have been run or + blockers are documented. + +## Verification + +- `make fmt` +- `make typecheck test lint` +- `cd apps/web && pnpm e2e:docker --shards 3` +- `cd apps/web && pnpm e2e:docker --project mobile-chrome` +- `cd apps/web && pnpm e2e:docker --project routing` +- `cd apps/web && KANDEV_E2E_CONTAINERS=1 pnpm e2e --project=containers` + +## Verification Completed + +- `rtk make fmt` passed. +- `rtk make typecheck test lint` passed. +- `rtk pnpm --dir apps/web e2e:docker --shards 3` passed with strict WS + accounting: + - shard 1: 363 passed / 1 flaky retry + - shard 2: 370 passed + - shard 3: 369 passed +- `rtk pnpm --dir apps/web e2e:docker --project mobile-chrome` passed 78 + mobile Docker tests with strict WS accounting. +- `rtk pnpm --dir apps/web e2e:docker --project routing` passed 7 routing + Docker tests with strict WS accounting. +- `rtk env KANDEV_E2E_CONTAINERS=1 pnpm --dir apps/web e2e --project=containers` + passed 99 container-backed tests / 1 skipped after building the Linux + `mock-agent` helper required by the Docker/SSH executor project. + +Focused regression checks run during final QA: + +- `rtk pnpm --dir apps/web e2e:docker e2e/tests/pr/pr-multi-popover.spec.ts` + passed 3 desktop Docker tests. +- `rtk pnpm --dir apps/web e2e:docker e2e/tests/pr/pr-detail-auto-show.spec.ts e2e/tests/pr/pr-detail-dedup.spec.ts e2e/tests/pr/pr-detail-manual-open.spec.ts e2e/tests/pr/pr-detection.spec.ts e2e/tests/pr/pr-multi-popover.spec.ts` + passed 13 desktop Docker tests. +- `rtk env KANDEV_E2E_CONTAINERS=1 pnpm --dir apps/web e2e --project=containers e2e/tests/ssh/recovery.spec.ts:97` + passed after the metadata smoke test was changed to wait for the SSH + `ExecutorRunning` row it asserts instead of waiting on unrelated chat-session + completion. + +Residual risks: + +- No unresolved Task 11 blockers. The containers project keeps one destructive + Docker recovery test skipped by design. + +Post-rebase CI follow-up (2026-07-11): + +- Reproduced the desktop plan-toolbar failure in the CI Docker runtime. +- Added failing unit regressions for dropped plan implementation markers and + accepted walkthrough prompts missing from the latest-message Query cache. +- Fixed both cache paths; 2 focused unit files / 10 tests passed. +- Rebuilt Docker E2E passed the desktop plan-toolbar spec and 4 mobile + plan-toolbar/walkthrough tests with strict WS accounting. +- The lifecycle rebase resolution passed all 765 package tests. + +Post-rebase audit (2026-07-13): + +- Rebased onto `origin/main` at `c4dd45f9`; kanban conflicts retained Query + cache ownership and upstream WIP-limit behavior. +- Added a Query snapshot mapper regression for workflow-step WIP metadata. +- Focused mapper, hydration, Query bridge, and kanban hook tests passed 29 + assertions. The remaining upstream worktree/mobile changes did not add new + server-state reads or cache ownership paths. + +## Files Likely Touched + +- `apps/web/e2e/tests/**` +- `apps/web/e2e/helpers/**` +- `apps/web/e2e/fixtures/**` +- `.github/workflows/e2e-tests.yml` +- `docs/plans/tanstack-query-server-state/plan.md` +- `docs/specs/ui/tanstack-query-server-state.md` + +## Dependencies + +- Task 10. + +## Inputs + +- `/e2e`, `/mobile-parity`, and `/verify` skill guidance. +- All domain task summaries. + +## Output Contract + +Update this task and the plan to `done`, list commands run, summarize residual +risks, and attach failure artifacts or exact blockers if any check cannot run. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 4b5a460d41..0d82e6990e 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -96,6 +96,7 @@ Subscription quota tracking and per-agent cheap-model profile routing. | [comment-markdown](ui/comment-markdown.md) | shipped | | [empty-turn-notice](ui/empty-turn-notice.md) | shipped | | [slash-command-composer](ui/slash-command-composer.md) | shipped | +| [tanstack-query-server-state](ui/tanstack-query-server-state.md) | done | ## system-page/ — operational diagnostics & maintenance UI diff --git a/docs/specs/ui/tanstack-query-server-state.md b/docs/specs/ui/tanstack-query-server-state.md new file mode 100644 index 0000000000..46e66dbb44 --- /dev/null +++ b/docs/specs/ui/tanstack-query-server-state.md @@ -0,0 +1,340 @@ +--- +status: done +created: 2026-06-23 +owner: cfl +--- + +# TanStack Query Server State + +## Why + +Kandev's web UI currently stores most server-owned data in Zustand and refreshes +it through a mix of boot hydration, component fetch effects, WebSocket handlers, +and page-local refetch callbacks. That makes live data freshness hard to prove: +an event can reach the browser but update a store path the mounted UI no longer +reads, or a first-paint payload can be followed by a redundant fetch that races +with newer WebSocket state. + +This feature makes TanStack Query the client authority for server state while +keeping Zustand for client-only UI state. The user-visible goal is that every +route still paints from Go boot data, stays live through WebSocket updates, and +recovers from missed events through explicit refetch/invalidation paths without +manual refresh. + +## What + +- Server-state reads use TanStack Query query keys and query option factories, + not component-local fetch effects or Zustand server-state slices. +- Go boot payloads and `/api/v1/app-state` route bootstraps seed the query cache + before dependent page effects run, preserving first-paint data behavior. +- WebSocket notifications update or invalidate the matching query cache entry. + The UI reads the cache entry that the WebSocket bridge writes. +- Mutations update query cache through optimistic updates, mutation responses, or + targeted invalidation. Failed optimistic updates roll back and surface the same + toasts/errors as the current UI. +- Zustand remains available for client-only state: active route/sidebar state, + dock layout, editor state, transient form state, terminal affordances, local + preferences, and in-browser persistence that is not server-owned. +- WebSocket event accounting is enforced in E2E: strict runs detect events the + backend sent but the frontend did not parse, and bridge audit detects parsed + server-state events that did not touch the query cache unless explicitly + allowlisted. +- The migration lands as one PR, but implementation is organized by domain so + each domain can be reviewed and tested independently inside that PR. +- Existing desktop and mobile workflows continue to use the same pages, routes, + and visible controls. The migration must not introduce desktop-only behavior. + +## Data Model + +This feature introduces no durable database tables. It changes frontend runtime +state ownership. + +### Query Cache + +TanStack Query owns server-state cache entries in the browser. Keys are array +tuples with stable domain prefixes: + +```text +["features"] +["workspaces"] +["workspaces", workspaceId, "repos"] +["kanban", "workflows"] +["kanban", "workflows-list", workspaceId] +["session", sessionId, "messages"] +["session", sessionId, "turns"] +["session", "byTask", taskId] +["session", "byId", sessionId] +["office", workspaceId, "dashboard"] +["office", workspaceId, "tasksPaginated", filters] +["github", workspaceId, "prs"] +["settings", "userSettings"] +``` + +The implementation may add additional keys, but every server-state key MUST have +a typed key factory and a query option factory. Filter objects in keys MUST be +JSON-serializable. Sorting/grouping that is purely presentational SHOULD be done +through selectors rather than new fetch keys. + +### Zustand State + +Zustand is reduced to client-only state plus transitional indexes needed by UI +logic. Any remaining server-state path MUST be listed in the implementation +plan as either migrated, explicitly retained as client-only, or temporarily +retained with a removal task. + +Examples of client-only retained paths include: + +- route and selection state such as active task/session/workspace +- sidebar view order, collapsed subtasks, preview panel, dockview layout +- editor/comment draft state and sessionStorage-backed annotations +- local terminal interaction affordances +- WebSocket connection status + +### WebSocket Accounting State + +E2E-only accounting state is in-memory: + +- backend `wsSentLog`: bounded ring buffer of sent WebSocket envelopes +- frontend `WsAccount`: bounded ring buffers of parsed envelopes by connection + and by session +- frontend bridge audit: bounded ring buffer of query-bridge handler outcomes + +No accounting buffer survives backend/browser restart. + +## API Surface + +No product HTTP API is added for TanStack Query itself. Existing HTTP and +WebSocket contracts continue to serve the same route data. + +### Frontend Query Surface + +Each domain exposes: + +```ts +qk..(...) +QueryOptions.(...) +``` + +Components and hooks call `useQuery`, `useInfiniteQuery`, `useMutation`, and +`useQueryClient` through domain hooks rather than ad hoc fetch effects. + +### Boot Payload Seeding + +`window.__KANDEV_BOOT_PAYLOAD__` and `/api/v1/app-state?path=...` provide route +data and `Partial` today. During this migration they also seed the +query cache from the same data before child hooks can decide to fetch. + +The Go boot payload remains the first-paint source. A hard reload on a route +with preloaded data MUST NOT show an empty state that currently paints from +boot-hydrated Zustand. + +### WebSocket Envelope Accounting + +Strict E2E accounting adds fields to outbound WebSocket envelopes: + +```json +{ + "connection_id": "server-generated socket id", + "connection_seq": 42, + "session_seq": 7 +} +``` + +`session_seq` is present only when the server can identify a session scope. +The backend exposes an E2E-only endpoint under `/api/v1/_test/ws-sent` for +fixture comparison. The endpoint is mounted only when the E2E mock/test harness +is enabled. + +### Bridge Audit + +When E2E mock mode is enabled, the browser exposes bounded audit helpers: + +```ts +window.__kandev_bridge_audit__?.() +window.__kandev_bridge_audit_clear__?.() +``` + +The audit records action, session/task ids when present, whether the bridge +touched the query cache, and mutation count. Production builds do not expose or +populate these helpers. + +## State Machine + +### Query Data Lifecycle + +| State | Entered when | Outgoing transitions | +|---|---|---| +| `seeded` | boot payload or route bootstrap writes initial query data | `fresh`, `stale`, `inactive` | +| `fresh` | `staleTime` has not elapsed or data was just written | invalidation -> `stale`; no observers -> `inactive` | +| `stale` | key is invalidated or `staleTime` elapses | observer refetch -> `fresh`; no observers -> `inactive` | +| `inactive` | no mounted observers use the key | remount -> `fresh` or refetch; `gcTime` elapsed -> removed | +| `optimistic` | mutation writes a temporary value | success -> `fresh`; failure -> rollback + error | + +Default query behavior is conservative for Kandev: + +- normal server state defaults to `staleTime: 30_000` +- active session chat/turn state uses a longer stale window so background + refetches do not clobber live streams +- global refetch-on-window-focus/reconnect is disabled by default; specific + hooks opt in when recovery requires it +- mutations do not retry unless the mutation is known to be idempotent + +### WebSocket Event Lifecycle + +1. Backend stamps an outbound envelope and records it in `wsSentLog`. +2. Browser parses the envelope and records it in `WsAccount`. +3. WebSocket client dispatches notification handlers. +4. Query bridge writes or invalidates the matching query cache. +5. Mounted UI observes the same query key and re-renders. +6. E2E fixture compares sent vs parsed envelopes and parsed vs applied bridge + entries at test teardown. + +## Failure Modes + +- **Boot payload missing or malformed**: route falls back to the current + `/api/v1/app-state` or domain fetch path, then seeds the query cache. +- **HTTP query failure**: query exposes loading/error state and the existing UI + error/toast behavior remains visible. +- **Mutation failure**: optimistic cache changes roll back and the same user + draft/input restoration rules remain in place. +- **WebSocket reconnect**: client resubscribes to task/session/run/user/system + topics and invalidates affected query keys when reconnect recovery requires a + server snapshot. +- **WebSocket event dropped in E2E**: strict accounting fails the test and names + the missing event/session. +- **WebSocket event parsed but not applied**: bridge audit fails the test unless + the action is documented in the bridge skipped allowlist. +- **High-frequency streams**: shell/process/terminal streams do not write every + chunk through TanStack Query if that would create a render bottleneck. They + use bounded stream buffers or retained terminal transport state, and the + exception is documented in the bridge allowlist. + +## Persistence Guarantees + +- Query cache is in-memory only. It is reseeded from Go boot payloads, app-state + fetches, HTTP queries, and WebSocket notifications after reload. +- User-visible durable state remains on the backend, in existing tables/files, + or in already-approved browser storage paths. +- E2E accounting buffers are test-only and reset between browser contexts and + backend restarts. +- WebSocket subscriptions are not durable. On reconnect, the client resends + subscription intent and refetches/invalidate query keys as needed. + +## Scenarios + +- **GIVEN** a task page served by Go boot payload, **WHEN** the React app mounts, + **THEN** the task, task sessions, messages, worktree data, repositories, and + relevant settings are visible from the query cache before child hooks issue + duplicate fetches. + +- **GIVEN** a kanban board already hydrated from the boot payload, **WHEN** + `task.updated` arrives over WebSocket, **THEN** the matching kanban query cache + entry updates and the visible card changes without a manual refresh. + +- **GIVEN** an office dashboard is open, **WHEN** an agent run creates activity + and updates metrics, **THEN** dashboard query keys are invalidated or patched + and cards/activity update without polling. + +- **GIVEN** an office tasks list has loaded multiple pages, **WHEN** a task is + created, moved, or updated, **THEN** the paginated query cache invalidates or + reconciles without losing pagination state or current filters. + +- **GIVEN** a session receives `session.message.added`, **WHEN** the event is + parsed, **THEN** the message-list query cache updates and both desktop and + mobile chat surfaces render the new message. + +- **GIVEN** a sent user prompt's `session.message.added` event is intentionally + dropped in an E2E test, **WHEN** the prompt send request resolves, **THEN** the + accepted prompt remains visible through mutation response or refetch recovery. + +- **GIVEN** two sessions are subscribed concurrently, **WHEN** WebSocket events + arrive interleaved, **THEN** connection sequence and per-session sequence + accounting detect missing or misrouted events. + +- **GIVEN** a WebSocket event with `session_id` is parsed in strict E2E mode, + **WHEN** no query bridge handler touches the cache, **THEN** the test fails + unless the action is documented as Zustand-only/client-only/high-frequency. + +- **GIVEN** a user switches workspaces, **WHEN** workspace-scoped query keys are + active, **THEN** stale data from the previous workspace is not rendered for the + new workspace. + +- **GIVEN** the browser reloads while an agent session has settled, **WHEN** the + route boots again, **THEN** query cache is reseeded from backend state and the + chat/session UI matches the persisted session without waiting for another WS + event. + +## Out Of Scope + +- Backend event replay or guaranteed catch-up after disconnect. +- Replacing Zustand for client-only UI state. +- Rewriting product UI layouts or navigation beyond state-source changes. +- Persisting TanStack Query cache to localStorage/sessionStorage. +- Production WebSocket ack metrics; this spec requires E2E accounting, not a + production observability project. + +## Verification + +Rebase follow-up completed locally on 2026-06-27 after rebasing the migration +branch onto `origin/main` at `7728ddb3b`: + +- Migrated the upstream task repository WebSocket preservation fix into the + Query bridge and removed the legacy `task-repositories` WebSocket helper. +- Migrated the new session layout active-session lookup to query-backed + `useSession`, keeping the upstream rowless-session fallback behavior. +- Updated rebased tests to seed/query through TanStack Query instead of deleted + Zustand server-state slices. +- `rtk make fmt` passed. +- `rtk pnpm --dir apps/web typecheck` passed. +- `rtk pnpm --dir apps/web lint` passed. +- Focused web unit slice passed 16 files / 95 tests, covering Query bridge, + session state/layout, topbar metrics, mobile selectors, PR chip, and rebased + mobile/task components. +- Docker E2E desktop focused slice passed 8 tests with strict WS accounting: + `rtk pnpm --dir apps/web e2e:docker tests/session/multi-session.spec.ts tests/system/ws-event-accounting.spec.ts tests/task/repository-selector-scroll.spec.ts`. +- Docker E2E mobile focused slice passed 8 tests with strict WS accounting: + `rtk pnpm --dir apps/web e2e:docker --no-build --project mobile-chrome tests/chat/mobile-model-selector.spec.ts tests/cli-mode/mobile-passthrough-composer.spec.ts tests/pr/mobile-pr-ci-chip.spec.ts tests/mobile-zoom.spec.ts`. +- Docker E2E multi-session UX slice passed 7 tests with strict WS accounting: + `rtk pnpm --dir apps/web e2e:docker --no-build tests/session/multi-session-ux.spec.ts`. + +Final strict QA completed locally: + +- `rtk make fmt` passed. +- `rtk make typecheck test lint` passed. +- `rtk pnpm --dir apps/web e2e:docker --shards 3` passed the full desktop + Docker suite with strict WS accounting. +- `rtk pnpm --dir apps/web e2e:docker --project mobile-chrome` passed 78 mobile + Docker tests with strict WS accounting. +- `rtk pnpm --dir apps/web e2e:docker --project routing` passed 7 routing + Docker tests with strict WS accounting. +- `rtk env KANDEV_E2E_CONTAINERS=1 pnpm --dir apps/web e2e --project=containers` + passed 99 container-backed Docker/SSH executor tests / 1 skipped. + +Post-rebase CI follow-up completed locally on 2026-07-11 after rebasing onto +`origin/main` at `9094659a`: + +- Preserved both upstream cancel-release handling and the migration's missing + session recovery in the only rebase conflict; the lifecycle package passed + 765 tests. +- Fixed `task.plan.updated` Query bridge projection so implementation-started + markers remain visible immediately after the toolbar action. +- Made accepted walkthrough prompts cancel an older latest-message fetch and + update the Query cache as well as the retained live message index. +- Focused unit regressions passed 2 files / 10 tests. +- Docker E2E passed the desktop plan-toolbar implementation spec and the rebuilt + mobile plan-toolbar/walkthrough slice (4 tests) with strict WS accounting. +- A pre-fix mobile stress reproduction also completed 20/20 focused repetitions; + the walkthrough cache race was confirmed by the failing unit regression rather + than a deterministic isolated browser failure. + +Post-rebase audit completed locally on 2026-07-13 after rebasing onto +`origin/main` at `c4dd45f9`: + +- Resolved the kanban conflict by retaining Query-backed repository and workflow + snapshot reads while preserving upstream WIP-limit presentation. +- Migrated the upstream workflow-step WIP fields through the Query snapshot + mapper; focused bridge, hydration, snapshot, and kanban hook tests passed + 29 assertions. +- Reviewed the remaining upstream worktree and mobile-action changes. They add + local UI/configuration behavior only; repository updates already use the + workspace Query bridge, so no further server-state migration was required.