diff --git a/apps/backend/internal/backendapp/boot_state.go b/apps/backend/internal/backendapp/boot_state.go index 1a6a9c9c31..854a626723 100644 --- a/apps/backend/internal/backendapp/boot_state.go +++ b/apps/backend/internal/backendapp/boot_state.go @@ -121,6 +121,7 @@ func bootInitialState( builder.addOfficeRouteState(ctx, req, state) } builder.addQuickChatState(ctx, req, state, route) + builder.addNeedsYouInboxState(ctx, req, state, route) return state } diff --git a/apps/backend/internal/backendapp/boot_state_needs_you_inbox_test.go b/apps/backend/internal/backendapp/boot_state_needs_you_inbox_test.go new file mode 100644 index 0000000000..5469cb8339 --- /dev/null +++ b/apps/backend/internal/backendapp/boot_state_needs_you_inbox_test.go @@ -0,0 +1,237 @@ +package backendapp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/config" + "github.com/kandev/kandev/internal/task/models" + sqlitetaskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" + userstore "github.com/kandev/kandev/internal/user/store" + "github.com/kandev/kandev/internal/webapp" +) + +type needsYouInboxBootPayload struct { + NeedsYouInboxBoot *struct { + WorkspaceID string `json:"workspaceId"` + Count int `json:"count"` + HasMore bool `json:"hasMore"` + NextSnoozeExpiry *string `json:"nextSnoozeExpiry"` + } `json:"needsYouInboxBoot"` +} + +func decodeNeedsYouInboxBoot(t *testing.T, state map[string]any) needsYouInboxBootPayload { + t.Helper() + raw, err := json.Marshal(state) + if err != nil { + t.Fatalf("Marshal state: %v", err) + } + var decoded needsYouInboxBootPayload + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("Unmarshal state: %v", err) + } + return decoded +} + +// seedInboxAnswerableBundle creates one answerable clarification bundle (a +// task, session, current turn, and a clarification_request message) in +// workspaceID -- the exact shape ListUnresolvedClarificationBundles requires: +// a resolvable pending_id and question_id, no parent_question marker, the +// message on the session's current (open) turn, and a non-terminal session. +func seedInboxAnswerableBundle(t *testing.T, repo *sqlitetaskrepo.Repository, n int, workspaceID string, at time.Time) (pendingID string) { + t.Helper() + ctx := context.Background() + id := fmt.Sprintf("inbox-%d", n) + pendingID = "pending-" + id + if err := repo.CreateTask(ctx, &models.Task{ID: "task-" + id, WorkspaceID: workspaceID, Title: "inbox task"}); err != nil { + t.Fatalf("CreateTask %s: %v", id, err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-" + id, TaskID: "task-" + id}); err != nil { + t.Fatalf("CreateTaskSession %s: %v", id, err) + } + if err := repo.CreateTurn(ctx, &models.Turn{ + ID: "turn-" + id, TaskSessionID: "session-" + id, TaskID: "task-" + id, + StartedAt: at, CreatedAt: at, UpdatedAt: at, + }); err != nil { + t.Fatalf("CreateTurn %s: %v", id, err) + } + if err := repo.CreateMessage(ctx, &models.Message{ + ID: "msg-" + id, TaskSessionID: "session-" + id, TaskID: "task-" + id, TurnID: "turn-" + id, + AuthorType: models.MessageAuthorAgent, Content: "q", Type: models.MessageTypeClarificationRequest, + RequestsInput: true, + Metadata: map[string]interface{}{ + "pending_id": pendingID, + "question_id": "q1", + "question": map[string]interface{}{ + "id": "q1", + "title": "title", + "prompt": "prompt", + }, + }, + CreatedAt: at, UpdatedAt: at, + }); err != nil { + t.Fatalf("CreateMessage %s: %v", id, err) + } + return pendingID +} + +func TestBootNeedsYouInbox_FlagOff_KeyAbsent(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + if err := harness.taskRepo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Only"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + params := routeParams{ + taskSvc: harness.taskSvc, + taskRepo: harness.taskRepo, + userCtrl: harness.userCtrl, + features: config.FeaturesConfig{NeedsYouInbox: false}, + } + state := bootInitialState(t.Context(), req, params, webapp.RouteClassification{Route: webapp.RouteHome}) + + decoded := decodeNeedsYouInboxBoot(t, state) + if decoded.NeedsYouInboxBoot != nil { + t.Fatalf("needsYouInboxBoot = %+v, want absent when the flag is off (AC .13: absent, not zero)", decoded.NeedsYouInboxBoot) + } +} + +func TestBootNeedsYouInbox_FlagOnNoWorkspace_KeyAbsent(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + // newBootStateTestHarness's fresh repository auto-provisions one unowned + // workspace (ensureDefaultWorkspace), and an unowned workspace stays + // visible to every caller until claimed. So the real "no active + // workspace resolves" path isn't an empty workspace table -- it's a + // scoped, authenticated caller who owns nothing and has no membership, + // once that default workspace has been claimed by somebody else (the same + // ClaimUnownedWorkspaces the auth setup wizard uses to promote the + // single-user instance's admin). + if err := harness.taskRepo.ClaimUnownedWorkspaces(ctx, "workspace-owner"); err != nil { + t.Fatalf("ClaimUnownedWorkspaces: %v", err) + } + + reqCtx := authn.WithIdentity(t.Context(), authn.Identity{UserID: "outsider", Role: authn.RoleMember}) + req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(reqCtx) + params := routeParams{ + taskSvc: harness.taskSvc, + taskRepo: harness.taskRepo, + userCtrl: harness.userCtrl, + features: config.FeaturesConfig{NeedsYouInbox: true}, + } + state := bootInitialState(reqCtx, req, params, webapp.RouteClassification{Route: webapp.RouteHome}) + + decoded := decodeNeedsYouInboxBoot(t, state) + if decoded.NeedsYouInboxBoot != nil { + t.Fatalf("needsYouInboxBoot = %+v, want absent when no active workspace resolves", decoded.NeedsYouInboxBoot) + } +} + +func TestBootNeedsYouInbox_FlagOnWithWorkspace_SeedsBoundedZeroCount(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + if err := harness.taskRepo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Only"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + params := routeParams{ + taskSvc: harness.taskSvc, + taskRepo: harness.taskRepo, + userCtrl: harness.userCtrl, + features: config.FeaturesConfig{NeedsYouInbox: true}, + } + state := bootInitialState(t.Context(), req, params, webapp.RouteClassification{Route: webapp.RouteHome}) + + decoded := decodeNeedsYouInboxBoot(t, state) + if decoded.NeedsYouInboxBoot == nil { + t.Fatal("needsYouInboxBoot absent, want it present once the flag is on and a workspace resolves") + } + boot := decoded.NeedsYouInboxBoot + if boot.WorkspaceID != "ws-1" { + t.Errorf("workspaceId = %q, want ws-1 (the sole/resolved workspace)", boot.WorkspaceID) + } + if boot.Count != 0 || boot.HasMore { + t.Errorf("count/hasMore = %d/%v, want 0/false against an empty workspace", boot.Count, boot.HasMore) + } + if boot.NextSnoozeExpiry != nil { + t.Errorf("nextSnoozeExpiry = %v, want nil against an empty workspace", *boot.NextSnoozeExpiry) + } +} + +// TestBootNeedsYouInbox_FlagOnWithBundles_SeedsRealCounts guards against a +// hardcoded-zero implementation: it seeds one more answerable bundle than the +// producer's bounded page limit (default 50, mirrored from defaultInboxLimit) +// plus one snoozed bundle, so count, hasMore, and nextSnoozeExpiry must all +// come from a real read. It also exercises formatOptionalBootTime's non-nil +// branch, which the zero-count test above cannot reach. +func TestBootNeedsYouInbox_FlagOnWithBundles_SeedsRealCounts(t *testing.T) { + const defaultInboxLimit = 50 + harness := newBootStateTestHarness(t) + ctx := context.Background() + if err := harness.taskRepo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Only"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + now := time.Now().UTC() + for i := 0; i < defaultInboxLimit+1; i++ { + seedInboxAnswerableBundle(t, harness.taskRepo, i, "ws-1", now.Add(time.Duration(i)*time.Second)) + } + snoozedPendingID := seedInboxAnswerableBundle(t, harness.taskRepo, defaultInboxLimit+1, "ws-1", now.Add(time.Hour)) + // The production write path (httpUpsertInboxSidecar) stamps snooze_until + // from the handler's own unmodified time.Now() clock, and every reader + // (including this boot-hydration producer) compares against that same + // unmodified time.Now() -- so the fixture must match that convention + // rather than normalizing to UTC, or the stored and compared timestamps + // carry different zone offsets and the TEXT-column ">" comparison stops + // being chronological. 24 hours out (not 1) keeps the expiry clear of a + // host's DST fall-back window, where a 1-hour offset can render to nearly + // the same local wall-clock text as the comparison time. + snoozeUntil := time.Now().Add(24 * time.Hour) + if err := harness.taskRepo.UpsertClarificationInboxSidecar( + ctx, userstore.DefaultUserID, snoozedPendingID, models.ClarificationSidecarSnoozed, &snoozeUntil, now, + ); err != nil { + t.Fatalf("UpsertClarificationInboxSidecar: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/", nil) + params := routeParams{ + taskSvc: harness.taskSvc, + taskRepo: harness.taskRepo, + userCtrl: harness.userCtrl, + features: config.FeaturesConfig{NeedsYouInbox: true}, + } + state := bootInitialState(t.Context(), req, params, webapp.RouteClassification{Route: webapp.RouteHome}) + + decoded := decodeNeedsYouInboxBoot(t, state) + if decoded.NeedsYouInboxBoot == nil { + t.Fatal("needsYouInboxBoot absent, want it present once the flag is on and a workspace resolves") + } + boot := decoded.NeedsYouInboxBoot + // defaultInboxLimit answerable bundles are visible; the snoozed one is + // hidden from the count but still owns the earliest snooze expiry. + if boot.Count != defaultInboxLimit { + t.Errorf("count = %d, want %d (bounded page of the answerable bundles)", boot.Count, defaultInboxLimit) + } + if !boot.HasMore { + t.Error("hasMore = false, want true: more answerable bundles exist beyond the bounded page") + } + if boot.NextSnoozeExpiry == nil { + t.Fatal("nextSnoozeExpiry = nil, want the snoozed bundle's expiry") + } + gotExpiry, err := time.Parse(time.RFC3339, *boot.NextSnoozeExpiry) + if err != nil { + t.Fatalf("nextSnoozeExpiry %q not RFC3339: %v", *boot.NextSnoozeExpiry, err) + } + // RFC3339 (unlike RFC3339Nano) drops sub-second precision, so compare at + // second granularity rather than requiring an exact instant match. + if !gotExpiry.Equal(snoozeUntil.Truncate(time.Second)) { + t.Errorf("nextSnoozeExpiry = %v, want %v", gotExpiry, snoozeUntil) + } +} diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 7c64a81cd0..50583199e8 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -5,15 +5,18 @@ import ( "net/http" "net/url" "strings" + "time" "go.uber.org/zap" + "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/common/httpcookie" officedashboard "github.com/kandev/kandev/internal/office/dashboard" taskdto "github.com/kandev/kandev/internal/task/dto" taskmodels "github.com/kandev/kandev/internal/task/models" userdto "github.com/kandev/kandev/internal/user/dto" usermodels "github.com/kandev/kandev/internal/user/models" + "github.com/kandev/kandev/internal/webapp" ) // tasksPageBootData builds the tasks page boot payload: workspaces, repositories, workflows, steps, tasks, and the user's settings. @@ -616,6 +619,52 @@ func (b bootStateBuilder) officeDashboard(ctx context.Context, activeID string) return officedashboard.NewDashboardResponse(data, summaries) } +// addNeedsYouInboxState adds the Needs-you Inbox boot-hydration producer: the +// second of the count's two allowed producers (needs-you-inbox +// design-01#Data-and-contracts), run at the same default limit and +// workspace/sidecar scope as the list endpoint, for the same active workspace +// quick chat already resolves for this route. Absent when the flag is off or +// no active workspace resolves, so the client can tell "not seeded" from "zero". +func (b bootStateBuilder) addNeedsYouInboxState( + ctx context.Context, + req *http.Request, + state map[string]any, + route webapp.RouteClassification, +) { + if !b.p.features.NeedsYouInbox || b.p.taskRepo == nil { + return + } + workspaceID := b.resolveQuickChatWorkspaceID(ctx, req, state, route) + if workspaceID == "" { + return + } + count, hasMore, nextSnoozeExpiry, err := clarification.InboxBootSummary( + ctx, b.p.taskRepo, workspaceID, clarification.InboxUserID(ctx), time.Now().UTC(), + ) + if err != nil { + b.logBootError("get needs-you inbox boot summary", err) + return + } + state["needsYouInboxBoot"] = map[string]any{ + bootStateKeyWorkspaceID: workspaceID, + "count": count, + "hasMore": hasMore, + "nextSnoozeExpiry": formatOptionalBootTime(nextSnoozeExpiry), + } +} + +// formatOptionalBootTime renders a *time.Time as an RFC3339 string, or nil +// (JSON null) when absent -- mirrors the list endpoint's own +// next_snooze_expiry encoding so the boot value and every later read agree on +// format (needs-you-inbox design-01#Data-and-contracts). +func formatOptionalBootTime(t *time.Time) *string { + if t == nil { + return nil + } + s := t.UTC().Format(time.RFC3339) + return &s +} + // mapUserSettingsState converts a user settings response to the SPA boot shape, preferring the given workspace id. func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID string) map[string]any { settings := response.Settings diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index d06ea40875..d7eb0dbb68 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1404,6 +1404,9 @@ func registerSecondaryRoutes( p.eventBus, clarificationResolver, p.log, + p.taskSvc, + p.taskRepo, + p.features.NeedsYouInbox, ) p.log.Debug("Registered Clarification handlers (HTTP)") diff --git a/apps/backend/internal/clarification/handlers.go b/apps/backend/internal/clarification/handlers.go index 44835e067d..ae66033861 100644 --- a/apps/backend/internal/clarification/handlers.go +++ b/apps/backend/internal/clarification/handlers.go @@ -169,6 +169,19 @@ type Handlers struct { eventBus EventBus resolver *Resolver logger *logger.Logger + + // inboxTasks/inboxBundles back the Needs-you Inbox endpoints + // (inbox_handlers.go) only; the original clarification endpoints above + // never read them. + inboxTasks inboxTaskService + inboxBundles inboxBundleStore + // now always returns a UTC instant. The sidecar's snooze_until is + // persisted and compared as SQLite TEXT (mattn/go-sqlite3 formats a + // time.Time with whatever offset it carries), so a non-UTC value here + // would make snooze expiry a lexical string comparison across mismatched + // offsets rather than a true instant comparison — wrong exactly at a DST + // transition (AC .24/.32, "evaluated against server time"). + now func() time.Time } // NewHandlers creates new clarification handlers. @@ -180,6 +193,8 @@ func NewHandlers( eventBus EventBus, resolver *Resolver, log *logger.Logger, + inboxTasks inboxTaskService, + inboxBundles inboxBundleStore, ) *Handlers { return &Handlers{ store: store, @@ -189,10 +204,16 @@ func NewHandlers( eventBus: eventBus, resolver: resolver, logger: log.WithFields(zap.String("component", "clarification-handlers")), + inboxTasks: inboxTasks, + inboxBundles: inboxBundles, + now: func() time.Time { return time.Now().UTC() }, } } -// RegisterRoutes registers clarification HTTP routes. +// RegisterRoutes registers clarification HTTP routes. needsYouInboxEnabled +// gates only the /api/v1/clarification-inbox group: the original +// /api/v1/clarification routes stay unconditional, matching every agent's +// existing clarification-request/respond flow regardless of the flag. func RegisterRoutes( router *gin.Engine, store *Store, @@ -202,14 +223,25 @@ func RegisterRoutes( eventBus EventBus, resolver *Resolver, log *logger.Logger, + inboxTasks inboxTaskService, + inboxBundles inboxBundleStore, + needsYouInboxEnabled bool, ) { - h := NewHandlers(store, hub, messageCreator, repo, eventBus, resolver, log) + h := NewHandlers(store, hub, messageCreator, repo, eventBus, resolver, log, inboxTasks, inboxBundles) api := router.Group("/api/v1/clarification") api.POST("/request", h.httpCreateRequest) api.GET("/:id", h.httpGetRequest) api.GET("/:id/wait", h.httpWaitForResponse) api.POST("/:id/respond", h.httpRespond) api.POST("/:id/cancel", h.httpCancelRequest) + + if needsYouInboxEnabled { + inbox := router.Group("/api/v1/clarification-inbox") + inbox.GET("", h.httpListInbox) + inbox.GET("/hidden", h.httpListInboxHidden) + inbox.PUT("/sidecar/:pendingID", h.httpUpsertInboxSidecar) + inbox.DELETE("/sidecar/:pendingID", h.httpDeleteInboxSidecar) + } } // CreateRequestBody is the request body for creating a clarification request. diff --git a/apps/backend/internal/clarification/handlers_authz_test.go b/apps/backend/internal/clarification/handlers_authz_test.go index 250b83a493..af524a0196 100644 --- a/apps/backend/internal/clarification/handlers_authz_test.go +++ b/apps/backend/internal/clarification/handlers_authz_test.go @@ -41,7 +41,7 @@ func setupAuthzHandler(t *testing.T, msgs map[string][]*taskmodels.Message, auth eventBus := &stubEventBus{} messageCreator := &stubMessageCreator{} resolver := NewResolver(store, repo, messageCreator, authorizer, eventBus, eventBus, nil, logger.Default()) - h := NewHandlers(store, nil, messageCreator, repo, eventBus, resolver, logger.Default()) + h := NewHandlers(store, nil, messageCreator, repo, eventBus, resolver, logger.Default(), nil, nil) return h, store } @@ -296,7 +296,7 @@ func TestHttpGetRequest_RepositoryFailure_500NotSilent404(t *testing.T) { eventBus := &stubEventBus{} messageCreator := &stubMessageCreator{} resolver := NewResolver(store, repo, messageCreator, &stubAuthorizer{}, eventBus, eventBus, nil, logger.Default()) - h := NewHandlers(store, nil, messageCreator, repo, eventBus, resolver, logger.Default()) + h := NewHandlers(store, nil, messageCreator, repo, eventBus, resolver, logger.Default(), nil, nil) rec := runGet(t, h, "pending-repo-failure") if rec.Code != http.StatusInternalServerError { @@ -314,7 +314,7 @@ func TestHttpWaitForResponse_RepositoryFailure_500NotSilent404(t *testing.T) { eventBus := &stubEventBus{} messageCreator := &stubMessageCreator{} resolver := NewResolver(store, repo, messageCreator, &stubAuthorizer{}, eventBus, eventBus, nil, logger.Default()) - h := NewHandlers(store, nil, messageCreator, repo, eventBus, resolver, logger.Default()) + h := NewHandlers(store, nil, messageCreator, repo, eventBus, resolver, logger.Default(), nil, nil) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() @@ -334,7 +334,7 @@ func TestHttpCancelRequest_RepositoryFailure_500NotSilent404(t *testing.T) { eventBus := &stubEventBus{} messageCreator := &stubMessageCreator{} resolver := NewResolver(store, repo, messageCreator, &stubAuthorizer{}, eventBus, eventBus, nil, logger.Default()) - h := NewHandlers(store, nil, messageCreator, repo, eventBus, resolver, logger.Default()) + h := NewHandlers(store, nil, messageCreator, repo, eventBus, resolver, logger.Default(), nil, nil) rec := runCancel(t, h, "pending-repo-failure") if rec.Code != http.StatusInternalServerError { diff --git a/apps/backend/internal/clarification/inbox_boot_test.go b/apps/backend/internal/clarification/inbox_boot_test.go new file mode 100644 index 0000000000..b25c34ab65 --- /dev/null +++ b/apps/backend/internal/clarification/inbox_boot_test.go @@ -0,0 +1,64 @@ +package clarification + +import ( + "context" + "errors" + "testing" + + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +func TestInboxBootSummary_UsesBundleCountAndHiddenSummary(t *testing.T) { + bundles := &fakeInboxBundleStore{ + page: &taskmodels.ClarificationBundlePage{ + Bundles: []taskmodels.ClarificationBundleSummary{{PendingID: "p1"}, {PendingID: "p2"}}, + HasMore: true, + }, + summary: taskmodels.ClarificationInboxHiddenSummary{ + HiddenCount: 3, + NextSnoozeExpiry: &testInboxNow, + }, + } + + count, hasMore, nextSnoozeExpiry, err := InboxBootSummary(context.Background(), bundles, "workspace-1", "user-1", testInboxNow) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if count != 2 { + t.Errorf("count = %d, want 2 (len(page.Bundles), not the hidden-filtered view count)", count) + } + if !hasMore { + t.Error("hasMore = false, want true from page.HasMore") + } + if nextSnoozeExpiry == nil || !nextSnoozeExpiry.Equal(testInboxNow) { + t.Errorf("nextSnoozeExpiry = %v, want %v", nextSnoozeExpiry, testInboxNow) + } + + if bundles.lastOpts.WorkspaceID != "workspace-1" || bundles.lastOpts.Limit != defaultInboxLimit { + t.Errorf("list opts = %+v, want workspace-1 at the default limit (AC .40: same bound as the list endpoint)", bundles.lastOpts) + } + if bundles.lastOpts.Sidecar == nil || bundles.lastOpts.Sidecar.UserID != "user-1" || bundles.lastOpts.Sidecar.Only { + t.Errorf("sidecar filter = %+v, want the main-read exclusion filter for user-1", bundles.lastOpts.Sidecar) + } +} + +func TestInboxBootSummary_ListErrorPropagates(t *testing.T) { + bundles := &fakeInboxBundleStore{listErr: errors.New("boom")} + + _, _, _, err := InboxBootSummary(context.Background(), bundles, "workspace-1", "user-1", testInboxNow) + if err == nil { + t.Fatal("expected the list error to propagate") + } +} + +func TestInboxBootSummary_HiddenSummaryErrorPropagates(t *testing.T) { + bundles := &fakeInboxBundleStore{ + page: &taskmodels.ClarificationBundlePage{}, + summaryErr: errors.New("boom"), + } + + _, _, _, err := InboxBootSummary(context.Background(), bundles, "workspace-1", "user-1", testInboxNow) + if err == nil { + t.Fatal("expected the hidden-summary error to propagate rather than defaulting") + } +} diff --git a/apps/backend/internal/clarification/inbox_handlers.go b/apps/backend/internal/clarification/inbox_handlers.go new file mode 100644 index 0000000000..a163f51aa3 --- /dev/null +++ b/apps/backend/internal/clarification/inbox_handlers.go @@ -0,0 +1,633 @@ +package clarification + +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/authz" + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "github.com/kandev/kandev/internal/task/service" + userstore "github.com/kandev/kandev/internal/user/store" + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +// inboxWorkspaceAuthorizer is the workspace-scope check the Needs-you Inbox +// read endpoints need (needs-you-inbox design, "Security"): the same rule +// AuthorizeWorkspaceScope already applies everywhere else, reached through a +// narrow interface so this package does not depend on service.Service's full +// surface. +type inboxWorkspaceAuthorizer interface { + AuthorizeWorkspaceScope(ctx context.Context, workspaceID string, scope authz.Scope) error +} + +// inboxTaskLookup resolves the task_title/session_state enrichment fields in +// bounded batch reads. Enrichment degrades to empty fields on lookup failure +// rather than failing the page. +type inboxTaskLookup interface { + GetTasksByIDs(ctx context.Context, ids []string) ([]*taskmodels.Task, error) + BatchGetSessionsForTasks(ctx context.Context, taskIDs []string) (map[string][]*taskmodels.TaskSession, error) +} + +// inboxTaskService is the combined dependency the Needs-you Inbox handlers +// need from the task service. +type inboxTaskService interface { + inboxWorkspaceAuthorizer + inboxTaskLookup +} + +// inboxBundleStore is the bounded read/write surface the Needs-you Inbox +// endpoints need: the existing bundle-query machinery plus the new per-user +// dismiss/snooze sidecar (needs-you-inbox design, "Persistence"). +type inboxBundleStore interface { + ListUnresolvedClarificationBundles(ctx context.Context, opts taskmodels.ListClarificationBundlesOptions) (*taskmodels.ClarificationBundlePage, error) + FindMessagesByPendingIDs(ctx context.Context, pendingIDs []string) (map[string][]*taskmodels.Message, error) + UpsertClarificationInboxSidecar(ctx context.Context, userID, pendingID string, state taskmodels.ClarificationSidecarState, snoozeUntil *time.Time, now time.Time) error + DeleteClarificationInboxSidecar(ctx context.Context, userID, pendingID string) error + CountHiddenClarificationBundles(ctx context.Context, opts taskmodels.ListClarificationBundlesOptions) (taskmodels.ClarificationInboxHiddenSummary, error) + GetClarificationInboxSidecarStates(ctx context.Context, userID string, pendingIDs []string) (map[string]taskmodels.ClarificationInboxHiddenBundle, error) +} + +const ( + defaultInboxLimit = 50 + maxInboxLimit = 200 + + inboxStateDismissed = string(taskmodels.ClarificationSidecarDismissed) + inboxStateSnoozed = string(taskmodels.ClarificationSidecarSnoozed) + + errInboxInternal = "failed to read needs-you inbox" +) + +// inboxSnoozeDurations is the closed set of accepted snooze durations: any +// other value, including a well-formed "2h", is 400 +// (design-01#Sidecar-write-and-restore-contracts). +var inboxSnoozeDurations = map[string]time.Duration{ + "1h": time.Hour, + "4h": 4 * time.Hour, + "24h": 24 * time.Hour, +} + +// inboxBundleView is one bundle row shared by the main list and the hidden +// enumeration (needs-you-inbox design, "Data and contracts"). +type inboxBundleView struct { + PendingID string `json:"pending_id"` + TaskID string `json:"task_id"` + SessionID string `json:"session_id"` + SessionState string `json:"session_state"` + TaskTitle string `json:"task_title"` + CreatedAt string `json:"created_at"` + Context string `json:"context"` + Messages []*v1.Message `json:"messages"` +} + +// inboxListResponse is GET /api/v1/clarification-inbox's response envelope. +type inboxListResponse struct { + Bundles []inboxBundleView `json:"bundles"` + Count int `json:"count"` + HiddenCount int `json:"hidden_count"` + NextSnoozeExpiry *string `json:"next_snooze_expiry"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// inboxHiddenBundleView is one row of the hidden-bundles enumeration: the +// same shape as inboxBundleView plus why it is hidden. +type inboxHiddenBundleView struct { + inboxBundleView + State string `json:"state"` + SnoozeUntil *string `json:"snooze_until"` +} + +// inboxHiddenListResponse is GET /api/v1/clarification-inbox/hidden's +// response envelope. total is workspace-wide; count is page-scoped. +type inboxHiddenListResponse struct { + Bundles []inboxHiddenBundleView `json:"bundles"` + Count int `json:"count"` + Total int `json:"total"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// inboxSidecarPutBody is PUT /api/v1/clarification-inbox/sidecar/:pendingID's +// request body. +type inboxSidecarPutBody struct { + State string `json:"state"` + SnoozeDuration string `json:"snooze_duration"` +} + +// respondInboxError writes a {"error": message} JSON body. Routed through one +// helper rather than repeating the gin.H{"error": ...} literal at every call +// site. The "error" key itself already recurs across this package's other, +// pre-existing handlers (handlers.go), so goconst's package-wide count stays +// over threshold regardless; consolidating to this one call site is the +// improvement available without touching that unrelated code. +// +//nolint:goconst // see comment above +func respondInboxError(c *gin.Context, status int, message string) { + c.JSON(status, gin.H{"error": message}) +} + +// httpListInbox backs GET /api/v1/clarification-inbox. +func (h *Handlers) httpListInbox(c *gin.Context) { + ctx := c.Request.Context() + workspaceID, limit, cursorCreatedAt, cursorPendingID, ok := h.parseInboxListQuery(c) + if !ok { + return + } + if err := h.inboxTasks.AuthorizeWorkspaceScope(ctx, workspaceID, authz.ScopeWorkspaceRead); err != nil { + h.respondInboxWorkspaceAuthzError(c, err) + return + } + + userID := InboxUserID(ctx) + now := h.now() + page, err := h.inboxBundles.ListUnresolvedClarificationBundles(ctx, taskmodels.ListClarificationBundlesOptions{ + Unscoped: true, + WorkspaceID: workspaceID, + CursorCreatedAt: cursorCreatedAt, + CursorPendingID: cursorPendingID, + Limit: limit, + Sidecar: &taskmodels.ClarificationSidecarFilter{UserID: userID, Now: now}, + }) + if err != nil { + h.logger.Error("failed to list needs-you inbox bundles", zap.Error(err)) + respondInboxError(c, http.StatusInternalServerError, errInboxInternal) + return + } + views, err := h.buildInboxBundleViews(ctx, page.Bundles) + if err != nil { + h.logger.Error("failed to hydrate needs-you inbox bundle messages", zap.Error(err)) + respondInboxError(c, http.StatusInternalServerError, errInboxInternal) + return + } + + // The hidden-count query failing fails the whole read rather than + // defaulting hidden_count/next_snooze_expiry, which would misreport both + // the empty state and the snooze timer. + summary, err := h.inboxBundles.CountHiddenClarificationBundles(ctx, taskmodels.ListClarificationBundlesOptions{ + Unscoped: true, WorkspaceID: workspaceID, Limit: 1, + Sidecar: &taskmodels.ClarificationSidecarFilter{UserID: userID, Only: true, Now: now}, + }) + if err != nil { + h.logger.Error("failed to count hidden needs-you inbox bundles", zap.Error(err)) + respondInboxError(c, http.StatusInternalServerError, errInboxInternal) + return + } + + resp := inboxListResponse{ + Bundles: views, + Count: len(views), + HiddenCount: summary.HiddenCount, + NextSnoozeExpiry: formatOptionalInboxTime(summary.NextSnoozeExpiry), + } + if page.HasMore && len(page.Bundles) > 0 { + last := page.Bundles[len(page.Bundles)-1] + resp.NextCursor = encodeInboxCursor(last.CreatedAt, last.PendingID) + } + c.JSON(http.StatusOK, resp) +} + +// httpListInboxHidden backs GET /api/v1/clarification-inbox/hidden. +func (h *Handlers) httpListInboxHidden(c *gin.Context) { + ctx := c.Request.Context() + workspaceID, limit, cursorCreatedAt, cursorPendingID, ok := h.parseInboxListQuery(c) + if !ok { + return + } + if err := h.inboxTasks.AuthorizeWorkspaceScope(ctx, workspaceID, authz.ScopeWorkspaceRead); err != nil { + h.respondInboxWorkspaceAuthzError(c, err) + return + } + + userID := InboxUserID(ctx) + now := h.now() + sidecar := &taskmodels.ClarificationSidecarFilter{UserID: userID, Only: true, Now: now} + page, err := h.inboxBundles.ListUnresolvedClarificationBundles(ctx, taskmodels.ListClarificationBundlesOptions{ + Unscoped: true, WorkspaceID: workspaceID, + CursorCreatedAt: cursorCreatedAt, CursorPendingID: cursorPendingID, Limit: limit, + Sidecar: sidecar, + }) + if err != nil { + h.logger.Error("failed to list hidden needs-you inbox bundles", zap.Error(err)) + respondInboxError(c, http.StatusInternalServerError, errInboxInternal) + return + } + resp, err := h.buildInboxHiddenResponse(ctx, userID, page) + if err != nil { + h.logger.Error("failed to hydrate hidden needs-you inbox bundles", zap.Error(err)) + respondInboxError(c, http.StatusInternalServerError, errInboxInternal) + return + } + + summary, err := h.inboxBundles.CountHiddenClarificationBundles(ctx, taskmodels.ListClarificationBundlesOptions{ + Unscoped: true, WorkspaceID: workspaceID, Limit: 1, Sidecar: sidecar, + }) + if err != nil { + h.logger.Error("failed to count hidden needs-you inbox bundles", zap.Error(err)) + respondInboxError(c, http.StatusInternalServerError, errInboxInternal) + return + } + resp.Total = summary.HiddenCount + c.JSON(http.StatusOK, resp) +} + +func (h *Handlers) buildInboxHiddenResponse( + ctx context.Context, userID string, page *taskmodels.ClarificationBundlePage, +) (inboxHiddenListResponse, error) { + views, err := h.buildInboxBundleViews(ctx, page.Bundles) + if err != nil { + return inboxHiddenListResponse{}, err + } + pendingIDs := make([]string, 0, len(page.Bundles)) + for _, b := range page.Bundles { + pendingIDs = append(pendingIDs, b.PendingID) + } + states, err := h.inboxBundles.GetClarificationInboxSidecarStates(ctx, userID, pendingIDs) + if err != nil { + return inboxHiddenListResponse{}, err + } + + resp := inboxHiddenListResponse{Bundles: make([]inboxHiddenBundleView, 0, len(views))} + for _, view := range views { + entry := inboxHiddenBundleView{inboxBundleView: view} + if state, ok := states[view.PendingID]; ok { + entry.State = string(state.State) + entry.SnoozeUntil = formatOptionalInboxTime(state.SnoozeUntil) + } + resp.Bundles = append(resp.Bundles, entry) + } + resp.Count = len(resp.Bundles) + if page.HasMore && len(page.Bundles) > 0 { + last := page.Bundles[len(page.Bundles)-1] + resp.NextCursor = encodeInboxCursor(last.CreatedAt, last.PendingID) + } + return resp, nil +} + +// buildInboxBundleViews hydrates each bundle's durable messages and +// task_title/session_state enrichment. A bundle whose messages cannot be read +// fails the whole page rather than omitting a row silently; a bundle with +// zero resolvable messages (should not happen given the bundle query's own +// filters) is skipped and logged. +func (h *Handlers) buildInboxBundleViews( + ctx context.Context, bundles []taskmodels.ClarificationBundleSummary, +) ([]inboxBundleView, error) { + if len(bundles) == 0 { + return []inboxBundleView{}, nil + } + pendingIDs := make([]string, 0, len(bundles)) + taskIDs := make([]string, 0, len(bundles)) + seenPendingIDs := make(map[string]struct{}, len(bundles)) + seenTaskIDs := make(map[string]struct{}, len(bundles)) + for _, bundle := range bundles { + if _, seen := seenPendingIDs[bundle.PendingID]; !seen { + seenPendingIDs[bundle.PendingID] = struct{}{} + pendingIDs = append(pendingIDs, bundle.PendingID) + } + if _, seen := seenTaskIDs[bundle.TaskID]; !seen && bundle.TaskID != "" { + seenTaskIDs[bundle.TaskID] = struct{}{} + taskIDs = append(taskIDs, bundle.TaskID) + } + } + messagesByPendingID, err := h.inboxBundles.FindMessagesByPendingIDs(ctx, pendingIDs) + if err != nil { + return nil, err + } + taskTitles := h.batchInboxTaskTitles(ctx, taskIDs) + sessionStates := h.batchInboxSessionStates(ctx, taskIDs) + + views := make([]inboxBundleView, 0, len(bundles)) + for _, b := range bundles { + msgs := messagesByPendingID[b.PendingID] + if len(msgs) == 0 { + h.logger.Warn("needs-you inbox bundle has no resolvable messages; omitting from page", + zap.String("pending_id", b.PendingID)) + continue + } + ordered := orderInboxMessages(msgs) + views = append(views, inboxBundleView{ + PendingID: b.PendingID, + TaskID: b.TaskID, + SessionID: b.SessionID, + CreatedAt: b.CreatedAt.UTC().Format(time.RFC3339), + Context: inboxBundleContext(ordered), + Messages: renderInboxMessages(ordered), + TaskTitle: taskTitles[b.TaskID], + SessionState: sessionStates[b.SessionID], + }) + } + return views, nil +} + +func (h *Handlers) batchInboxTaskTitles(ctx context.Context, taskIDs []string) map[string]string { + titles := make(map[string]string, len(taskIDs)) + if len(taskIDs) == 0 { + return titles + } + tasks, err := h.inboxTasks.GetTasksByIDs(ctx, taskIDs) + if err != nil { + h.logger.Warn("failed to batch-load needs-you inbox task titles", zap.Error(err)) + return titles + } + for _, task := range tasks { + if task != nil { + titles[task.ID] = task.Title + } + } + return titles +} + +func (h *Handlers) batchInboxSessionStates(ctx context.Context, taskIDs []string) map[string]string { + states := make(map[string]string, len(taskIDs)) + if len(taskIDs) == 0 { + return states + } + sessionsByTaskID, err := h.inboxTasks.BatchGetSessionsForTasks(ctx, taskIDs) + if err != nil { + h.logger.Warn("failed to batch-load needs-you inbox session states", zap.Error(err)) + return states + } + for _, sessions := range sessionsByTaskID { + for _, session := range sessions { + if session != nil { + states[session.ID] = string(session.State) + } + } + } + return states +} + +// orderInboxMessages sorts a copy of msgs into AC .10's canonical order: +// question_index ascending (absent, negative, or non-numeric normalizes to +// zero), ties broken by question_id ascending. This is deliberately its own +// sort rather than a reuse of orderBundleMessages, whose D2/L5 order +// tie-breaks by created_at/message id for MCP resolution semantics and does +// not clamp a negative index to zero; the two consumers have different +// contracts and must not be forced to share one implementation. +func orderInboxMessages(msgs []*taskmodels.Message) []*taskmodels.Message { + sorted := make([]*taskmodels.Message, len(msgs)) + copy(sorted, msgs) + sort.SliceStable(sorted, func(i, j int) bool { + qi, qj := normalizedInboxQuestionIndex(sorted[i].Metadata), normalizedInboxQuestionIndex(sorted[j].Metadata) + if qi != qj { + return qi < qj + } + return questionIDFromMetadata(sorted[i].Metadata) < questionIDFromMetadata(sorted[j].Metadata) + }) + return sorted +} + +// normalizedInboxQuestionIndex clamps a negative index to zero on top of +// questionIndexFromMetadata's existing absent/non-numeric-to-zero handling, +// so every non-positive-index case collapses to the same rank and the +// question_id tie-break decides order for all of them alike (AC .10). +func normalizedInboxQuestionIndex(meta map[string]any) int { + if idx := questionIndexFromMetadata(meta); idx > 0 { + return idx + } + return 0 +} + +// renderInboxMessages projects each message through Message.ToAPI() and +// rewrites its emitted metadata.question_index to its 0-based rank within +// this order (design-01's "rewrite" rule): the shared client sort is then a +// no-op against normalized ranks. A fresh metadata map is +// built rather than written through the map ToAPI() returns, because that +// map may be the SAME reference as the source message's own metadata. +func renderInboxMessages(ordered []*taskmodels.Message) []*v1.Message { + out := make([]*v1.Message, 0, len(ordered)) + for i, m := range ordered { + api := m.ToAPI() + meta := make(map[string]interface{}, len(api.Metadata)+1) + for k, v := range api.Metadata { + meta[k] = v + } + meta["question_index"] = i + api.Metadata = meta + out = append(out, api) + } + return out +} + +// inboxBundleContext resolves the bundle's context to its FIRST message's +// metadata.context when that is a non-empty string after trimming, else "". +// This mirrors readSharedContext's +// `context?.trim() ? context : null` exactly (clarification-input-overlay.tsx), +// so the row and the panel it expands into can never disagree. +func inboxBundleContext(ordered []*taskmodels.Message) string { + if len(ordered) == 0 { + return "" + } + v, ok := ordered[0].Metadata["context"].(string) + if !ok || strings.TrimSpace(v) == "" { + return "" + } + return v +} + +// parseInboxListQuery implements the query-validation table shared by the +// main read and the hidden enumeration. +func (h *Handlers) parseInboxListQuery(c *gin.Context) ( + workspaceID string, limit int, cursorCreatedAt time.Time, cursorPendingID string, ok bool, +) { + workspaceID = strings.TrimSpace(c.Query("workspace_id")) + if workspaceID == "" { + respondInboxError(c, http.StatusBadRequest, "workspace_id is required") + return "", 0, time.Time{}, "", false + } + var err error + limit, err = parseInboxLimit(c.Query("limit")) + if err != nil { + respondInboxError(c, http.StatusBadRequest, err.Error()) + return "", 0, time.Time{}, "", false + } + if raw := c.Query("cursor"); raw != "" { + cursorCreatedAt, cursorPendingID, err = decodeInboxCursor(raw) + if err != nil { + respondInboxError(c, http.StatusBadRequest, "cursor: "+err.Error()) + return "", 0, time.Time{}, "", false + } + } + return workspaceID, limit, cursorCreatedAt, cursorPendingID, true +} + +// parseInboxLimit implements the limit validation rules: absent defaults to +// 50, non-numeric or <= 0 is rejected (not clamped), and > 200 clamps to 200. +func parseInboxLimit(raw string) (int, error) { + if raw == "" { + return defaultInboxLimit, nil + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return 0, errors.New("limit must be a positive integer") + } + if n > maxInboxLimit { + return maxInboxLimit, nil + } + return n, nil +} + +// respondInboxWorkspaceAuthzError maps AuthorizeWorkspaceScope's errors per +// the read's Security rule: an unreachable workspace is 404, and 403 never +// arises on this endpoint (needs-you-inbox design, "Security"). +func (h *Handlers) respondInboxWorkspaceAuthzError(c *gin.Context, err error) { + switch { + case errors.Is(err, repoerrors.ErrWorkspaceNotFound): + respondInboxError(c, http.StatusNotFound, "workspace not found") + case errors.Is(err, service.ErrForbidden): + respondInboxError(c, http.StatusForbidden, "insufficient permissions") + default: + h.logger.Error("failed to authorize needs-you inbox workspace access", zap.Error(err)) + respondInboxError(c, http.StatusInternalServerError, errInboxInternal) + } +} + +// httpUpsertInboxSidecar backs PUT /api/v1/clarification-inbox/sidecar/:pendingID. +func (h *Handlers) httpUpsertInboxSidecar(c *gin.Context) { + pendingID := c.Param("pendingID") + var body inboxSidecarPutBody + if err := c.ShouldBindJSON(&body); err != nil { + respondInboxError(c, http.StatusBadRequest, "invalid payload: "+err.Error()) + return + } + + var state taskmodels.ClarificationSidecarState + switch body.State { + case inboxStateDismissed: + state = taskmodels.ClarificationSidecarDismissed + case inboxStateSnoozed: + state = taskmodels.ClarificationSidecarSnoozed + default: + respondInboxError(c, http.StatusBadRequest, `state must be "dismissed" or "snoozed"`) + return + } + + now := h.now() + var snoozeUntil *time.Time + if state == taskmodels.ClarificationSidecarSnoozed { + duration := body.SnoozeDuration + if duration == "" { + duration = "4h" + } + d, ok := inboxSnoozeDurations[duration] + if !ok { + respondInboxError(c, http.StatusBadRequest, `snooze_duration must be one of "1h", "4h", "24h"`) + return + } + until := now.Add(d) + snoozeUntil = &until + } + + if !h.authorizeBundleAccessOrRespond(c, pendingID) { + return + } + if err := h.inboxBundles.UpsertClarificationInboxSidecar(c.Request.Context(), InboxUserID(c.Request.Context()), pendingID, state, snoozeUntil, now); err != nil { + h.logger.Error("failed to upsert needs-you inbox sidecar", + zap.String("pending_id", pendingID), zap.Error(err)) + respondInboxError(c, http.StatusInternalServerError, "failed to update needs-you inbox") + return + } + c.Status(http.StatusNoContent) +} + +// httpDeleteInboxSidecar backs DELETE /api/v1/clarification-inbox/sidecar/:pendingID: +// restore is deleting the sidecar row, idempotent by construction. +func (h *Handlers) httpDeleteInboxSidecar(c *gin.Context) { + pendingID := c.Param("pendingID") + if !h.authorizeBundleAccessOrRespond(c, pendingID) { + return + } + if err := h.inboxBundles.DeleteClarificationInboxSidecar(c.Request.Context(), InboxUserID(c.Request.Context()), pendingID); err != nil { + h.logger.Error("failed to delete needs-you inbox sidecar", + zap.String("pending_id", pendingID), zap.Error(err)) + respondInboxError(c, http.StatusInternalServerError, "failed to update needs-you inbox") + return + } + c.Status(http.StatusNoContent) +} + +// encodeInboxCursor/decodeInboxCursor implement the opaque (created_at, +// pending_id) cursor, mirroring the MCP list_pending_questions_kandev cursor +// codec (internal/mcp/handlers/question_handlers.go) so the two independent +// callers of the same bundle query encode pages identically. +func encodeInboxCursor(createdAt time.Time, pendingID string) string { + raw := createdAt.UTC().Format(time.RFC3339Nano) + "|" + pendingID + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +func decodeInboxCursor(cursor string) (time.Time, string, error) { + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return time.Time{}, "", err + } + parts := strings.SplitN(string(raw), "|", 2) + if len(parts) != 2 || parts[1] == "" { + return time.Time{}, "", errors.New("malformed cursor") + } + createdAt, err := time.Parse(time.RFC3339Nano, parts[0]) + if err != nil { + return time.Time{}, "", err + } + return createdAt, parts[1], nil +} + +// formatOptionalInboxTime renders a *time.Time as an RFC3339 string pointer, +// or nil (JSON null) when absent -- next_snooze_expiry and snooze_until are +// never omitted, always either a string or null. +func formatOptionalInboxTime(t *time.Time) *string { + if t == nil { + return nil + } + s := t.UTC().Format(time.RFC3339) + return &s +} + +// InboxUserID resolves the calling operator's identity for sidecar scoping. +// A real identity uses its own user id; an unscoped caller (auth disabled, or +// no identity at all) uses the pre-auth default user, keeping single-user +// behavior byte-identical -- the same convention internal/notifications uses +// for pre-auth ownership. Exported so the boot-state builder can resolve the +// same identity for the boot-hydration producer (InboxBootSummary). +func InboxUserID(ctx context.Context) string { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic || identity.UserID == "" { + return userstore.DefaultUserID + } + return identity.UserID +} + +// InboxBootSummary computes the Needs-you Inbox boot-hydration producer: the +// same bounded count, truncation flag, and next snooze expiry the list +// endpoint returns, at the same default limit and workspace/sidecar scope, +// for a workspace the boot builder has already resolved as the caller's own. +func InboxBootSummary( + ctx context.Context, + bundles inboxBundleStore, + workspaceID, userID string, + now time.Time, +) (count int, hasMore bool, nextSnoozeExpiry *time.Time, err error) { + page, err := bundles.ListUnresolvedClarificationBundles(ctx, taskmodels.ListClarificationBundlesOptions{ + Unscoped: true, + WorkspaceID: workspaceID, + Limit: defaultInboxLimit, + Sidecar: &taskmodels.ClarificationSidecarFilter{UserID: userID, Now: now}, + }) + if err != nil { + return 0, false, nil, err + } + summary, err := bundles.CountHiddenClarificationBundles(ctx, taskmodels.ListClarificationBundlesOptions{ + Unscoped: true, WorkspaceID: workspaceID, Limit: 1, + Sidecar: &taskmodels.ClarificationSidecarFilter{UserID: userID, Only: true, Now: now}, + }) + if err != nil { + return 0, false, nil, err + } + return len(page.Bundles), page.HasMore, summary.NextSnoozeExpiry, nil +} diff --git a/apps/backend/internal/clarification/inbox_handlers_test.go b/apps/backend/internal/clarification/inbox_handlers_test.go new file mode 100644 index 0000000000..5a4a7df2f6 --- /dev/null +++ b/apps/backend/internal/clarification/inbox_handlers_test.go @@ -0,0 +1,661 @@ +package clarification + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/authz" + "github.com/kandev/kandev/internal/common/logger" + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "github.com/kandev/kandev/internal/task/service" +) + +var testInboxNow = time.Date(2026, time.September, 12, 12, 0, 0, 0, time.UTC) + +// fakeInboxTasks is an inboxTaskService double: configurable workspace +// authorization plus task/session lookups for the task_title/session_state +// enrichment. +type fakeInboxTasks struct { + authzErr error + tasks map[string]*taskmodels.Task + sessions map[string]*taskmodels.TaskSession + taskBatchCalls int + sessionBatchCalls int +} + +func (f *fakeInboxTasks) AuthorizeWorkspaceScope(context.Context, string, authz.Scope) error { + return f.authzErr +} + +func (f *fakeInboxTasks) GetTask(_ context.Context, id string) (*taskmodels.Task, error) { + if t, ok := f.tasks[id]; ok { + return t, nil + } + return nil, errors.New("task not found") +} + +func (f *fakeInboxTasks) GetTaskSession(_ context.Context, id string) (*taskmodels.TaskSession, error) { + if s, ok := f.sessions[id]; ok { + return s, nil + } + return nil, errors.New("session not found") +} + +func (f *fakeInboxTasks) GetTasksByIDs(_ context.Context, ids []string) ([]*taskmodels.Task, error) { + f.taskBatchCalls++ + result := make([]*taskmodels.Task, 0, len(ids)) + for _, id := range ids { + if task := f.tasks[id]; task != nil { + result = append(result, task) + } + } + return result, nil +} + +func (f *fakeInboxTasks) BatchGetSessionsForTasks(_ context.Context, taskIDs []string) (map[string][]*taskmodels.TaskSession, error) { + f.sessionBatchCalls++ + result := make(map[string][]*taskmodels.TaskSession) + for _, taskID := range taskIDs { + for _, session := range f.sessions { + if session != nil && (session.TaskID == taskID || session.TaskID == "") { + result[taskID] = append(result[taskID], session) + } + } + } + return result, nil +} + +type sidecarUpsertCall struct { + userID, pendingID string + state taskmodels.ClarificationSidecarState + snoozeUntil *time.Time +} + +// fakeInboxBundleStore is an inboxBundleStore double. It embeds +// stubMessageStore (canceller_test.go) for FindMessagesByPendingID so the +// same message fixtures back both the resolver's authorization lookup and +// the inbox handlers' own message hydration. +type fakeInboxBundleStore struct { + stubMessageStore + batchMessageCalls int + + page *taskmodels.ClarificationBundlePage + listErr error + + lastOpts taskmodels.ListClarificationBundlesOptions + + upsertErr error + deleteErr error + upserted []sidecarUpsertCall + deleted []string + + summary taskmodels.ClarificationInboxHiddenSummary + summaryErr error + + states map[string]taskmodels.ClarificationInboxHiddenBundle + statesErr error +} + +func (f *fakeInboxBundleStore) FindMessagesByPendingIDs( + _ context.Context, pendingIDs []string, +) (map[string][]*taskmodels.Message, error) { + f.batchMessageCalls++ + if f.findErr != nil { + return nil, f.findErr + } + result := make(map[string][]*taskmodels.Message, len(pendingIDs)) + for _, pendingID := range pendingIDs { + result[pendingID] = f.messages[pendingID] + } + return result, nil +} + +func (f *fakeInboxBundleStore) ListUnresolvedClarificationBundles( + _ context.Context, opts taskmodels.ListClarificationBundlesOptions, +) (*taskmodels.ClarificationBundlePage, error) { + f.lastOpts = opts + if f.listErr != nil { + return nil, f.listErr + } + if f.page == nil { + return &taskmodels.ClarificationBundlePage{}, nil + } + return f.page, nil +} + +func (f *fakeInboxBundleStore) UpsertClarificationInboxSidecar( + _ context.Context, userID, pendingID string, state taskmodels.ClarificationSidecarState, snoozeUntil *time.Time, _ time.Time, +) error { + if f.upsertErr != nil { + return f.upsertErr + } + f.upserted = append(f.upserted, sidecarUpsertCall{userID, pendingID, state, snoozeUntil}) + return nil +} + +func (f *fakeInboxBundleStore) DeleteClarificationInboxSidecar(_ context.Context, _, pendingID string) error { + if f.deleteErr != nil { + return f.deleteErr + } + f.deleted = append(f.deleted, pendingID) + return nil +} + +func (f *fakeInboxBundleStore) CountHiddenClarificationBundles( + context.Context, taskmodels.ListClarificationBundlesOptions, +) (taskmodels.ClarificationInboxHiddenSummary, error) { + if f.summaryErr != nil { + return taskmodels.ClarificationInboxHiddenSummary{}, f.summaryErr + } + return f.summary, nil +} + +func (f *fakeInboxBundleStore) GetClarificationInboxSidecarStates( + context.Context, string, []string, +) (map[string]taskmodels.ClarificationInboxHiddenBundle, error) { + if f.statesErr != nil { + return nil, f.statesErr + } + if f.states == nil { + return map[string]taskmodels.ClarificationInboxHiddenBundle{}, nil + } + return f.states, nil +} + +// alwaysAllowAuthorizer is a taskAccessAuthorizer double that authorizes +// every task_id, for tests that only care about the inbox layer's own +// validation/response behavior. +type alwaysAllowAuthorizer struct{} + +func (alwaysAllowAuthorizer) AuthorizeTaskAccess(context.Context, string) error { return nil } + +func newInboxTestHandler( + t *testing.T, + msgs map[string][]*taskmodels.Message, + authorizer taskAccessAuthorizer, + tasks *fakeInboxTasks, + bundles *fakeInboxBundleStore, +) *Handlers { + t.Helper() + gin.SetMode(gin.TestMode) + bundles.stubMessageStore = stubMessageStore{messages: msgs} + store := NewStore(time.Minute) + resolverRepo := &stubMessageStore{messages: msgs} + eventBus := &stubEventBus{} + messageCreator := &stubMessageCreator{} + resolver := NewResolver(store, resolverRepo, messageCreator, authorizer, eventBus, eventBus, nil, logger.Default()) + h := NewHandlers(store, nil, messageCreator, resolverRepo, eventBus, resolver, logger.Default(), tasks, bundles) + h.now = func() time.Time { return testInboxNow } + return h +} + +func runInboxGet(h *Handlers, target string, handler gin.HandlerFunc) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, target, nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + handler(c) + return rec +} + +func runInboxSidecarWrite(method, pendingID, body string, handler gin.HandlerFunc) *httptest.ResponseRecorder { + var req *http.Request + if body != "" { + req = httptest.NewRequest(method, "/api/v1/clarification-inbox/sidecar/"+pendingID, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, "/api/v1/clarification-inbox/sidecar/"+pendingID, nil) + } + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Params = gin.Params{gin.Param{Key: "pendingID", Value: pendingID}} + handler(c) + // c.Status()/c.JSON() only set the writer's buffered status; gin's engine + // normally flushes it via WriteHeaderNow() at the end of ServeHTTP. These + // tests call the handler directly, bypassing that, so a bare c.Status(204) + // (no body) would otherwise leave the recorder at its default 200. + c.Writer.WriteHeaderNow() + return rec +} + +func decodeInboxBody(t *testing.T, rec *httptest.ResponseRecorder, out interface{}) { + t.Helper() + if err := json.Unmarshal(rec.Body.Bytes(), out); err != nil { + t.Fatalf("decode response body %q: %v", rec.Body.String(), err) + } +} + +func TestHttpListInbox_MissingWorkspaceID_BadRequest(t *testing.T) { + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, &fakeInboxBundleStore{}) + rec := runInboxGet(h, "/api/v1/clarification-inbox", h.httpListInbox) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpListInbox_NonNumericLimit_BadRequest(t *testing.T) { + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, &fakeInboxBundleStore{}) + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1&limit=abc", h.httpListInbox) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpListInbox_ZeroLimit_RejectedNotClamped(t *testing.T) { + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, &fakeInboxBundleStore{}) + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1&limit=0", h.httpListInbox) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpListInbox_LimitAboveCap_ClampedTo200(t *testing.T) { + bundles := &fakeInboxBundleStore{} + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, bundles) + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1&limit=99999", h.httpListInbox) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if bundles.lastOpts.Limit != 200 { + t.Fatalf("limit passed to query = %d, want clamped 200", bundles.lastOpts.Limit) + } +} + +func TestHttpListInbox_UndecodableCursor_BadRequest(t *testing.T) { + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, &fakeInboxBundleStore{}) + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1&cursor=not-valid-base64!!", h.httpListInbox) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpListInbox_WorkspaceNotFound_404(t *testing.T) { + tasks := &fakeInboxTasks{authzErr: repoerrors.ErrWorkspaceNotFound} + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, tasks, &fakeInboxBundleStore{}) + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1", h.httpListInbox) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpListInbox_Forbidden_403(t *testing.T) { + tasks := &fakeInboxTasks{authzErr: service.ErrForbidden} + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, tasks, &fakeInboxBundleStore{}) + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1", h.httpListInbox) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpListInbox_HiddenCountQueryFails_500NotDefaulted(t *testing.T) { + // A failing second bounded query fails the whole read rather than + // defaulting hidden_count/next_snooze_expiry. + bundleCreatedAt := testInboxNow.Add(-time.Hour) + msgs := map[string][]*taskmodels.Message{ + "p1": {{ID: "m1", TaskSessionID: "s1", TaskID: "t1", Metadata: map[string]any{"pending_id": "p1"}}}, + } + bundles := &fakeInboxBundleStore{ + page: &taskmodels.ClarificationBundlePage{ + Bundles: []taskmodels.ClarificationBundleSummary{ + {PendingID: "p1", TaskID: "t1", SessionID: "s1", CreatedAt: bundleCreatedAt}, + }, + }, + summaryErr: errors.New("boom"), + } + h := newInboxTestHandler(t, msgs, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, bundles) + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1", h.httpListInbox) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpListInbox_MessagesFetchFails_500(t *testing.T) { + bundleCreatedAt := testInboxNow.Add(-time.Hour) + bundles := &fakeInboxBundleStore{ + page: &taskmodels.ClarificationBundlePage{ + Bundles: []taskmodels.ClarificationBundleSummary{ + {PendingID: "p1", TaskID: "t1", SessionID: "s1", CreatedAt: bundleCreatedAt}, + }, + }, + } + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, bundles) + bundles.findErr = errors.New("db down") + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1", h.httpListInbox) + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpListInbox_SuccessShape_RewritesQuestionIndexAndTrimsContext(t *testing.T) { + bundleCreatedAt := testInboxNow.Add(-time.Hour) + // Messages are seeded out of order (index 5 before index 3) and with a + // whitespace-only context on the first message, to exercise the + // server-side question_index rank rewrite and the context trim rule + // together. + msgs := map[string][]*taskmodels.Message{ + "p1": { + { + ID: "m-b", TaskSessionID: "s1", TaskID: "t1", CreatedAt: bundleCreatedAt, + Metadata: map[string]any{"pending_id": "p1", "question_id": "q2", "question_index": 5, "context": " "}, + }, + { + ID: "m-a", TaskSessionID: "s1", TaskID: "t1", CreatedAt: bundleCreatedAt, + Metadata: map[string]any{"pending_id": "p1", "question_id": "q1", "question_index": 3}, + }, + }, + } + bundles := &fakeInboxBundleStore{ + page: &taskmodels.ClarificationBundlePage{ + HasMore: true, + Bundles: []taskmodels.ClarificationBundleSummary{ + {PendingID: "p1", TaskID: "t1", SessionID: "s1", CreatedAt: bundleCreatedAt}, + }, + }, + summary: taskmodels.ClarificationInboxHiddenSummary{HiddenCount: 2}, + } + tasks := &fakeInboxTasks{ + tasks: map[string]*taskmodels.Task{"t1": {ID: "t1", Title: "Fix the thing"}}, + sessions: map[string]*taskmodels.TaskSession{"s1": {ID: "s1", State: "WAITING_FOR_INPUT"}}, + } + h := newInboxTestHandler(t, msgs, alwaysAllowAuthorizer{}, tasks, bundles) + + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1", h.httpListInbox) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + + var resp inboxListResponse + decodeInboxBody(t, rec, &resp) + + if resp.Count != 1 || len(resp.Bundles) != 1 { + t.Fatalf("expected 1 bundle, got count=%d bundles=%d", resp.Count, len(resp.Bundles)) + } + b := resp.Bundles[0] + if b.TaskTitle != "Fix the thing" || b.SessionState != "WAITING_FOR_INPUT" { + t.Fatalf("enrichment = %+v, want title/state populated", b) + } + if b.Context != "" { + t.Fatalf("context = %q, want \"\" for a whitespace-only first-message context", b.Context) + } + if resp.HiddenCount != 2 { + t.Fatalf("hidden_count = %d, want 2", resp.HiddenCount) + } + if resp.NextSnoozeExpiry != nil { + t.Fatalf("next_snooze_expiry = %v, want nil", resp.NextSnoozeExpiry) + } + if resp.NextCursor == "" { + t.Fatalf("expected next_cursor to be set when HasMore is true") + } + if len(b.Messages) != 2 { + t.Fatalf("expected 2 messages, got %d", len(b.Messages)) + } + // m-a (question_index 3) sorts before m-b (question_index 5); the server + // rewrites the emitted question_index to the 0-based rank within that + // order. + if b.Messages[0].ID != "m-a" || b.Messages[1].ID != "m-b" { + t.Fatalf("message order = [%s, %s], want [m-a, m-b]", b.Messages[0].ID, b.Messages[1].ID) + } + if got := b.Messages[0].Metadata["question_index"]; got != float64(0) { + t.Fatalf("first message's rewritten question_index = %v, want 0", got) + } + if got := b.Messages[1].Metadata["question_index"]; got != float64(1) { + t.Fatalf("second message's rewritten question_index = %v, want 1", got) + } + // The original message's metadata must not have been mutated in place. + if msgs["p1"][0].Metadata["question_index"] != 5 || msgs["p1"][1].Metadata["question_index"] != 3 { + t.Fatalf("source message metadata was mutated: %+v", msgs["p1"]) + } +} + +func TestBuildInboxBundleViews_UsesBatchHydration(t *testing.T) { + createdAt := testInboxNow.Add(-time.Hour) + msgs := map[string][]*taskmodels.Message{ + "p1": {{ID: "m1", TaskSessionID: "s1", TaskID: "t1", Metadata: map[string]any{"pending_id": "p1", "question_id": "q1"}}}, + "p2": {{ID: "m2", TaskSessionID: "s2", TaskID: "t2", Metadata: map[string]any{"pending_id": "p2", "question_id": "q2"}}}, + } + bundles := &fakeInboxBundleStore{} + tasks := &fakeInboxTasks{ + tasks: map[string]*taskmodels.Task{ + "t1": {ID: "t1", Title: "First task"}, + "t2": {ID: "t2", Title: "Second task"}, + }, + sessions: map[string]*taskmodels.TaskSession{ + "s1": {ID: "s1", TaskID: "t1", State: "WAITING_FOR_INPUT"}, + "s2": {ID: "s2", TaskID: "t2", State: "RUNNING"}, + }, + } + h := newInboxTestHandler(t, msgs, alwaysAllowAuthorizer{}, tasks, bundles) + views, err := h.buildInboxBundleViews(context.Background(), []taskmodels.ClarificationBundleSummary{ + {PendingID: "p1", TaskID: "t1", SessionID: "s1", CreatedAt: createdAt}, + {PendingID: "p2", TaskID: "t2", SessionID: "s2", CreatedAt: createdAt.Add(time.Minute)}, + }) + if err != nil { + t.Fatalf("buildInboxBundleViews: %v", err) + } + if len(views) != 2 { + t.Fatalf("views = %d, want 2", len(views)) + } + if bundles.batchMessageCalls != 1 { + t.Fatalf("batch message calls = %d, want 1", bundles.batchMessageCalls) + } + if tasks.taskBatchCalls != 1 { + t.Fatalf("task batch calls = %d, want 1", tasks.taskBatchCalls) + } + if tasks.sessionBatchCalls != 1 { + t.Fatalf("session batch calls = %d, want 1", tasks.sessionBatchCalls) + } + if views[1].TaskTitle != "Second task" || views[1].SessionState != "RUNNING" { + t.Fatalf("second view enrichment = %+v", views[1]) + } +} + +func TestHttpListInbox_OrderInboxMessages_NegativeAndAbsentIndexTieBreakByQuestionID(t *testing.T) { + bundleCreatedAt := testInboxNow.Add(-time.Hour) + // m-z is created before m-a but claims a negative index (-5); m-a has no + // question_index at all. AC .10 normalizes both to rank zero and then + // tie-breaks by question_id ascending ("q-a" < "q-z"), so m-a must sort + // first despite being created later and despite orderBundleMessages' + // distinct D2/L5 contract (which would keep -5 strictly below 0 and + // tie-break by created_at/message id, ordering m-z first). + msgs := map[string][]*taskmodels.Message{ + "p1": { + { + ID: "m-z", TaskSessionID: "s1", TaskID: "t1", CreatedAt: bundleCreatedAt, + Metadata: map[string]any{"pending_id": "p1", "question_id": "q-z", "question_index": -5}, + }, + { + ID: "m-a", TaskSessionID: "s1", TaskID: "t1", CreatedAt: bundleCreatedAt.Add(time.Minute), + Metadata: map[string]any{"pending_id": "p1", "question_id": "q-a"}, + }, + { + ID: "m-mid", TaskSessionID: "s1", TaskID: "t1", CreatedAt: bundleCreatedAt, + Metadata: map[string]any{"pending_id": "p1", "question_id": "q-mid", "question_index": 2}, + }, + }, + } + bundles := &fakeInboxBundleStore{ + page: &taskmodels.ClarificationBundlePage{ + Bundles: []taskmodels.ClarificationBundleSummary{ + {PendingID: "p1", TaskID: "t1", SessionID: "s1", CreatedAt: bundleCreatedAt}, + }, + }, + } + tasks := &fakeInboxTasks{ + tasks: map[string]*taskmodels.Task{"t1": {ID: "t1", Title: "Fix the thing"}}, + sessions: map[string]*taskmodels.TaskSession{"s1": {ID: "s1", State: "WAITING_FOR_INPUT"}}, + } + h := newInboxTestHandler(t, msgs, alwaysAllowAuthorizer{}, tasks, bundles) + + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1", h.httpListInbox) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + + var resp inboxListResponse + decodeInboxBody(t, rec, &resp) + if len(resp.Bundles) != 1 || len(resp.Bundles[0].Messages) != 3 { + t.Fatalf("expected 1 bundle with 3 messages, got %+v", resp) + } + got := []string{ + resp.Bundles[0].Messages[0].ID, + resp.Bundles[0].Messages[1].ID, + resp.Bundles[0].Messages[2].ID, + } + want := []string{"m-a", "m-z", "m-mid"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("message order = %v, want %v", got, want) + } + } +} + +func TestHttpListInbox_EmptyResult_NeverNullBundles(t *testing.T) { + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, &fakeInboxBundleStore{}) + rec := runInboxGet(h, "/api/v1/clarification-inbox?workspace_id=w1", h.httpListInbox) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + if !jsonHasEmptyArrayField(t, rec.Body.Bytes(), "bundles") { + t.Fatalf("expected bundles to serialize as [], got %s", rec.Body.String()) + } +} + +func jsonHasEmptyArrayField(t *testing.T, body []byte, field string) bool { + t.Helper() + var raw map[string]json.RawMessage + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("decode: %v", err) + } + return string(raw[field]) == "[]" +} + +func TestHttpUpsertInboxSidecar_InvalidState_BadRequest(t *testing.T) { + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, &fakeInboxBundleStore{}) + rec := runInboxSidecarWrite(http.MethodPut, "p1", `{"state":"archived"}`, h.httpUpsertInboxSidecar) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpUpsertInboxSidecar_InvalidSnoozeDuration_BadRequest(t *testing.T) { + h := newInboxTestHandler(t, nil, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, &fakeInboxBundleStore{}) + rec := runInboxSidecarWrite(http.MethodPut, "p1", `{"state":"snoozed","snooze_duration":"2h"}`, h.httpUpsertInboxSidecar) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpUpsertInboxSidecar_UnknownPendingID_404(t *testing.T) { + h := newInboxTestHandler(t, map[string][]*taskmodels.Message{}, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, &fakeInboxBundleStore{}) + rec := runInboxSidecarWrite(http.MethodPut, "unknown", `{"state":"dismissed"}`, h.httpUpsertInboxSidecar) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpUpsertInboxSidecar_Dismiss_204AndUpserts(t *testing.T) { + msgs := map[string][]*taskmodels.Message{ + "p1": {{ID: "m1", TaskSessionID: "s1", TaskID: "t1", Metadata: map[string]any{"pending_id": "p1"}}}, + } + bundles := &fakeInboxBundleStore{} + h := newInboxTestHandler(t, msgs, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, bundles) + rec := runInboxSidecarWrite(http.MethodPut, "p1", `{"state":"dismissed"}`, h.httpUpsertInboxSidecar) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204: %s", rec.Code, rec.Body.String()) + } + if len(bundles.upserted) != 1 || bundles.upserted[0].pendingID != "p1" { + t.Fatalf("upserted = %+v", bundles.upserted) + } + if bundles.upserted[0].state != taskmodels.ClarificationSidecarDismissed { + t.Fatalf("state = %v, want dismissed", bundles.upserted[0].state) + } + if bundles.upserted[0].snoozeUntil != nil { + t.Fatalf("snoozeUntil = %v, want nil for a dismiss", bundles.upserted[0].snoozeUntil) + } +} + +func TestHttpUpsertInboxSidecar_SnoozeDefaultsTo4h(t *testing.T) { + msgs := map[string][]*taskmodels.Message{ + "p1": {{ID: "m1", TaskSessionID: "s1", TaskID: "t1", Metadata: map[string]any{"pending_id": "p1"}}}, + } + bundles := &fakeInboxBundleStore{} + h := newInboxTestHandler(t, msgs, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, bundles) + rec := runInboxSidecarWrite(http.MethodPut, "p1", `{"state":"snoozed"}`, h.httpUpsertInboxSidecar) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204: %s", rec.Code, rec.Body.String()) + } + want := testInboxNow.Add(4 * time.Hour) + got := bundles.upserted[0].snoozeUntil + if got == nil || !got.Equal(want) { + t.Fatalf("snoozeUntil = %v, want default 4h from %v = %v", got, testInboxNow, want) + } +} + +func TestHttpDeleteInboxSidecar_UnknownPendingID_404(t *testing.T) { + h := newInboxTestHandler(t, map[string][]*taskmodels.Message{}, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, &fakeInboxBundleStore{}) + rec := runInboxSidecarWrite(http.MethodDelete, "unknown", "", h.httpDeleteInboxSidecar) + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404: %s", rec.Code, rec.Body.String()) + } +} + +func TestHttpDeleteInboxSidecar_KnownBundle_204AndDeletes(t *testing.T) { + msgs := map[string][]*taskmodels.Message{ + "p1": {{ID: "m1", TaskSessionID: "s1", TaskID: "t1", Metadata: map[string]any{"pending_id": "p1"}}}, + } + bundles := &fakeInboxBundleStore{} + h := newInboxTestHandler(t, msgs, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, bundles) + rec := runInboxSidecarWrite(http.MethodDelete, "p1", "", h.httpDeleteInboxSidecar) + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want 204: %s", rec.Code, rec.Body.String()) + } + if len(bundles.deleted) != 1 || bundles.deleted[0] != "p1" { + t.Fatalf("deleted = %+v", bundles.deleted) + } +} + +func TestHttpListInboxHidden_ReportsStateSnoozeUntilAndDistinctTotal(t *testing.T) { + bundleCreatedAt := testInboxNow.Add(-time.Hour) + snoozeUntil := testInboxNow.Add(2 * time.Hour) + msgs := map[string][]*taskmodels.Message{ + "p1": {{ID: "m1", TaskSessionID: "s1", TaskID: "t1", CreatedAt: bundleCreatedAt, Metadata: map[string]any{"pending_id": "p1"}}}, + } + bundles := &fakeInboxBundleStore{ + page: &taskmodels.ClarificationBundlePage{ + Bundles: []taskmodels.ClarificationBundleSummary{ + {PendingID: "p1", TaskID: "t1", SessionID: "s1", CreatedAt: bundleCreatedAt}, + }, + }, + // total (workspace-wide) can exceed this page's count (AC .37). + summary: taskmodels.ClarificationInboxHiddenSummary{HiddenCount: 5}, + states: map[string]taskmodels.ClarificationInboxHiddenBundle{ + "p1": {PendingID: "p1", State: taskmodels.ClarificationSidecarSnoozed, SnoozeUntil: &snoozeUntil}, + }, + } + h := newInboxTestHandler(t, msgs, alwaysAllowAuthorizer{}, &fakeInboxTasks{}, bundles) + + rec := runInboxGet(h, "/api/v1/clarification-inbox/hidden?workspace_id=w1", h.httpListInboxHidden) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) + } + var resp inboxHiddenListResponse + decodeInboxBody(t, rec, &resp) + if resp.Count != 1 { + t.Fatalf("count = %d, want 1", resp.Count) + } + if resp.Total != 5 { + t.Fatalf("total = %d, want 5 (workspace-wide, distinct from page count)", resp.Total) + } + if len(resp.Bundles) != 1 || resp.Bundles[0].State != "snoozed" || resp.Bundles[0].SnoozeUntil == nil { + t.Fatalf("hidden bundle = %+v", resp.Bundles) + } +} diff --git a/apps/backend/internal/clarification/register_routes_test.go b/apps/backend/internal/clarification/register_routes_test.go new file mode 100644 index 0000000000..ce7f93f818 --- /dev/null +++ b/apps/backend/internal/clarification/register_routes_test.go @@ -0,0 +1,64 @@ +package clarification + +import ( + "testing" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" +) + +// registeredPaths collects every route path RegisterRoutes wired onto router, +// independent of HTTP method. +func registeredPaths(router *gin.Engine) map[string]bool { + paths := map[string]bool{} + for _, route := range router.Routes() { + paths[route.Path] = true + } + return paths +} + +// TestRegisterRoutes_InboxGatedByFlag guards the Needs-you Inbox routes +// against shipping reachable in a profile where the feature flag is off: the +// original /api/v1/clarification routes must stay unconditional (every +// agent's clarification-request/respond flow does not carry the flag), while +// the four /api/v1/clarification-inbox routes must only be registered when +// needsYouInboxEnabled is true. +func TestRegisterRoutes_InboxGatedByFlag(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("flag off: inbox routes absent, original routes present", func(t *testing.T) { + router := gin.New() + RegisterRoutes(router, nil, nil, nil, nil, nil, nil, logger.Default(), nil, nil, false) + paths := registeredPaths(router) + + if !paths["/api/v1/clarification/request"] { + t.Error("/api/v1/clarification/request missing: the original route must stay unconditional") + } + for _, path := range []string{ + "/api/v1/clarification-inbox", + "/api/v1/clarification-inbox/hidden", + "/api/v1/clarification-inbox/sidecar/:pendingID", + } { + if paths[path] { + t.Errorf("%s registered with the flag off: the Inbox API must not be reachable in a profile where features.needsYouInbox is false", path) + } + } + }) + + t.Run("flag on: inbox routes present alongside the original routes", func(t *testing.T) { + router := gin.New() + RegisterRoutes(router, nil, nil, nil, nil, nil, nil, logger.Default(), nil, nil, true) + paths := registeredPaths(router) + + for _, path := range []string{ + "/api/v1/clarification/request", + "/api/v1/clarification-inbox", + "/api/v1/clarification-inbox/hidden", + "/api/v1/clarification-inbox/sidecar/:pendingID", + } { + if !paths[path] { + t.Errorf("%s missing with the flag on", path) + } + } + }) +} diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index 471de923cb..f307932515 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -502,6 +502,12 @@ type FeaturesConfig struct { // decision re-evaluation when disabled. OfficeSessionIdentity bool `mapstructure:"office_session_identity" json:"officeSessionIdentity"` + // NeedsYouInbox gates the Needs-you Inbox: a workspace-scoped sidebar + // destination, independent of Office, listing exactly the answerable + // clarification bundles for the active workspace. Off in prod until the + // feature is user-ready. + NeedsYouInbox bool `mapstructure:"needs_you_inbox" json:"needsYouInbox"` + // AgentSurvival lets a worktree or local-executor agent session survive a // backend restart by adopting its still-running standalone control server // instead of killing it. Off in every embedded profile, and unavailable on diff --git a/apps/backend/internal/mcp/handlers/question_handlers_cross_entry_race_test.go b/apps/backend/internal/mcp/handlers/question_handlers_cross_entry_race_test.go index 6c9ea4ba53..c451ed7221 100644 --- a/apps/backend/internal/mcp/handlers/question_handlers_cross_entry_race_test.go +++ b/apps/backend/internal/mcp/handlers/question_handlers_cross_entry_race_test.go @@ -45,7 +45,7 @@ func TestAnswerQuestion_RESTvsMCP_OnlyOneClaims(t *testing.T) { resolver := newTestResolver(t, store, repo, svc) engine := gin.New() - clarification.RegisterRoutes(engine, store, noopBroadcaster{}, &svcMessageUpdater{Service: svc}, repo, stubDetachedResumer{}, resolver, testLogger(t)) + clarification.RegisterRoutes(engine, store, noopBroadcaster{}, &svcMessageUpdater{Service: svc}, repo, stubDetachedResumer{}, resolver, testLogger(t), svc, repo, true) mcpH := &Handlers{taskSvc: svc, clarificationResolver: resolver, clarificationBundles: repo, logger: testLogger(t)} diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index 4ad4c382be..7f8e50e428 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -106,6 +106,15 @@ features: dev: "true" e2e: "true" + # Needs-you Inbox: a workspace-scoped sidebar destination listing exactly + # the answerable clarification bundles for the active workspace. Off in + # prod until the feature is user-ready; on in dev/e2e so contributors and + # tests exercise it. + KANDEV_FEATURES_NEEDS_YOU_INBOX: + prod: "false" + dev: "true" + e2e: "true" + # Agent survival across a backend restart (worktree/local executors only). # Off in every profile: it replaces the standalone control server's # kill-on-restart safeguard with an adoption handshake, and is unavailable diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index 6ef786e90b..14772708a4 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -68,6 +68,23 @@ var registrations = []runtimeFlagRegistration{ read: func(cfg *config.Config) bool { return cfg.Features.Office }, apply: func(cfg *config.Config, value bool) { cfg.Features.Office = value }, }, + { + definition: RuntimeFlagDefinition{ + Key: "features.needsYouInbox", + EnvVar: "KANDEV_FEATURES_NEEDS_YOU_INBOX", + Kind: KindFeature, + Label: "Needs-you Inbox", + Description: "Enables a workspace-scoped sidebar destination listing exactly the answerable clarification bundles for the active workspace, independent of Office mode.", + Stability: StabilityExperimental, + RiskLevel: RiskLow, + RiskDescription: "The Inbox is a new read surface plus a per-user dismiss/snooze sidecar; it never mutates " + + "the underlying clarification record. Still evolving and should be reviewed before relying on it.", + RestartRequired: true, + Mutable: true, + }, + read: func(cfg *config.Config) bool { return cfg.Features.NeedsYouInbox }, + apply: func(cfg *config.Config, value bool) { cfg.Features.NeedsYouInbox = value }, + }, { definition: RuntimeFlagDefinition{ Key: "features.auth", diff --git a/apps/backend/internal/task/models/clarification_bundle.go b/apps/backend/internal/task/models/clarification_bundle.go index f130d12cb2..e39e667641 100644 --- a/apps/backend/internal/task/models/clarification_bundle.go +++ b/apps/backend/internal/task/models/clarification_bundle.go @@ -39,6 +39,22 @@ type ListClarificationBundlesOptions struct { // Limit is the page size, already resolved by the caller to the L10 // default/cap (default 50, capped at 200). Must be >= 1. Limit int + // Sidecar optionally joins the Needs-you Inbox per-user dismiss/snooze + // sidecar into the same query. Nil leaves the query byte-for-byte as it + // is without this feature (needs-you-inbox design, "Persistence"). + Sidecar *ClarificationSidecarFilter +} + +// ClarificationSidecarFilter selects one operator's dismiss/snooze sidecar +// rows and a direction: Only=false excludes bundles that operator is hiding +// (the main Inbox list); Only=true keeps exactly the bundles that operator is +// hiding (the hidden-bundles enumeration and count). Now is the instant a +// snooze is compared against server-side, so a skewed client clock cannot +// hide or reveal a row early. +type ClarificationSidecarFilter struct { + UserID string + Only bool + Now time.Time } // ClarificationBundlePage is one page of ListUnresolvedClarificationBundles, diff --git a/apps/backend/internal/task/models/clarification_inbox_sidecar.go b/apps/backend/internal/task/models/clarification_inbox_sidecar.go new file mode 100644 index 0000000000..3b4be8dbd2 --- /dev/null +++ b/apps/backend/internal/task/models/clarification_inbox_sidecar.go @@ -0,0 +1,33 @@ +package models + +import "time" + +// ClarificationSidecarState is the two states a Needs-you Inbox sidecar row +// can hold. There is no third value: restore deletes the row rather than +// storing a "visible" state. +type ClarificationSidecarState string + +const ( + ClarificationSidecarDismissed ClarificationSidecarState = "dismissed" + ClarificationSidecarSnoozed ClarificationSidecarState = "snoozed" +) + +// ClarificationInboxHiddenBundle is one row of the hidden-bundles +// enumeration: the same identity a listed bundle carries, plus which sidecar +// state is hiding it and, when snoozed, its expiry. +type ClarificationInboxHiddenBundle struct { + PendingID string + SessionID string + TaskID string + CreatedAt time.Time + State ClarificationSidecarState + SnoozeUntil *time.Time +} + +// ClarificationInboxHiddenSummary is the bounded aggregate the main read and +// the hidden-bundles read both consult: a workspace-wide count of bundles one +// operator's sidecar is hiding, and the earliest snooze expiry among them. +type ClarificationInboxHiddenSummary struct { + HiddenCount int + NextSnoozeExpiry *time.Time +} diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 7d2f0255a8..b6c001914b 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -41,6 +41,7 @@ func (r *Repository) initSchemaContext(ctx context.Context) error { r.initGitSchema, r.initReviewSchema, r.initTaskReviewSchema, + r.initClarificationInboxSidecarSchema, r.migrateExecutorProfiles, r.migrateTaskSessions, r.ensureDefaultWorkspace, diff --git a/apps/backend/internal/task/repository/sqlite/clarification_bundle_query.go b/apps/backend/internal/task/repository/sqlite/clarification_bundle_query.go index d4aa2e4b21..465e4270b1 100644 --- a/apps/backend/internal/task/repository/sqlite/clarification_bundle_query.go +++ b/apps/backend/internal/task/repository/sqlite/clarification_bundle_query.go @@ -22,8 +22,10 @@ func (r *Repository) ListUnresolvedClarificationBundles(ctx context.Context, opt } drv := r.ro.DriverName() - whereExtra, args := clarificationBundleWhereClause(opts) - query := clarificationBundleQuery(drv, whereExtra) + joinExtra, joinArgs := clarificationSidecarJoin(opts.Sidecar) + whereExtra, whereArgs := clarificationBundleWhereClause(opts) + args := append(append([]interface{}{}, joinArgs...), whereArgs...) + query := clarificationBundleQuery(drv, joinExtra, whereExtra) args = append(args, opts.Limit+1) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) @@ -65,6 +67,10 @@ func clarificationBundleWhereClause(opts models.ListClarificationBundlesOptions) conditions = append(conditions, "(b.created_at > ? OR (b.created_at = ? AND b.pending_id > ?))") args = append(args, opts.CursorCreatedAt, opts.CursorCreatedAt, opts.CursorPendingID) } + if predicate, predArgs := clarificationSidecarPredicate(opts.Sidecar); predicate != "" { + conditions = append(conditions, predicate) + args = append(args, predArgs...) + } if len(conditions) == 0 { return "", args @@ -72,6 +78,37 @@ func clarificationBundleWhereClause(opts models.ListClarificationBundlesOptions) return "AND " + strings.Join(conditions, " AND "), args } +// clarificationSidecarJoin returns the LEFT JOIN clause that makes one +// operator's sidecar row addressable as `cs.*` for the predicate below, and +// its bound arg. Nil returns "" so a caller that never sets Sidecar (every +// existing MCP caller) leaves the query byte-for-byte unchanged. +func clarificationSidecarJoin(sidecar *models.ClarificationSidecarFilter) (string, []interface{}) { + if sidecar == nil { + return "", nil + } + return "LEFT JOIN clarification_inbox_sidecar cs ON cs.pending_id = b.pending_id AND cs.user_id = ?", + []interface{}{sidecar.UserID} +} + +// clarificationSidecarPredicate implements the needs-you-inbox design's +// "Persistence" section: Only=false (the main list) keeps a bundle unless +// this operator's sidecar still hides it right now; Only=true (the hidden +// enumeration/count) keeps exactly the bundles it is hiding right now. A +// snooze is "still hidden" only while its expiry is strictly after Now, so an +// expiry exactly at the current instant is already expired and the bundle +// reappears. +func clarificationSidecarPredicate(sidecar *models.ClarificationSidecarFilter) (string, []interface{}) { + if sidecar == nil { + return "", nil + } + if sidecar.Only { + return "(cs.pending_id IS NOT NULL AND (cs.state = 'dismissed' OR (cs.state = 'snoozed' AND cs.snooze_until > ?)))", + []interface{}{sidecar.Now} + } + return "(cs.pending_id IS NULL OR NOT (cs.state = 'dismissed' OR (cs.state = 'snoozed' AND cs.snooze_until > ?)))", + []interface{}{sidecar.Now} +} + // clarificationBundleQuery assembles the bundle-grouping subquery joined to // its visibility-checked task row. Per spec D4/L2a, a bundle is pending only // when all three conjuncts hold: at least one message has status in (”, @@ -106,7 +143,27 @@ func clarificationBundleWhereClause(opts models.ListClarificationBundlesOptions) // answer_question_kandev's pre-claim validation already rejects. A bundle // admitted here with no resolvable question_id could be listed but could // never be answered. -func clarificationBundleQuery(drv, whereExtra string) string { +func clarificationBundleQuery(drv, joinExtra, whereExtra string) string { + return "SELECT b.pending_id, b.session_id, b.task_id, b.created_at\n" + + clarificationBundleTableExpr(drv, joinExtra, whereExtra) + + "\nORDER BY b.created_at ASC, b.pending_id ASC\nLIMIT ?" +} + +// clarificationBundleCountQuery is clarificationBundleQuery's aggregate +// sibling: the same bundle-visibility predicate, unbounded (no ORDER BY, no +// LIMIT), reduced to a workspace-wide COUNT and the earliest still-hidden +// snooze expiry. Used only with a Sidecar filter set to Only=true (needs-you- +// inbox design, "Persistence" and "Data and contracts" hidden_count / +// next_snooze_expiry). +func clarificationBundleCountQuery(drv, joinExtra, whereExtra string) string { + return "SELECT COUNT(*), MIN(cs.snooze_until)\n" + clarificationBundleTableExpr(drv, joinExtra, whereExtra) +} + +// clarificationBundleTableExpr is the shared FROM/JOIN/WHERE expression both +// queries above select from. See clarificationBundleQuery's doc comment for +// what each conjunct means; kept in one place so the count query cannot drift +// from the page query's notion of "answerable". +func clarificationBundleTableExpr(drv, joinExtra, whereExtra string) string { pendingIDExpr := dialect.JSONExtract(drv, "m.metadata", "pending_id") statusExpr := dialect.JSONExtract(drv, "m.metadata", "status") questionIDExpr := fmt.Sprintf( @@ -126,7 +183,6 @@ func clarificationBundleQuery(drv, whereExtra string) string { LIMIT 1 )`, predicate, orderBy) return fmt.Sprintf(` - SELECT b.pending_id, b.session_id, b.task_id, b.created_at FROM ( SELECT %[1]s AS pending_id, @@ -144,12 +200,11 @@ func clarificationBundleQuery(drv, whereExtra string) string { GROUP BY %[1]s, m.task_session_id ) b JOIN tasks t ON t.id = b.task_id + %[8]s WHERE b.has_pending = 1 AND b.has_missing_question_id = 0 %[3]s - ORDER BY b.created_at ASC, b.pending_id ASC - LIMIT ? - `, pendingIDExpr, statusExpr, whereExtra, notParentQuestion, nonTerminalSession, currentTurn, questionIDExpr) + `, pendingIDExpr, statusExpr, whereExtra, notParentQuestion, nonTerminalSession, currentTurn, questionIDExpr, joinExtra) } // scanClarificationBundleRows scans the bundle rows. The created_at column diff --git a/apps/backend/internal/task/repository/sqlite/clarification_inbox_sidecar.go b/apps/backend/internal/task/repository/sqlite/clarification_inbox_sidecar.go new file mode 100644 index 0000000000..c3d0ccadb6 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/clarification_inbox_sidecar.go @@ -0,0 +1,170 @@ +package sqlite + +import ( + "context" + "database/sql" + "strings" + "time" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/task/models" +) + +// clarificationInboxSidecarSchemaDDL creates the Needs-you Inbox per-user +// dismiss/snooze sidecar (needs-you-inbox design, "Persistence"). Primary key +// (user_id, pending_id) makes repeated dismiss/snooze an upsert by +// construction, and the sidecar never touches task_session_messages: the +// underlying clarification record is unchanged by a dismiss or a snooze. +const clarificationInboxSidecarSchemaDDL = ` + CREATE TABLE IF NOT EXISTS clarification_inbox_sidecar ( + user_id TEXT NOT NULL, + pending_id TEXT NOT NULL, + state TEXT NOT NULL, + snooze_until TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (user_id, pending_id) + ); +` + +func (r *Repository) initClarificationInboxSidecarSchema() error { + _, err := r.db.ExecContext(r.migrationContext(), clarificationInboxSidecarSchemaDDL) + return err +} + +const upsertClarificationInboxSidecarSQL = ` + INSERT INTO clarification_inbox_sidecar (user_id, pending_id, state, snooze_until, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, pending_id) DO UPDATE SET + state = excluded.state, + snooze_until = excluded.snooze_until, + updated_at = excluded.updated_at +` + +// UpsertClarificationInboxSidecar dismisses or snoozes one bundle for one +// operator. Idempotent by construction: repeating the same call leaves the +// single row the primary key allows. Re-snoozing REPLACES +// snooze_until with a fresh absolute instant rather than extending the old +// one, matching the design's "no accumulation" rule. +func (r *Repository) UpsertClarificationInboxSidecar( + ctx context.Context, + userID, pendingID string, + state models.ClarificationSidecarState, + snoozeUntil *time.Time, + now time.Time, +) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(upsertClarificationInboxSidecarSQL), + userID, pendingID, string(state), snoozeUntil, now, now, + ) + return err +} + +// DeleteClarificationInboxSidecar restores a bundle: the sidecar row is +// deleted, which is the whole of restore (the underlying record was never +// touched). Deleting an absent row is a no-op success, which is what makes +// this endpoint idempotent. +func (r *Repository) DeleteClarificationInboxSidecar(ctx context.Context, userID, pendingID string) error { + _, err := r.db.ExecContext(ctx, r.db.Rebind(` + DELETE FROM clarification_inbox_sidecar WHERE user_id = ? AND pending_id = ? + `), userID, pendingID) + return err +} + +// CountHiddenClarificationBundles answers the workspace-wide hidden_count and +// next_snooze_expiry the main read and the hidden-bundles read both need +// (needs-you-inbox design, "Data and contracts"). opts.Sidecar must carry +// Only=true; every other opts field narrows the same way it does for +// ListUnresolvedClarificationBundles, so this can never disagree with the +// page query about what counts as answerable. +func (r *Repository) CountHiddenClarificationBundles( + ctx context.Context, + opts models.ListClarificationBundlesOptions, +) (models.ClarificationInboxHiddenSummary, error) { + drv := r.ro.DriverName() + joinExtra, joinArgs := clarificationSidecarJoin(opts.Sidecar) + whereExtra, whereArgs := clarificationBundleWhereClause(opts) + args := append(append([]interface{}{}, joinArgs...), whereArgs...) + query := clarificationBundleCountQuery(drv, joinExtra, whereExtra) + + row := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), args...) + var count int + if dialect.IsPostgres(drv) { + var nextExpiry sql.NullTime + if err := row.Scan(&count, &nextExpiry); err != nil { + return models.ClarificationInboxHiddenSummary{}, err + } + return summaryFromScan(count, nextExpiry.Valid, nextExpiry.Time), nil + } + var nextExpiryRaw sql.NullString + if err := row.Scan(&count, &nextExpiryRaw); err != nil { + return models.ClarificationInboxHiddenSummary{}, err + } + if !nextExpiryRaw.Valid || nextExpiryRaw.String == "" { + return summaryFromScan(count, false, time.Time{}), nil + } + return summaryFromScan(count, true, parseLegacyTimestamp(nextExpiryRaw.String)), nil +} + +func summaryFromScan(count int, hasExpiry bool, expiry time.Time) models.ClarificationInboxHiddenSummary { + summary := models.ClarificationInboxHiddenSummary{HiddenCount: count} + if hasExpiry { + summary.NextSnoozeExpiry = &expiry + } + return summary +} + +// GetClarificationInboxSidecarStates resolves each addressed pending_id's +// hidden state and snooze expiry for one operator, used to enrich the hidden- +// bundles enumeration after ListUnresolvedClarificationBundles has already +// produced the bundle identities with the Only=true sidecar filter. +// A pending_id with no sidecar row (should not happen given the caller's own +// filter, but the map lookup degrades safely) is simply absent from the map. +func (r *Repository) GetClarificationInboxSidecarStates( + ctx context.Context, + userID string, + pendingIDs []string, +) (map[string]models.ClarificationInboxHiddenBundle, error) { + out := make(map[string]models.ClarificationInboxHiddenBundle, len(pendingIDs)) + if len(pendingIDs) == 0 { + return out, nil + } + + placeholders := make([]string, len(pendingIDs)) + args := make([]interface{}, 0, len(pendingIDs)+1) + args = append(args, userID) + for i, id := range pendingIDs { + placeholders[i] = "?" + args = append(args, id) + } + + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT pending_id, state, snooze_until + FROM clarification_inbox_sidecar + WHERE user_id = ? AND pending_id IN (`+strings.Join(placeholders, ", ")+`) + `), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + for rows.Next() { + var pendingID, state string + var snoozeUntil sql.NullTime + if err := rows.Scan(&pendingID, &state, &snoozeUntil); err != nil { + return nil, err + } + entry := models.ClarificationInboxHiddenBundle{ + PendingID: pendingID, + State: models.ClarificationSidecarState(state), + } + if snoozeUntil.Valid { + t := snoozeUntil.Time + entry.SnoozeUntil = &t + } + out[pendingID] = entry + } + if err := rows.Err(); err != nil { + return nil, err + } + return out, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/clarification_inbox_sidecar_postgres_test.go b/apps/backend/internal/task/repository/sqlite/clarification_inbox_sidecar_postgres_test.go new file mode 100644 index 0000000000..ef013f2503 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/clarification_inbox_sidecar_postgres_test.go @@ -0,0 +1,192 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/testutil" +) + +// seedAnswerablePostgresBundle mirrors seedAnswerableBundle but leaves +// WorkspaceID empty: CreateTask's workspace-row lock requires a real +// workspace row to exist under PostgreSQL's foreign-key enforcement, which +// SQLite does not require, and these tests use Unscoped:true options that +// never filter on workspace anyway. +func seedAnswerablePostgresBundle(t *testing.T, repo *Repository, n int) (pendingID string) { + t.Helper() + taskID := "task-pg-sidecar-" + itoa(n) + sessionID := "session-pg-sidecar-" + itoa(n) + turnID := "turn-pg-sidecar-" + itoa(n) + pendingID = "pending-pg-sidecar-" + itoa(n) + seedBundleTask(t, repo, taskID, "") + seedBundleSession(t, repo, sessionID, taskID) + seedBundleTurn(t, repo, turnID, sessionID, taskID) + insertClarificationMessage(t, repo, "msg-pg-sidecar-"+itoa(n), sessionID, taskID, turnID, pendingID, "q1", "", 0, time.Now().UTC()) + return pendingID +} + +// TestPostgresUpsertClarificationInboxSidecar_DismissedBundleExcludedFromMainList +// mirrors TestUpsertClarificationInboxSidecar_DismissedBundleExcludedFromMainList +// but on PostgreSQL, exercising clarificationSidecarJoin's LEFT JOIN against a +// jsonb-backed clarification_bundle_query (the SQLite-only run never proves the +// join and predicate compile and evaluate correctly against Postgres). +func TestPostgresUpsertClarificationInboxSidecar_DismissedBundleExcludedFromMainList(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + pendingID := seedAnswerablePostgresBundle(t, repo, 101) + + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", pendingID, models.ClarificationSidecarDismissed, nil, time.Now().UTC()); err != nil { + t.Fatalf("upsert sidecar: %v", err) + } + + opts := unscopedOpts(50) + opts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Now: time.Now().UTC()} + page, err := repo.ListUnresolvedClarificationBundles(ctx, opts) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(page.Bundles) != 0 { + t.Fatalf("expected dismissed bundle excluded from main list, got %d bundles", len(page.Bundles)) + } + + otherOpts := unscopedOpts(50) + otherOpts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-2", Now: time.Now().UTC()} + otherPage, err := repo.ListUnresolvedClarificationBundles(ctx, otherOpts) + if err != nil { + t.Fatalf("list other user: %v", err) + } + if len(otherPage.Bundles) != 1 { + t.Fatalf("expected bundle visible to a different operator, got %d bundles", len(otherPage.Bundles)) + } +} + +// TestPostgresCountHiddenClarificationBundles_MatchesEnumerationAndReportsEarliestExpiry +// exercises CountHiddenClarificationBundles' dialect.IsPostgres branch +// (sql.NullTime scan of MIN(cs.snooze_until)) directly, which the SQLite-only +// suite never reaches. +func TestPostgresCountHiddenClarificationBundles_MatchesEnumerationAndReportsEarliestExpiry(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + // Postgres `timestamptz` stores microsecond precision; Go's time.Now() + // carries nanoseconds, so comparing the exact written value on read-back + // needs the same truncation the column itself performs, or an unlucky + // nanosecond remainder fails a numerically-correct round trip. + now := time.Now().UTC().Truncate(time.Microsecond) + + dismissedID := seedAnswerablePostgresBundle(t, repo, 102) + snoozedSoonID := seedAnswerablePostgresBundle(t, repo, 103) + snoozedLaterID := seedAnswerablePostgresBundle(t, repo, 104) + visibleID := seedAnswerablePostgresBundle(t, repo, 105) + + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", dismissedID, models.ClarificationSidecarDismissed, nil, now); err != nil { + t.Fatalf("dismiss: %v", err) + } + soon := now.Add(time.Hour) + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", snoozedSoonID, models.ClarificationSidecarSnoozed, &soon, now); err != nil { + t.Fatalf("snooze soon: %v", err) + } + later := now.Add(4 * time.Hour) + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", snoozedLaterID, models.ClarificationSidecarSnoozed, &later, now); err != nil { + t.Fatalf("snooze later: %v", err) + } + + countOpts := unscopedOpts(50) + countOpts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Only: true, Now: now} + summary, err := repo.CountHiddenClarificationBundles(ctx, countOpts) + if err != nil { + t.Fatalf("count hidden: %v", err) + } + if summary.HiddenCount != 3 { + t.Fatalf("expected 3 hidden bundles, got %d", summary.HiddenCount) + } + if summary.NextSnoozeExpiry == nil || !summary.NextSnoozeExpiry.Equal(soon) { + t.Fatalf("expected next snooze expiry %v, got %v", soon, summary.NextSnoozeExpiry) + } + + mainOpts := unscopedOpts(50) + mainOpts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Now: now} + mainPage, err := repo.ListUnresolvedClarificationBundles(ctx, mainOpts) + if err != nil { + t.Fatalf("list main: %v", err) + } + if len(mainPage.Bundles) != 1 || mainPage.Bundles[0].PendingID != visibleID { + t.Fatalf("expected exactly the un-hidden bundle in the main list, got %+v", mainPage.Bundles) + } +} + +// TestPostgresCountHiddenClarificationBundles_ZeroHiddenReportsNoExpiry proves +// the dialect.IsPostgres branch correctly reads a NULL MIN(cs.snooze_until) +// (via sql.NullTime, not sql.NullString) as "no expiry" rather than erroring +// on a type mismatch or defaulting to a zero-value time. +func TestPostgresCountHiddenClarificationBundles_ZeroHiddenReportsNoExpiry(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + now := time.Now().UTC() + seedAnswerablePostgresBundle(t, repo, 106) + + countOpts := unscopedOpts(50) + countOpts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Only: true, Now: now} + summary, err := repo.CountHiddenClarificationBundles(ctx, countOpts) + if err != nil { + t.Fatalf("count hidden: %v", err) + } + if summary.HiddenCount != 0 { + t.Fatalf("expected 0 hidden bundles, got %d", summary.HiddenCount) + } + if summary.NextSnoozeExpiry != nil { + t.Fatalf("expected no next snooze expiry, got %v", summary.NextSnoozeExpiry) + } +} + +// TestPostgresGetClarificationInboxSidecarStates_ResolvesStateAndExpiry proves +// the sidecar-state lookup's IN (...) query and sql.NullTime scan work against +// Postgres. +func TestPostgresGetClarificationInboxSidecarStates_ResolvesStateAndExpiry(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + ctx := context.Background() + // See the truncation note in TestPostgresCountHiddenClarificationBundles_ + // MatchesEnumerationAndReportsEarliestExpiry above. + now := time.Now().UTC().Truncate(time.Microsecond) + dismissedID := seedAnswerablePostgresBundle(t, repo, 107) + snoozedID := seedAnswerablePostgresBundle(t, repo, 108) + snoozeUntil := now.Add(time.Hour) + + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", dismissedID, models.ClarificationSidecarDismissed, nil, now); err != nil { + t.Fatalf("dismiss: %v", err) + } + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", snoozedID, models.ClarificationSidecarSnoozed, &snoozeUntil, now); err != nil { + t.Fatalf("snooze: %v", err) + } + + states, err := repo.GetClarificationInboxSidecarStates(ctx, "user-1", []string{dismissedID, snoozedID, "unknown-pending-id"}) + if err != nil { + t.Fatalf("get states: %v", err) + } + if len(states) != 2 { + t.Fatalf("expected 2 resolved states, got %d: %+v", len(states), states) + } + if states[dismissedID].State != models.ClarificationSidecarDismissed { + t.Fatalf("dismissed state = %v", states[dismissedID].State) + } + if states[snoozedID].State != models.ClarificationSidecarSnoozed || states[snoozedID].SnoozeUntil == nil || !states[snoozedID].SnoozeUntil.Equal(snoozeUntil) { + t.Fatalf("snoozed state = %+v, want snoozeUntil %v", states[snoozedID], snoozeUntil) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/clarification_inbox_sidecar_test.go b/apps/backend/internal/task/repository/sqlite/clarification_inbox_sidecar_test.go new file mode 100644 index 0000000000..35b0bf7e14 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/clarification_inbox_sidecar_test.go @@ -0,0 +1,238 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func seedAnswerableBundle(t *testing.T, repo *Repository, n int) (pendingID string) { + t.Helper() + taskID := "task-sidecar-" + itoa(n) + sessionID := "session-sidecar-" + itoa(n) + turnID := "turn-sidecar-" + itoa(n) + pendingID = "pending-sidecar-" + itoa(n) + seedBundleTask(t, repo, taskID, "workspace-sidecar") + seedBundleSession(t, repo, sessionID, taskID) + seedBundleTurn(t, repo, turnID, sessionID, taskID) + insertClarificationMessage(t, repo, "msg-sidecar-"+itoa(n), sessionID, taskID, turnID, pendingID, "q1", "", 0, time.Now().UTC()) + return pendingID +} + +func itoa(n int) string { + digits := "0123456789" + if n == 0 { + return "0" + } + out := "" + for n > 0 { + out = string(digits[n%10]) + out + n /= 10 + } + return out +} + +func TestUpsertClarificationInboxSidecar_DismissedBundleExcludedFromMainList(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + pendingID := seedAnswerableBundle(t, repo, 1) + + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", pendingID, models.ClarificationSidecarDismissed, nil, time.Now().UTC()); err != nil { + t.Fatalf("upsert sidecar: %v", err) + } + + opts := unscopedOpts(50) + opts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Now: time.Now().UTC()} + page, err := repo.ListUnresolvedClarificationBundles(ctx, opts) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(page.Bundles) != 0 { + t.Fatalf("expected dismissed bundle excluded from main list, got %d bundles", len(page.Bundles)) + } + + // A different operator's sidecar must not hide it. + otherOpts := unscopedOpts(50) + otherOpts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-2", Now: time.Now().UTC()} + otherPage, err := repo.ListUnresolvedClarificationBundles(ctx, otherOpts) + if err != nil { + t.Fatalf("list other user: %v", err) + } + if len(otherPage.Bundles) != 1 { + t.Fatalf("expected bundle visible to a different operator, got %d bundles", len(otherPage.Bundles)) + } +} + +func TestUpsertClarificationInboxSidecar_SnoozeExpiryReappearsAtBoundary(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + pendingID := seedAnswerableBundle(t, repo, 2) + now := time.Now().UTC() + snoozeUntil := now.Add(time.Hour) + + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", pendingID, models.ClarificationSidecarSnoozed, &snoozeUntil, now); err != nil { + t.Fatalf("upsert sidecar: %v", err) + } + + stillHidden := unscopedOpts(50) + stillHidden.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Now: snoozeUntil.Add(-time.Minute)} + page, err := repo.ListUnresolvedClarificationBundles(ctx, stillHidden) + if err != nil { + t.Fatalf("list before expiry: %v", err) + } + if len(page.Bundles) != 0 { + t.Fatalf("expected bundle still hidden before expiry, got %d bundles", len(page.Bundles)) + } + + // AC .24: an expiry exactly at the current instant reappears (ties + // resolve toward showing the question). + atExpiry := unscopedOpts(50) + atExpiry.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Now: snoozeUntil} + page, err = repo.ListUnresolvedClarificationBundles(ctx, atExpiry) + if err != nil { + t.Fatalf("list at expiry: %v", err) + } + if len(page.Bundles) != 1 { + t.Fatalf("expected bundle visible exactly at expiry, got %d bundles", len(page.Bundles)) + } +} + +func TestDeleteClarificationInboxSidecar_RestoresBundleAndIsIdempotent(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + pendingID := seedAnswerableBundle(t, repo, 3) + now := time.Now().UTC() + + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", pendingID, models.ClarificationSidecarDismissed, nil, now); err != nil { + t.Fatalf("upsert sidecar: %v", err) + } + if err := repo.DeleteClarificationInboxSidecar(ctx, "user-1", pendingID); err != nil { + t.Fatalf("delete sidecar: %v", err) + } + // Deleting again (already-restored) must succeed as a no-op. + if err := repo.DeleteClarificationInboxSidecar(ctx, "user-1", pendingID); err != nil { + t.Fatalf("delete sidecar again: %v", err) + } + // Deleting an entirely unknown pending_id must also succeed as a no-op. + if err := repo.DeleteClarificationInboxSidecar(ctx, "user-1", "unknown-pending-id"); err != nil { + t.Fatalf("delete unknown sidecar: %v", err) + } + + opts := unscopedOpts(50) + opts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Now: now} + page, err := repo.ListUnresolvedClarificationBundles(ctx, opts) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(page.Bundles) != 1 { + t.Fatalf("expected restored bundle visible again, got %d bundles", len(page.Bundles)) + } +} + +func TestCountHiddenClarificationBundles_MatchesEnumerationAndReportsEarliestExpiry(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + now := time.Now().UTC() + + dismissedID := seedAnswerableBundle(t, repo, 4) + snoozedSoonID := seedAnswerableBundle(t, repo, 5) + snoozedLaterID := seedAnswerableBundle(t, repo, 6) + visibleID := seedAnswerableBundle(t, repo, 7) + _ = visibleID + + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", dismissedID, models.ClarificationSidecarDismissed, nil, now); err != nil { + t.Fatalf("dismiss: %v", err) + } + soon := now.Add(time.Hour) + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", snoozedSoonID, models.ClarificationSidecarSnoozed, &soon, now); err != nil { + t.Fatalf("snooze soon: %v", err) + } + later := now.Add(4 * time.Hour) + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", snoozedLaterID, models.ClarificationSidecarSnoozed, &later, now); err != nil { + t.Fatalf("snooze later: %v", err) + } + + countOpts := unscopedOpts(50) + countOpts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Only: true, Now: now} + summary, err := repo.CountHiddenClarificationBundles(ctx, countOpts) + if err != nil { + t.Fatalf("count hidden: %v", err) + } + if summary.HiddenCount != 3 { + t.Fatalf("expected 3 hidden bundles, got %d", summary.HiddenCount) + } + if summary.NextSnoozeExpiry == nil || !summary.NextSnoozeExpiry.Equal(soon) { + t.Fatalf("expected next snooze expiry %v, got %v", soon, summary.NextSnoozeExpiry) + } + + // The hidden-bundles enumeration must return exactly the same set the + // count sees, using the same Only=true filter, with the main list a + // strict complement (AC .37's "partition of one answerable set"). + hiddenPage, err := repo.ListUnresolvedClarificationBundles(ctx, countOpts) + if err != nil { + t.Fatalf("list hidden: %v", err) + } + if len(hiddenPage.Bundles) != summary.HiddenCount { + t.Fatalf("expected hidden list length %d to equal hidden count %d", len(hiddenPage.Bundles), summary.HiddenCount) + } + + mainOpts := unscopedOpts(50) + mainOpts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Now: now} + mainPage, err := repo.ListUnresolvedClarificationBundles(ctx, mainOpts) + if err != nil { + t.Fatalf("list main: %v", err) + } + if len(mainPage.Bundles) != 1 || mainPage.Bundles[0].PendingID != visibleID { + t.Fatalf("expected exactly the un-hidden bundle in the main list, got %+v", mainPage.Bundles) + } +} + +func TestCountHiddenClarificationBundles_ZeroHiddenReportsNoExpiry(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + now := time.Now().UTC() + seedAnswerableBundle(t, repo, 8) + + countOpts := unscopedOpts(50) + countOpts.Sidecar = &models.ClarificationSidecarFilter{UserID: "user-1", Only: true, Now: now} + summary, err := repo.CountHiddenClarificationBundles(ctx, countOpts) + if err != nil { + t.Fatalf("count hidden: %v", err) + } + if summary.HiddenCount != 0 { + t.Fatalf("expected 0 hidden bundles, got %d", summary.HiddenCount) + } + if summary.NextSnoozeExpiry != nil { + t.Fatalf("expected no next snooze expiry, got %v", summary.NextSnoozeExpiry) + } +} + +func TestGetClarificationInboxSidecarStates_ResolvesStateAndExpiry(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + now := time.Now().UTC() + dismissedID := seedAnswerableBundle(t, repo, 9) + snoozedID := seedAnswerableBundle(t, repo, 10) + snoozeUntil := now.Add(time.Hour) + + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", dismissedID, models.ClarificationSidecarDismissed, nil, now); err != nil { + t.Fatalf("dismiss: %v", err) + } + if err := repo.UpsertClarificationInboxSidecar(ctx, "user-1", snoozedID, models.ClarificationSidecarSnoozed, &snoozeUntil, now); err != nil { + t.Fatalf("snooze: %v", err) + } + + states, err := repo.GetClarificationInboxSidecarStates(ctx, "user-1", []string{dismissedID, snoozedID}) + if err != nil { + t.Fatalf("get states: %v", err) + } + if states[dismissedID].State != models.ClarificationSidecarDismissed || states[dismissedID].SnoozeUntil != nil { + t.Fatalf("unexpected dismissed entry: %+v", states[dismissedID]) + } + if states[snoozedID].State != models.ClarificationSidecarSnoozed || + states[snoozedID].SnoozeUntil == nil || !states[snoozedID].SnoozeUntil.Equal(snoozeUntil) { + t.Fatalf("unexpected snoozed entry: %+v", states[snoozedID]) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/message.go b/apps/backend/internal/task/repository/sqlite/message.go index 65fee58a9f..ae24fb0177 100644 --- a/apps/backend/internal/task/repository/sqlite/message.go +++ b/apps/backend/internal/task/repository/sqlite/message.go @@ -686,6 +686,47 @@ func (r *Repository) FindMessagesByPendingID(ctx context.Context, pendingID stri return result, err } +// FindMessagesByPendingIDs returns all messages for the requested pending IDs +// in one or more bounded queries, grouped by pending ID. The chunking keeps +// the query below SQLite's host-parameter limit while replacing the inbox's +// per-row message hydration loop. +func (r *Repository) FindMessagesByPendingIDs( + ctx context.Context, pendingIDs []string, +) (map[string][]*models.Message, error) { + result := make(map[string][]*models.Message, len(pendingIDs)) + if len(pendingIDs) == 0 { + return result, nil + } + driverName := r.ro.DriverName() + pendingExpr := dialect.JSONExtract(driverName, "metadata", "pending_id") + for _, chunk := range chunkIDs(pendingIDs, sqliteMaxHostParams) { + placeholders, args := buildInPlaceholders(chunk) + query := fmt.Sprintf(` + SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, + requests_input, type, metadata, created_at, updated_at + FROM task_session_messages + WHERE %s IN (%s) + ORDER BY %s ASC, created_at ASC, id ASC + `, pendingExpr, placeholders, pendingExpr) + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) + if err != nil { + return nil, err + } + messages, _, scanErr := scanMessageRows(rows, 0) + _ = rows.Close() + if scanErr != nil { + return nil, scanErr + } + for _, message := range messages { + pendingID, _ := message.Metadata["pending_id"].(string) + if pendingID != "" { + result[pendingID] = append(result[pendingID], message) + } + } + } + return result, nil +} + // findMessagesByPendingIDQuery is shared with query-plan tests so the // production lookup and its planner witness cannot drift apart. func findMessagesByPendingIDQuery(driverName string) string { diff --git a/apps/backend/internal/task/repository/sqlite/message_pending_index_test.go b/apps/backend/internal/task/repository/sqlite/message_pending_index_test.go index 142d32673c..8f3c9775cc 100644 --- a/apps/backend/internal/task/repository/sqlite/message_pending_index_test.go +++ b/apps/backend/internal/task/repository/sqlite/message_pending_index_test.go @@ -146,6 +146,29 @@ func TestFindMessagesByPendingIDOrdersEqualTimestampsByID(t *testing.T) { } } +func TestFindMessagesByPendingIDsGroupsMessagesInOneBatch(t *testing.T) { + repo := newRepoForSessionTests(t) + ctx := context.Background() + seedForMsgTest(t, repo, "task-pending-batch", "session-pending-batch", "turn-pending-batch") + createdAt := time.Date(2026, 9, 5, 22, 0, 0, 0, time.UTC) + insertPendingIndexMessage(t, repo, "message-batch-a", "session-pending-batch", "task-pending-batch", "turn-pending-batch", "pending-a", createdAt) + insertPendingIndexMessage(t, repo, "message-batch-b", "session-pending-batch", "task-pending-batch", "turn-pending-batch", "pending-b", createdAt.Add(time.Minute)) + + grouped, err := repo.FindMessagesByPendingIDs(ctx, []string{"pending-b", "missing", "pending-a"}) + if err != nil { + t.Fatalf("find pending message batch: %v", err) + } + if got := messageIDs(grouped["pending-a"]); strings.Join(got, ",") != "message-batch-a" { + t.Fatalf("pending-a messages = %v, want [message-batch-a]", got) + } + if got := messageIDs(grouped["pending-b"]); strings.Join(got, ",") != "message-batch-b" { + t.Fatalf("pending-b messages = %v, want [message-batch-b]", got) + } + if _, ok := grouped["missing"]; ok { + t.Fatalf("missing pending ID returned a group: %#v", grouped["missing"]) + } +} + func assertSQLiteIndexExists(t *testing.T, repo *Repository, indexName string) { t.Helper() var got string diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 82f4845a68..a0c76dad13 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -1885,6 +1885,12 @@ func (s *Service) GetTask(ctx context.Context, id string) (*models.Task, error) return task, nil } +// GetTasksByIDs fetches task rows in one repository query. Inbox callers use +// this after a workspace-scoped bundle query has already authorized the rows. +func (s *Service) GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error) { + return s.tasks.GetTasksByIDs(ctx, ids) +} + func (s *Service) tryUpdateTaskPriorityOnly( ctx context.Context, id string, diff --git a/apps/web/app/needs-you-inbox/needs-you-inbox-page-client.test.tsx b/apps/web/app/needs-you-inbox/needs-you-inbox-page-client.test.tsx new file mode 100644 index 0000000000..407b30698b --- /dev/null +++ b/apps/web/app/needs-you-inbox/needs-you-inbox-page-client.test.tsx @@ -0,0 +1,159 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sessionId as toSessionId, taskId as toTaskId } from "@/lib/types/http"; +import type { ClarificationInboxBundle } from "@/lib/types/clarification-inbox"; + +const mocks = vi.hoisted(() => ({ + bumpRefreshTick: vi.fn(), +})); + +const EMPTY_TESTID = "needs-you-inbox-empty"; + +let state: { + status: "idle" | "loading" | "ready" | "error"; + bundles: ClarificationInboxBundle[]; + hiddenCount: number; + hasMore: boolean; +}; + +vi.mock("@/components/state-provider", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useAppStore: (selector: (s: any) => unknown) => + selector({ + needsYouInbox: { byWorkspaceId: { w1: state } }, + workspaces: { activeId: "w1" }, + bumpNeedsYouInboxRefreshTick: mocks.bumpRefreshTick, + }), +})); + +// The shell is the app's own chrome (topbar, nav trigger, scroll container) +// and pulls the whole nav context in with it; these cases are about which view +// the page resolves to, so it is stubbed down to the title it is handed. +vi.mock("@/components/page-shell", () => ({ + PageShell: ({ title, children }: { title: string; children: React.ReactNode }) => ( +
+ {children} +
+ ), +})); + +vi.mock("@/components/needs-you-inbox/needs-you-inbox-row", () => ({ + NeedsYouInboxRow: ({ bundle }: { bundle: ClarificationInboxBundle }) => ( +
{bundle.pending_id}
+ ), +})); + +vi.mock("@/components/needs-you-inbox/needs-you-inbox-hidden-panel", () => ({ + NeedsYouInboxHiddenPanel: ({ hiddenCount }: { hiddenCount: number }) => ( +
{hiddenCount}
+ ), +})); + +import { NeedsYouInboxPageClient } from "./needs-you-inbox-page-client"; + +function bundle(id: string): ClarificationInboxBundle { + return { + pending_id: id, + task_id: "t1", + session_id: "s1", + session_state: "WAITING_FOR_INPUT", + task_title: "Task", + created_at: "2026-09-03T04:54:02Z", + context: "", + messages: [ + { + id: "m1", + session_id: toSessionId("s1"), + task_id: toTaskId("t1"), + author_type: "agent", + content: "", + type: "clarification_request", + created_at: "2026-09-03T04:54:02Z", + metadata: { + pending_id: id, + session_id: "s1", + question: { id: "q1", title: "Q", prompt: "", options: [] }, + }, + }, + ], + }; +} + +beforeEach(() => { + mocks.bumpRefreshTick.mockReset(); + state = { status: "idle", bundles: [], hiddenCount: 0, hasMore: false }; +}); + +afterEach(() => cleanup()); + +describe("NeedsYouInboxPageClient", () => { + it("titles the route Inbox, which the app top bar owns (design-03#D4)", () => { + render(); + + expect(screen.getByTestId("stub-page-shell").getAttribute("data-title")).toBe("Inbox"); + }); + + it("renders a row per listed bundle", () => { + state = { + status: "ready", + bundles: [bundle("p1"), bundle("p2")], + hiddenCount: 0, + hasMore: false, + }; + render(); + + expect(screen.getAllByTestId("stub-row")).toHaveLength(2); + }); + + it("renders the empty state when the read succeeded with no rows and no truncation", () => { + state = { status: "ready", bundles: [], hiddenCount: 0, hasMore: false }; + render(); + + expect(screen.getByTestId(EMPTY_TESTID)).not.toBeNull(); + }); + + it("renders the error state, not the empty state, when the read failed (AC .21)", () => { + state = { status: "error", bundles: [], hiddenCount: 0, hasMore: false }; + render(); + + expect(screen.getByTestId("needs-you-inbox-error")).not.toBeNull(); + expect(screen.queryByTestId(EMPTY_TESTID)).toBeNull(); + }); + + it("renders the error state for a zero-row truncated page rather than caught up (F42)", () => { + state = { status: "ready", bundles: [], hiddenCount: 0, hasMore: true }; + render(); + + expect(screen.getByTestId("needs-you-inbox-error")).not.toBeNull(); + expect(screen.queryByTestId(EMPTY_TESTID)).toBeNull(); + }); + + it("shows the truncation notice when the page is bounded (AC .11)", () => { + state = { status: "ready", bundles: [bundle("p1")], hiddenCount: 0, hasMore: true }; + render(); + + expect(screen.getByTestId("needs-you-inbox-truncated")).not.toBeNull(); + }); + + it("does not show the truncation notice when the page is exhaustive", () => { + state = { status: "ready", bundles: [bundle("p1")], hiddenCount: 0, hasMore: false }; + render(); + + expect(screen.queryByTestId("needs-you-inbox-truncated")).toBeNull(); + }); + + it("shows the hidden panel alongside a non-empty list when bundles are hidden", () => { + state = { status: "ready", bundles: [bundle("p1")], hiddenCount: 2, hasMore: false }; + render(); + + expect(screen.getByTestId("stub-hidden-panel").textContent).toBe("2"); + }); + + it("shows a loading indicator on the very first read, not the empty state", () => { + state = { status: "loading", bundles: [], hiddenCount: 0, hasMore: false }; + render(); + + expect(screen.getByRole("status")).not.toBeNull(); + expect(screen.queryByTestId(EMPTY_TESTID)).toBeNull(); + }); +}); diff --git a/apps/web/app/needs-you-inbox/needs-you-inbox-page-client.tsx b/apps/web/app/needs-you-inbox/needs-you-inbox-page-client.tsx new file mode 100644 index 0000000000..eab4dd0351 --- /dev/null +++ b/apps/web/app/needs-you-inbox/needs-you-inbox-page-client.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useCallback } from "react"; +import { useTranslation } from "react-i18next"; +import { PageShell } from "@/components/page-shell"; +import { useAppStore } from "@/components/state-provider"; +import { + selectNeedsYouInboxBundles, + selectNeedsYouInboxHasMore, + selectNeedsYouInboxHiddenCount, + selectNeedsYouInboxRevision, + selectNeedsYouInboxStatus, +} from "@/lib/state/slices/needs-you-inbox/selectors"; +import type { ClarificationInboxBundle } from "@/lib/types/clarification-inbox"; +import { NeedsYouInboxRow } from "@/components/needs-you-inbox/needs-you-inbox-row"; +import { NeedsYouInboxEmptyState } from "@/components/needs-you-inbox/needs-you-inbox-empty-state"; +import { NeedsYouInboxErrorState } from "@/components/needs-you-inbox/needs-you-inbox-error-state"; +import { NeedsYouInboxHiddenPanel } from "@/components/needs-you-inbox/needs-you-inbox-hidden-panel"; + +type ViewMode = "error" | "loading" | "empty" | "list"; + +// A page reporting truncation while listing zero rows means enrichment +// emptied a page the query had filled, so this resolves to the retryable +// "error" state rather than "empty" (design-01#Data-and-contracts). +function resolveViewMode( + status: string, + bundleCount: number, + hiddenCount: number, + hasMore: boolean, +): ViewMode { + if (status === "error" || (bundleCount === 0 && hasMore)) return "error"; + if (status === "loading" && bundleCount === 0 && hiddenCount === 0) return "loading"; + if (bundleCount === 0) return "empty"; + return "list"; +} + +function NeedsYouInboxList({ + bundles, + hasMore, + hiddenCount, + listRevision, +}: { + bundles: readonly ClarificationInboxBundle[]; + hasMore: boolean; + hiddenCount: number; + listRevision: number; +}) { + const { t } = useTranslation(); + return ( + <> +
+ {bundles.map((bundle) => ( + + ))} +
+ {hasMore && ( +

+ {t("needsYouInbox:truncatedNotice")} +

+ )} + {hiddenCount > 0 && ( + + )} + + ); +} + +// v1 renders no tab strip and no in-page title (design-01#Components). The +// title belongs to the app top bar, which is PageShell's, so this route mounts +// the same chrome every other top-level route does rather than an unlabelled +// bare div -- that chrome also carries the phone nav trigger (design-03#D4). +export function NeedsYouInboxPageClient() { + const { t } = useTranslation(); + const status = useAppStore(selectNeedsYouInboxStatus); + const bundles = useAppStore(selectNeedsYouInboxBundles); + const hiddenCount = useAppStore(selectNeedsYouInboxHiddenCount); + const listRevision = useAppStore(selectNeedsYouInboxRevision); + const hasMore = useAppStore(selectNeedsYouInboxHasMore); + const bumpRefreshTick = useAppStore((s) => s.bumpNeedsYouInboxRefreshTick); + const retry = useCallback(() => bumpRefreshTick(), [bumpRefreshTick]); + + const viewMode = resolveViewMode(status, bundles.length, hiddenCount, hasMore); + + return ( + + {viewMode === "error" && } + {viewMode === "loading" && ( +

+ {t("common:loading")} +

+ )} + {viewMode === "empty" && ( + + )} + {viewMode === "list" && ( + + )} +
+ ); +} diff --git a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx index 2247e7c5e1..a88d18dd6c 100644 --- a/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-nav-item.tsx @@ -16,6 +16,8 @@ type AppSidebarNavItemProps = { href?: string; badge?: number; badgeVariant?: "primary" | "muted"; + /** Appended after the number, e.g. "+" for a capped/truncated count. */ + badgeSuffix?: string; activity?: QuickChatActivityState; onClick?: () => void; collapsed: boolean; @@ -86,12 +88,21 @@ function sidebarBadgeClass(variant: NonNullable {label} - {typeof badge === "number" && badge > 0 && ( - {badge} - )} + {badgeLabel && {badgeLabel}} )} @@ -138,7 +148,7 @@ export function AppSidebarNavItem({ {buttonOrLink} {label} - {typeof badge === "number" && badge > 0 ? ` (${badge})` : ""} + {badgeLabel ? ` (${badgeLabel})` : ""} ); 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 b2a1299dc0..2d589d4044 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 @@ -2,6 +2,7 @@ import { cleanup, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { defaultState } from "@/lib/state/default-state"; +import { defaultFeatureFlags, type FeatureFlags } from "@/lib/state/slices/features/types"; const mocks = vi.hoisted(() => ({ openQuickChat: vi.fn(), @@ -10,7 +11,9 @@ const mocks = vi.hoisted(() => ({ const state = { workspaces: { activeId: "ws-1" as string | null }, userSettings: { ...defaultState.userSettings }, + features: { ...defaultFeatureFlags } as FeatureFlags, office: { inboxCountByWorkspaceId: {} as Record }, + needsYouInbox: { byWorkspaceId: {} as Record }, quickChat: { isOpen: false, sessions: [] as Array<{ @@ -67,6 +70,8 @@ describe("AppSidebarPrimaryNav", () => { beforeEach(() => { state.workspaces.activeId = "ws-1"; state.userSettings = { ...defaultState.userSettings }; + state.features = { ...defaultFeatureFlags }; + state.needsYouInbox = { byWorkspaceId: {} }; state.office.inboxCountByWorkspaceId = {}; state.quickChat.isOpen = false; state.quickChat.sessions = []; @@ -162,3 +167,64 @@ describe("AppSidebarPrimaryNav", () => { expect(screen.queryByRole("link", { name: "Home" })).toBeNull(); }); }); + +describe("AppSidebarPrimaryNav — Needs-you Inbox nav entry", () => { + beforeEach(() => { + state.workspaces.activeId = "ws-1"; + state.userSettings = { ...defaultState.userSettings }; + state.features = { ...defaultFeatureFlags }; + state.needsYouInbox = { byWorkspaceId: {} }; + state.office.inboxCountByWorkspaceId = {}; + mode = "kanban"; + pathname = "/"; + }); + + afterEach(() => cleanup()); + + it("is hidden when the feature flag is off", () => { + state.features.needsYouInbox = false; + renderNav(false); + + expect(screen.queryByRole("link", { name: "Inbox" })).toBeNull(); + }); + + it("names the destination Inbox, not the bucket it renders (design-03#D4)", () => { + state.features.needsYouInbox = true; + mode = "kanban"; + renderNav(false); + + const link = screen.getByRole("link", { name: "Inbox" }); + expect(link.getAttribute("href")).toBe("/needs-you-inbox"); + expect(screen.queryByRole("link", { name: "Needs you" })).toBeNull(); + }); + + it("still renders while in Office mode, distinguishable from Office's own Inbox (design-03#D4)", () => { + state.features.needsYouInbox = true; + mode = "office"; + renderNav(false); + + // AC .3 keeps both entries present, so they must not share a name. + expect(screen.getByRole("link", { name: "Needs you" }).getAttribute("href")).toBe( + "/needs-you-inbox", + ); + expect(screen.getByRole("link", { name: "Inbox" }).getAttribute("href")).toBe("/office/inbox"); + }); + + it("shows the count from the active workspace's needs-you-inbox state as a badge", () => { + state.features.needsYouInbox = true; + state.needsYouInbox.byWorkspaceId["ws-1"] = { count: 3 }; + renderNav(false); + + const link = screen.getByRole("link", { name: "Inbox" }); + expect(link.textContent).toContain("3"); + }); + + it("presents the count as capped when the list is truncated (AC .14)", () => { + state.features.needsYouInbox = true; + state.needsYouInbox.byWorkspaceId["ws-1"] = { count: 50, hasMore: true }; + renderNav(false); + + const link = screen.getByRole("link", { name: "Inbox" }); + expect(link.textContent).toContain("50+"); + }); +}); 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 1664d9f10a..9c0ce4a4e1 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -4,10 +4,16 @@ import { IconHome, IconInbox, IconMessageCircle } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useAppStore } from "@/components/state-provider"; import { selectOfficeInboxCount } from "@/lib/state/slices/office/selectors"; +import { + selectNeedsYouInboxCount, + selectNeedsYouInboxHasMore, +} from "@/lib/state/slices/needs-you-inbox/selectors"; +import { useFeature } from "@/hooks/domains/features/use-feature"; import { useOfficeModeState } from "@/hooks/use-in-office"; import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; import { useQuickChatActivity } from "@/components/quick-chat/use-quick-chat-activity"; import { homeDestinationHref } from "@/lib/navigation/core-destinations"; +import { NEEDS_YOU_INBOX_HREF } from "@/lib/navigation/needs-you-inbox-destination"; import { AppSidebarNavItem } from "./app-sidebar-nav-item"; import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; @@ -20,6 +26,9 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { const workspaceId = useAppStore((s) => s.workspaces.activeId); const startupPage = useAppStore((s) => s.userSettings.startupPage); const inboxCount = useAppStore(selectOfficeInboxCount); + const needsYouInboxEnabled = useFeature("needsYouInbox"); + const needsYouInboxCount = useAppStore(selectNeedsYouInboxCount); + const needsYouInboxHasMore = useAppStore(selectNeedsYouInboxHasMore); const mode = useOfficeModeState(); const inOffice = mode === "office"; const handleOpenQuickChat = useQuickChatLauncher(workspaceId); @@ -48,6 +57,20 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { collapsed={collapsed} /> )} + {/* The destination is the Inbox (design-03#D4). "Needs you" names the one + bucket it renders, not the place, and is used only in Office mode, + where AC .3 keeps this entry present alongside Office's own Inbox row + and two identically named rows would be indistinguishable. */} + {needsYouInboxEnabled && ( + + )} {workspaceId && collapsed && ( ({ + listHiddenClarificationInbox: vi.fn().mockResolvedValue({ bundles: [], count: 0, total: 0 }), + restoreClarificationInboxBundle: vi.fn(), +})); + +let workspaceItems: Array<{ id: string; name: string }> = [{ id: "w1", name: "Kegmil V2" }]; + +vi.mock("@/components/state-provider", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useAppStore: (selector: (s: any) => unknown) => + selector({ + workspaces: { activeId: "w1", items: workspaceItems }, + bumpNeedsYouInboxRefreshTick: vi.fn(), + }), +})); + +import { NeedsYouInboxEmptyState } from "./needs-you-inbox-empty-state"; + +afterEach(() => { + cleanup(); + workspaceItems = [{ id: "w1", name: "Kegmil V2" }]; +}); + +describe("NeedsYouInboxEmptyState", () => { + it("names the workspace rather than claiming the whole instance is quiet (design-03#D2)", () => { + render(); + + expect(screen.getByText("Nothing is waiting on you in Kegmil V2.")).not.toBeNull(); + }); + + it("falls back to a workspace-less sentence when the active workspace is unresolved", () => { + workspaceItems = []; + render(); + + expect(screen.getByText("Nothing is waiting on you in this workspace.")).not.toBeNull(); + }); + + it("does not congratulate: an empty queue is a normal state, not an achievement (design-03#D2)", () => { + const { container } = render(); + + expect(container.querySelector("svg")).toBeNull(); + expect(container.textContent).not.toContain("!"); + expect(container.textContent).not.toContain("caught up"); + }); + + it("names all four things it does not count (AC .20)", () => { + render(); + + const description = screen.getByText(/does not count bundles in other workspaces/); + expect(description.textContent).toContain("bundles in other workspaces"); + expect(description.textContent).toContain("pending permission requests"); + expect(description.textContent).toContain("parent-question records"); + expect(description.textContent).toContain("snoozed or dismissed rows"); + }); + + it("does not imply anything is hidden when nothing is (AC .20)", () => { + render(); + + expect(screen.queryByTestId("needs-you-inbox-hidden-panel")).toBeNull(); + }); + + it("discloses and offers to restore hidden bundles when the operator has some hidden", () => { + render(); + + expect(screen.getByTestId("needs-you-inbox-hidden-panel")).not.toBeNull(); + }); +}); diff --git a/apps/web/components/needs-you-inbox/needs-you-inbox-empty-state.tsx b/apps/web/components/needs-you-inbox/needs-you-inbox-empty-state.tsx new file mode 100644 index 0000000000..61955df185 --- /dev/null +++ b/apps/web/components/needs-you-inbox/needs-you-inbox-empty-state.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { useAppStore } from "@/components/state-provider"; +import { NeedsYouInboxHiddenPanel } from "./needs-you-inbox-hidden-panel"; + +// An empty queue is a normal state, not an achievement: no icon, no +// congratulation, and the workspace is named so the sentence is never read as a +// claim about the whole instance (design-03#D2). Hidden bundles are disclosed +// only when the operator actually has some (AC .20). +export function NeedsYouInboxEmptyState({ + hiddenCount, + listRevision = 0, +}: { + hiddenCount: number; + listRevision?: number; +}) { + const { t } = useTranslation(); + const workspaceName = useAppStore( + (s) => s.workspaces?.items?.find((w) => w.id === s.workspaces.activeId)?.name, + ); + return ( +
+
+

+ {workspaceName + ? t("needsYouInbox:emptyTitle", { workspace: workspaceName }) + : t("needsYouInbox:emptyTitleUnnamedWorkspace")} +

+

{t("needsYouInbox:emptyDescription")}

+
+ {hiddenCount > 0 && ( + + )} +
+ ); +} diff --git a/apps/web/components/needs-you-inbox/needs-you-inbox-error-state.test.tsx b/apps/web/components/needs-you-inbox/needs-you-inbox-error-state.test.tsx new file mode 100644 index 0000000000..42a2cc6470 --- /dev/null +++ b/apps/web/components/needs-you-inbox/needs-you-inbox-error-state.test.tsx @@ -0,0 +1,23 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { NeedsYouInboxErrorState } from "./needs-you-inbox-error-state"; + +afterEach(() => cleanup()); + +describe("NeedsYouInboxErrorState", () => { + it("renders as an alert with a retry action (AC .21)", () => { + render(); + + expect(screen.getByRole("alert")).not.toBeNull(); + expect(screen.getByRole("button", { name: "Retry" })).not.toBeNull(); + }); + + it("calls onRetry when the retry button is clicked", () => { + const onRetry = vi.fn(); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + + expect(onRetry).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/components/needs-you-inbox/needs-you-inbox-error-state.tsx b/apps/web/components/needs-you-inbox/needs-you-inbox-error-state.tsx new file mode 100644 index 0000000000..324f33b26d --- /dev/null +++ b/apps/web/components/needs-you-inbox/needs-you-inbox-error-state.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { IconAlertTriangle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { useTranslation } from "react-i18next"; + +export function NeedsYouInboxErrorState({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslation(); + return ( +
+ +

{t("needsYouInbox:errorTitle")}

+

{t("needsYouInbox:errorDescription")}

+ +
+ ); +} diff --git a/apps/web/components/needs-you-inbox/needs-you-inbox-hidden-panel.test.tsx b/apps/web/components/needs-you-inbox/needs-you-inbox-hidden-panel.test.tsx new file mode 100644 index 0000000000..9a8f14ce83 --- /dev/null +++ b/apps/web/components/needs-you-inbox/needs-you-inbox-hidden-panel.test.tsx @@ -0,0 +1,285 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClarificationInboxHiddenBundle } from "@/lib/types/clarification-inbox"; + +const mocks = vi.hoisted(() => ({ + listHidden: vi.fn(), + restore: vi.fn(), + bumpRefreshTick: vi.fn(), + toastError: vi.fn(), + activeWorkspaceId: "w1", +})); + +vi.mock("@/lib/api/domains/clarification-inbox-api", () => ({ + listHiddenClarificationInbox: (...args: unknown[]) => mocks.listHidden(...args), + restoreClarificationInboxBundle: (...args: unknown[]) => mocks.restore(...args), +})); + +vi.mock("@/lib/toast/sonner", () => ({ + toast: Object.assign(vi.fn(), { error: (...args: unknown[]) => mocks.toastError(...args) }), +})); + +vi.mock("@/components/state-provider", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useAppStore: (selector: (s: any) => unknown) => + selector({ + workspaces: { activeId: mocks.activeWorkspaceId }, + bumpNeedsYouInboxRefreshTick: mocks.bumpRefreshTick, + }), +})); + +import { NeedsYouInboxHiddenPanel } from "./needs-you-inbox-hidden-panel"; + +const SHOW_HIDDEN = "Show hidden"; +const WORKSPACE_ONE_CONTEXT = "From workspace one"; +const WORKSPACE_TWO_CONTEXT = "From workspace two"; + +function hiddenBundle( + overrides: Partial = {}, +): ClarificationInboxHiddenBundle { + return { + pending_id: "p1", + task_id: "t1", + session_id: "s1", + session_state: "WAITING_FOR_INPUT", + task_title: "Task", + created_at: "2026-09-03T04:54:02Z", + context: "Deploying", + messages: [], + state: "dismissed", + snooze_until: null, + ...overrides, + }; +} + +beforeEach(() => { + mocks.listHidden.mockReset(); + mocks.restore.mockReset().mockResolvedValue(undefined); + mocks.bumpRefreshTick.mockReset(); + mocks.toastError.mockReset(); + mocks.activeWorkspaceId = "w1"; +}); + +afterEach(() => cleanup()); + +describe("NeedsYouInboxHiddenPanel", () => { + it("discloses the hidden count without fetching until expanded", () => { + render(); + + expect( + screen.getByText("3 questions are hidden by your own dismiss or snooze."), + ).not.toBeNull(); + expect(mocks.listHidden).not.toHaveBeenCalled(); + }); + + it("fetches and lists hidden bundles when expanded", async () => { + mocks.listHidden.mockResolvedValue({ bundles: [hiddenBundle()], count: 1, total: 1 }); + render(); + + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + expect(await screen.findByTestId("needs-you-inbox-hidden-row")).not.toBeNull(); + expect(mocks.listHidden).toHaveBeenCalledWith("w1"); + }); + + it("shows an empty message when nothing is currently hidden despite a stale count", async () => { + mocks.listHidden.mockResolvedValue({ bundles: [], count: 0, total: 0 }); + render(); + + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + expect(await screen.findByText("Nothing is currently hidden.")).not.toBeNull(); + }); + + it("restores a hidden bundle and re-reads the main list (AC .33)", async () => { + mocks.listHidden.mockResolvedValue({ bundles: [hiddenBundle()], count: 1, total: 1 }); + render(); + + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + fireEvent.click(await screen.findByText("Restore")); + + await act(async () => { + await Promise.resolve(); + }); + expect(mocks.restore).toHaveBeenCalledWith("p1"); + expect(mocks.bumpRefreshTick).toHaveBeenCalledTimes(1); + }); + + it("shows a retryable error when the hidden read fails", async () => { + mocks.listHidden.mockRejectedValue(new Error("boom")); + render(); + + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + expect(await screen.findByText("Could not load hidden questions. Try again.")).not.toBeNull(); + }); + + it("prefers the fetched total over a stale hiddenCount prop once loaded", async () => { + mocks.listHidden.mockResolvedValue({ bundles: [hiddenBundle()], count: 1, total: 5 }); + render(); + + expect(screen.getByText("1 question is hidden by your own dismiss or snooze.")).not.toBeNull(); + + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + await screen.findByTestId("needs-you-inbox-hidden-row"); + + expect( + screen.getByText("5 questions are hidden by your own dismiss or snooze."), + ).not.toBeNull(); + }); + + it("clears the stale list and re-fetches when the active workspace changes", async () => { + mocks.listHidden.mockResolvedValue({ + bundles: [hiddenBundle({ pending_id: "w1-p1", context: WORKSPACE_ONE_CONTEXT })], + count: 1, + total: 1, + }); + const { rerender } = render(); + + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + await screen.findByText(WORKSPACE_ONE_CONTEXT); + expect(mocks.listHidden).toHaveBeenCalledWith("w1"); + + mocks.activeWorkspaceId = "w2"; + mocks.listHidden.mockResolvedValue({ + bundles: [hiddenBundle({ pending_id: "w2-p1", context: WORKSPACE_TWO_CONTEXT })], + count: 1, + total: 1, + }); + rerender(); + + expect(await screen.findByText(WORKSPACE_TWO_CONTEXT)).not.toBeNull(); + expect(screen.queryByText(WORKSPACE_ONE_CONTEXT)).toBeNull(); + expect(mocks.listHidden).toHaveBeenLastCalledWith("w2"); + }); + + it("discards a stale in-flight response from the previous workspace", async () => { + let resolveFirst: ((value: unknown) => void) | undefined; + mocks.listHidden.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ); + const { rerender } = render(); + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + + mocks.activeWorkspaceId = "w2"; + mocks.listHidden.mockResolvedValue({ + bundles: [hiddenBundle({ pending_id: "w2-p1", context: WORKSPACE_TWO_CONTEXT })], + count: 1, + total: 1, + }); + rerender(); + await screen.findByText(WORKSPACE_TWO_CONTEXT); + + await act(async () => { + resolveFirst?.({ + bundles: [hiddenBundle({ pending_id: "w1-p1", context: WORKSPACE_ONE_CONTEXT })], + count: 1, + total: 1, + }); + await Promise.resolve(); + }); + + expect(screen.queryByText(WORKSPACE_ONE_CONTEXT)).toBeNull(); + expect(screen.getByText(WORKSPACE_TWO_CONTEXT)).not.toBeNull(); + }); +}); + +describe("NeedsYouInboxHiddenPanel staleness guards", () => { + it("does not let a restore begun before a workspace switch overwrite the new workspace's panel", async () => { + mocks.listHidden.mockResolvedValueOnce({ + bundles: [hiddenBundle({ pending_id: "w1-p1", context: WORKSPACE_ONE_CONTEXT })], + count: 1, + total: 1, + }); + const { rerender } = render(); + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + await screen.findByText(WORKSPACE_ONE_CONTEXT); + + let resolveRestore: (() => void) | undefined; + mocks.restore.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRestore = resolve; + }), + ); + fireEvent.click(screen.getByText("Restore")); + + mocks.activeWorkspaceId = "w2"; + // Both the workspace-change and the hiddenCount-change effects can fire + // on this rerender; give every call the same answer rather than pinning + // an exact call count that isn't this test's concern. + mocks.listHidden.mockResolvedValue({ bundles: [], count: 0, total: 0 }); + rerender(); + await screen.findByText("Nothing is currently hidden."); + + const callsBeforeRestoreSettles = mocks.listHidden.mock.calls.length; + await act(async () => { + resolveRestore?.(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // The stale w1 continuation must not re-fetch w1 nor reintroduce its row. + expect(mocks.listHidden.mock.calls.length).toBe(callsBeforeRestoreSettles); + expect(screen.getByText("Nothing is currently hidden.")).not.toBeNull(); + expect(screen.queryByText(WORKSPACE_ONE_CONTEXT)).toBeNull(); + }); + + it("re-enumerates while open when the main list's hidden count changes elsewhere", async () => { + mocks.listHidden.mockResolvedValueOnce({ + bundles: [ + hiddenBundle({ pending_id: "p1", context: "First" }), + hiddenBundle({ pending_id: "p2", context: "Second" }), + ], + count: 2, + total: 2, + }); + const { rerender } = render(); + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + await screen.findByText("First"); + await screen.findByText("Second"); + + // Another row was dismissed elsewhere: the main list's hiddenCount grew + // without any action inside this panel. + mocks.listHidden.mockResolvedValueOnce({ + bundles: [ + hiddenBundle({ pending_id: "p1", context: "First" }), + hiddenBundle({ pending_id: "p2", context: "Second" }), + hiddenBundle({ pending_id: "p3", context: "Third" }), + ], + count: 3, + total: 3, + }); + rerender(); + + expect(await screen.findByText("Third")).not.toBeNull(); + expect(mocks.listHidden).toHaveBeenCalledTimes(2); + expect( + screen.getByText("3 questions are hidden by your own dismiss or snooze."), + ).not.toBeNull(); + }); + + it("re-enumerates while open when a newer main-list response keeps the same hidden count", async () => { + mocks.listHidden.mockResolvedValueOnce({ + bundles: [hiddenBundle({ pending_id: "p1", context: "First" })], + count: 1, + total: 1, + }); + const { rerender } = render(); + fireEvent.click(screen.getByText(SHOW_HIDDEN)); + await screen.findByText("First"); + + // A newer main-list response replaced one hidden row with another, so the + // count stayed at one while the disclosed set changed. + mocks.listHidden.mockResolvedValueOnce({ + bundles: [hiddenBundle({ pending_id: "p2", context: "Second" })], + count: 1, + total: 1, + }); + rerender(); + + expect(await screen.findByText("Second")).not.toBeNull(); + expect(screen.queryByText("First")).toBeNull(); + expect(mocks.listHidden).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/components/needs-you-inbox/needs-you-inbox-hidden-panel.tsx b/apps/web/components/needs-you-inbox/needs-you-inbox-hidden-panel.tsx new file mode 100644 index 0000000000..67d554d336 --- /dev/null +++ b/apps/web/components/needs-you-inbox/needs-you-inbox-hidden-panel.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@kandev/ui/button"; +import { useAppStore } from "@/components/state-provider"; +import { toast } from "@/lib/toast/sonner"; +import { formatRelativeTime } from "@/lib/i18n/formats"; +import { + listHiddenClarificationInbox, + restoreClarificationInboxBundle, +} from "@/lib/api/domains/clarification-inbox-api"; +import type { ClarificationInboxHiddenBundle } from "@/lib/types/clarification-inbox"; +import { rowPrimaryText } from "@/lib/needs-you-inbox/row-presentation"; + +type HiddenListStatus = "idle" | "loading" | "ready" | "error"; + +function HiddenRow({ + bundle, + onRestore, +}: { + bundle: ClarificationInboxHiddenBundle; + onRestore: (pendingId: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+
+

+ {rowPrimaryText(bundle, t("needsYouInbox:questionFromAgent"))} +

+

+ {bundle.state === "snoozed" && bundle.snooze_until + ? t("needsYouInbox:snoozedUntil", { time: formatRelativeTime(bundle.snooze_until) }) + : t("needsYouInbox:dismissed")} +

+
+ +
+ ); +} + +function useHiddenList( + workspaceId: string | null, + open: boolean, + hiddenCount: number, + listRevision: number, +) { + const [status, setStatus] = useState("idle"); + const [bundles, setBundles] = useState([]); + const [total, setTotal] = useState(null); + const generationRef = useRef(0); + // Read inside `load` instead of `workspaceId` itself: a restore captures + // this closure while its DELETE is in flight, and a workspace switch during + // that wait must not let the stale continuation apply workspace A's read to + // workspace B's panel (or bump the shared generation past B's own request). + const currentWorkspaceIdRef = useRef(workspaceId); + currentWorkspaceIdRef.current = workspaceId; + + const load = useCallback(async () => { + if (!workspaceId || currentWorkspaceIdRef.current !== workspaceId) return; + const generation = ++generationRef.current; + setStatus("loading"); + try { + const page = await listHiddenClarificationInbox(workspaceId); + if (generationRef.current !== generation || currentWorkspaceIdRef.current !== workspaceId) { + return; + } + setBundles(page.bundles); + setTotal(page.total); + setStatus("ready"); + } catch { + if (generationRef.current !== generation || currentWorkspaceIdRef.current !== workspaceId) { + return; + } + setStatus("error"); + } + }, [workspaceId]); + + const previousWorkspaceId = useRef(workspaceId); + useEffect(() => { + if (previousWorkspaceId.current === workspaceId) return; + previousWorkspaceId.current = workspaceId; + generationRef.current += 1; + setBundles([]); + setTotal(null); + setStatus("idle"); + if (open) void load(); + }, [workspaceId, open, load]); + + // The main list's hidden count is the only signal this panel gets that the + // hidden set changed elsewhere (another row dismissed/snoozed, or a snooze + // expiring): re-enumerate so the disclosed total and rows stay reconciled + // with it rather than showing what this panel happened to load last. + const previousListState = useRef({ hiddenCount, listRevision }); + useEffect(() => { + const previous = previousListState.current; + previousListState.current = { hiddenCount, listRevision }; + if (previous.hiddenCount === hiddenCount && previous.listRevision === listRevision) return; + if (open) void load(); + }, [hiddenCount, listRevision, open, load]); + + return { status, bundles, total, load }; +} + +// Discloses how many answerable bundles this operator's own dismiss or +// snooze is hiding, and lets them enumerate and restore one. +export function NeedsYouInboxHiddenPanel({ + hiddenCount, + listRevision = 0, +}: { + hiddenCount: number; + listRevision?: number; +}) { + const { t } = useTranslation(); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const bumpRefreshTick = useAppStore((s) => s.bumpNeedsYouInboxRefreshTick); + const [open, setOpen] = useState(false); + const { status, bundles, total, load } = useHiddenList( + workspaceId, + open, + hiddenCount, + listRevision, + ); + const displayCount = total ?? hiddenCount; + + const toggle = useCallback(() => { + setOpen((current) => { + const next = !current; + if (next) void load(); + return next; + }); + }, [load]); + + const handleRestore = useCallback( + async (pendingId: string) => { + try { + await restoreClarificationInboxBundle(pendingId); + bumpRefreshTick(); + void load(); + } catch { + toast.error(t("needsYouInbox:restoreFailed")); + } + }, + [bumpRefreshTick, load, t], + ); + + return ( +
+
+

+ {t("needsYouInbox:hiddenNotice", { count: displayCount })} +

+ +
+ {open && ( +
+ {status === "loading" && ( +

{t("needsYouInbox:hiddenLoading")}

+ )} + {status === "error" && ( +

{t("needsYouInbox:hiddenLoadFailed")}

+ )} + {status === "ready" && bundles.length === 0 && ( +

{t("needsYouInbox:hiddenEmpty")}

+ )} + {status === "ready" && + bundles.map((bundle) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/needs-you-inbox/needs-you-inbox-row.test.tsx b/apps/web/components/needs-you-inbox/needs-you-inbox-row.test.tsx new file mode 100644 index 0000000000..61fe9d39af --- /dev/null +++ b/apps/web/components/needs-you-inbox/needs-you-inbox-row.test.tsx @@ -0,0 +1,270 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { sessionId as toSessionId, taskId as toTaskId } from "@/lib/types/http"; +import type { ClarificationInboxBundle } from "@/lib/types/clarification-inbox"; +import type { ClarificationOutcome } from "@/hooks/domains/session/use-clarification-group"; + +const mocks = vi.hoisted(() => ({ + dismiss: vi.fn(), + snooze: vi.fn(), + bumpRefreshTick: vi.fn(), + toast: vi.fn(), + toastError: vi.fn(), +})); + +let capturedOnOutcome: ((outcome: ClarificationOutcome) => void) | undefined; + +vi.mock("@/lib/api/domains/clarification-inbox-api", () => ({ + dismissClarificationInboxBundle: (...args: unknown[]) => mocks.dismiss(...args), + snoozeClarificationInboxBundle: (...args: unknown[]) => mocks.snooze(...args), +})); + +vi.mock("@/lib/toast/sonner", () => ({ + toast: Object.assign((...args: unknown[]) => mocks.toast(...args), { + error: (...args: unknown[]) => mocks.toastError(...args), + }), +})); + +vi.mock("@/components/state-provider", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + useAppStore: (selector: (state: any) => unknown) => + selector({ bumpNeedsYouInboxRefreshTick: mocks.bumpRefreshTick }), +})); + +vi.mock("@/components/task/chat/clarification-panel-section", () => ({ + ClarificationPanelSection: (props: { onOutcome?: (outcome: ClarificationOutcome) => void }) => { + capturedOnOutcome = props.onOutcome; + return
; + }, +})); + +import { NeedsYouInboxRow } from "./needs-you-inbox-row"; + +function openRowActionsMenu() { + const trigger = screen.getByRole("button", { name: "Row actions" }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" }); + fireEvent.pointerUp(trigger, { button: 0, ctrlKey: false, pointerType: "mouse" }); + fireEvent.click(trigger); +} + +function bundle(overrides: Partial = {}): ClarificationInboxBundle { + return { + pending_id: "p1", + task_id: "t1", + session_id: "s1", + session_state: "WAITING_FOR_INPUT", + task_title: "Fix the deploy", + created_at: new Date().toISOString(), + context: "", + messages: [ + { + id: "m1", + session_id: toSessionId("s1"), + task_id: toTaskId("t1"), + author_type: "agent", + content: "", + type: "clarification_request", + created_at: new Date().toISOString(), + metadata: { + pending_id: "p1", + session_id: "s1", + question: { id: "q1", title: "Pick an option", prompt: "", options: [] }, + }, + }, + ], + ...overrides, + }; +} + +beforeEach(() => { + mocks.dismiss.mockReset().mockResolvedValue(undefined); + mocks.snooze.mockReset().mockResolvedValue(undefined); + mocks.bumpRefreshTick.mockReset(); + mocks.toast.mockReset(); + mocks.toastError.mockReset(); + capturedOnOutcome = undefined; +}); + +afterEach(() => cleanup()); + +describe("NeedsYouInboxRow", () => { + it("shows the primary question, secondary task text, and relative time", () => { + render(); + + expect(screen.getByText("Pick an option")).not.toBeNull(); + expect(screen.getByText("Fix the deploy")).not.toBeNull(); + }); + + it("links to the task, which the row body itself cannot reach (design-03#D1)", () => { + render(); + + const link = screen.getByTestId("needs-you-inbox-open-task"); + expect(link.getAttribute("href")).toBe("/t/t1?sessionId=secondary-session"); + expect(link.textContent).toBe("Open task"); + }); + + it("keeps the task reachable from the actions menu, where narrow viewports must find it (design-03#D1)", () => { + render(); + openRowActionsMenu(); + + const item = screen.getByTestId("needs-you-inbox-open-task-menu-item"); + expect(item.getAttribute("href")).toBe("/t/t1?sessionId=secondary-session"); + }); + + it("states the bundle size only when the bundle holds more than one question (design-03#D3)", () => { + const { unmount } = render(); + expect(screen.queryByTestId("needs-you-inbox-row-question-count")).toBeNull(); + unmount(); + + const two = bundle(); + two.messages = [ + two.messages[0], + { + ...two.messages[0], + id: "m2", + metadata: { + pending_id: "p1", + session_id: "s1", + question: { id: "q2", title: "And another", prompt: "", options: [] }, + }, + }, + ]; + render(); + + expect(screen.getByTestId("needs-you-inbox-row-question-count").textContent).toBe( + "2 questions in this bundle", + ); + }); + + it("labels the row's status through the shared Threads status vocabulary (AC .26)", () => { + render(); + + expect(screen.getByRole("img", { name: "Question from agent" })).not.toBeNull(); + }); + + it("mounts the clarification panel only after the row is expanded", () => { + render(); + + expect(screen.queryByTestId("mock-clarification-panel-section")).toBeNull(); + fireEvent.click(screen.getByTestId("needs-you-inbox-row-toggle")); + expect(screen.getByTestId("mock-clarification-panel-section")).not.toBeNull(); + }); + + it("unmounts the clarification panel on a second toggle", () => { + render(); + + const toggle = screen.getByTestId("needs-you-inbox-row-toggle"); + fireEvent.click(toggle); + fireEvent.click(toggle); + expect(screen.queryByTestId("mock-clarification-panel-section")).toBeNull(); + }); + + it("dismisses the bundle and triggers a convergence re-read", async () => { + render(); + openRowActionsMenu(); + fireEvent.click(await screen.findByText("Dismiss")); + + await act(async () => { + await Promise.resolve(); + }); + expect(mocks.dismiss).toHaveBeenCalledWith("p1"); + expect(mocks.bumpRefreshTick).toHaveBeenCalledTimes(1); + }); + + it("snoozes the bundle for the selected duration and re-reads", async () => { + render(); + openRowActionsMenu(); + const snoozeTrigger = await screen.findByText("Snooze"); + fireEvent.keyDown(snoozeTrigger, { key: "ArrowRight" }); + fireEvent.click(await screen.findByText("4 hours")); + + await act(async () => { + await Promise.resolve(); + }); + expect(mocks.snooze).toHaveBeenCalledWith("p1", "4h"); + expect(mocks.bumpRefreshTick).toHaveBeenCalledTimes(1); + }); + + it("shows a retryable error and does not re-read when dismiss fails", async () => { + mocks.dismiss.mockRejectedValue(new Error("boom")); + render(); + openRowActionsMenu(); + fireEvent.click(await screen.findByText("Dismiss")); + + await act(async () => { + await Promise.resolve(); + }); + expect(mocks.toastError).toHaveBeenCalledOnce(); + expect(mocks.bumpRefreshTick).not.toHaveBeenCalled(); + }); +}); + +describe("NeedsYouInboxRow onOutcome (AC .17, .35, .39)", () => { + function expand() { + fireEvent.click(screen.getByTestId("needs-you-inbox-row-toggle")); + } + + it("this caller won: re-reads with no notice", () => { + render(); + expand(); + act(() => capturedOnOutcome?.({ kind: "resolved", claimedByThisCaller: true })); + + expect(mocks.bumpRefreshTick).toHaveBeenCalledTimes(1); + expect(mocks.toast).not.toHaveBeenCalled(); + }); + + it("another caller won by answering: notice names 'answered'", () => { + render(); + expand(); + act(() => + capturedOnOutcome?.({ kind: "resolved", claimedByThisCaller: false, status: "answered" }), + ); + + expect(mocks.bumpRefreshTick).toHaveBeenCalledTimes(1); + expect(mocks.toast).toHaveBeenCalledOnce(); + expect(mocks.toast.mock.calls[0][0]).toMatch(/answered/i); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); + + it("another caller won by rejecting: notice names 'rejected', not 'answered' (AC .17)", () => { + render(); + expand(); + act(() => + capturedOnOutcome?.({ kind: "resolved", claimedByThisCaller: false, status: "rejected" }), + ); + + expect(mocks.bumpRefreshTick).toHaveBeenCalledTimes(1); + expect(mocks.toast).toHaveBeenCalledOnce(); + expect(mocks.toast.mock.calls[0][0]).toMatch(/rejected/i); + expect(mocks.toast.mock.calls[0][0]).not.toMatch(/answered/i); + }); + + it("another caller won with no reported status (legacy backend): falls back to 'answered' wording", () => { + render(); + expand(); + act(() => capturedOnOutcome?.({ kind: "resolved", claimedByThisCaller: false })); + + expect(mocks.bumpRefreshTick).toHaveBeenCalledTimes(1); + expect(mocks.toast).toHaveBeenCalledOnce(); + expect(mocks.toast.mock.calls[0][0]).toMatch(/answered/i); + }); + + it("bundle no longer active: shows a transient non-error notice and re-reads", () => { + render(); + expand(); + act(() => capturedOnOutcome?.({ kind: "no_longer_active" })); + + expect(mocks.bumpRefreshTick).toHaveBeenCalledTimes(1); + expect(mocks.toast).toHaveBeenCalledOnce(); + }); + + it("submission failed: leaves the row and count untouched (AC .35)", () => { + render(); + expand(); + act(() => capturedOnOutcome?.({ kind: "submission_failed" })); + + expect(mocks.bumpRefreshTick).not.toHaveBeenCalled(); + expect(mocks.toast).not.toHaveBeenCalled(); + expect(mocks.toastError).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/needs-you-inbox/needs-you-inbox-row.tsx b/apps/web/components/needs-you-inbox/needs-you-inbox-row.tsx new file mode 100644 index 0000000000..4435385292 --- /dev/null +++ b/apps/web/components/needs-you-inbox/needs-you-inbox-row.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { + IconChevronDown, + IconChevronRight, + IconDotsVertical, + IconMessageQuestion, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { useTranslation } from "react-i18next"; +import Link from "@/components/routing/app-link"; +import { useAppStore } from "@/components/state-provider"; +import { toast } from "@/lib/toast/sonner"; +import { formatRelativeTime } from "@/lib/i18n/formats"; +import { + dismissClarificationInboxBundle, + snoozeClarificationInboxBundle, +} from "@/lib/api/domains/clarification-inbox-api"; +import type { + ClarificationInboxBundle, + ClarificationInboxSnoozeDuration, +} from "@/lib/types/clarification-inbox"; +import type { + ClarificationOutcome, + ResolvedStatus, +} from "@/hooks/domains/session/use-clarification-group"; +import { ClarificationPanelSection } from "@/components/task/chat/clarification-panel-section"; +import { + rowPrimaryText, + rowQuestionCount, + rowSecondaryText, +} from "@/lib/needs-you-inbox/row-presentation"; +import { resolveThreadSessionStatus } from "@/lib/threads/thread-session-status"; +import type { TaskSessionState } from "@/lib/types/http"; + +const SNOOZE_DURATIONS: ClarificationInboxSnoozeDuration[] = ["1h", "4h", "24h"]; + +const SNOOZE_DURATION_LABEL_KEYS: Record = { + "1h": "needsYouInbox:snoozeDuration1h", + "4h": "needsYouInbox:snoozeDuration4h", + "24h": "needsYouInbox:snoozeDuration24h", +}; + +function useSidecarAction(pendingId: string, onDone: () => void, onFailed: () => void) { + const [busy, setBusy] = useState(false); + const run = useCallback( + async (action: () => Promise) => { + if (busy) return; + setBusy(true); + try { + await action(); + onDone(); + } catch { + onFailed(); + } finally { + setBusy(false); + } + }, + [busy, onDone, onFailed], + ); + return { busy, run }; +} + +// The winner's status is absent only against a pre-R10 backend that never +// sent an envelope status; treating that as "answered" preserves this +// notice's pre-existing wording for that case. +function anotherCallerOutcomeKey(status: ResolvedStatus | undefined): string { + return status === "rejected" + ? "needsYouInbox:anotherCallerRejected" + : "needsYouInbox:anotherCallerResolved"; +} + +function useRowOutcomeNotice(primaryText: string, bumpRefreshTick: () => void) { + const { t } = useTranslation(); + return useCallback( + (outcome: ClarificationOutcome) => { + if (outcome.kind === "resolved") { + if (!outcome.claimedByThisCaller) { + toast(t(anotherCallerOutcomeKey(outcome.status), { question: primaryText })); + } + bumpRefreshTick(); + return; + } + if (outcome.kind === "no_longer_active") { + toast(t("needsYouInbox:bundleNoLongerActive", { question: primaryText })); + bumpRefreshTick(); + } + // submission_failed: the shared overlay's own inline banner already + // offers a retry; the row and count stay untouched here. + }, + [bumpRefreshTick, primaryText, t], + ); +} + +function RowActionsMenu({ + disabled, + taskHref, + onDismiss, + onSnooze, +}: { + disabled: boolean; + taskHref: string; + onDismiss: () => void; + onSnooze: (duration: ClarificationInboxSnoozeDuration) => void; +}) { + const { t } = useTranslation(); + return ( + + + + + + + + {t("needsYouInbox:openTask")} + + + + {t("needsYouInbox:dismiss")} + + + + {t("needsYouInbox:snooze")} + + + {SNOOZE_DURATIONS.map((duration) => ( + onSnooze(duration)} + > + {t(SNOOZE_DURATION_LABEL_KEYS[duration])} + + ))} + + + + + ); +} + +function taskHrefForBundle(bundle: ClarificationInboxBundle): string { + if (!bundle.session_id) return `/t/${bundle.task_id}`; + return `/t/${bundle.task_id}?sessionId=${encodeURIComponent(bundle.session_id)}`; +} + +export function NeedsYouInboxRow({ bundle }: { bundle: ClarificationInboxBundle }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const rowRef = useRef(null); + const bumpRefreshTick = useAppStore((s) => s.bumpNeedsYouInboxRefreshTick); + + const primaryText = rowPrimaryText(bundle, t("needsYouInbox:questionFromAgent")); + const secondaryText = rowSecondaryText(bundle); + const questionCount = rowQuestionCount(bundle); + const taskHref = taskHrefForBundle(bundle); + const relativeTime = formatRelativeTime(bundle.created_at); + const statusLabel = t( + resolveThreadSessionStatus({ + state: bundle.session_state as TaskSessionState, + pending_action: "clarification", + }).labelKey, + ); + + const { busy: dismissing, run: runDismiss } = useSidecarAction( + bundle.pending_id, + bumpRefreshTick, + () => toast.error(t("needsYouInbox:dismissFailed")), + ); + const { busy: snoozing, run: runSnooze } = useSidecarAction( + bundle.pending_id, + bumpRefreshTick, + () => toast.error(t("needsYouInbox:snoozeFailed")), + ); + const handleOutcome = useRowOutcomeNotice(primaryText, bumpRefreshTick); + + return ( +
+
+ + {/* The row body toggles the answer panel, so without this the task is + unreachable from the Inbox (design-03#D1). Withheld below `sm`, + where it would squeeze the title; the actions menu carries it at + every width. */} + + + void runDismiss(() => dismissClarificationInboxBundle(bundle.pending_id)) + } + onSnooze={(duration) => + void runSnooze(() => snoozeClarificationInboxBundle(bundle.pending_id, duration)) + } + /> +
+ {expanded && ( + {}} + onOutcome={handleOutcome} + shortcutScopeRef={rowRef} + maxHeightVh={50} + /> + )} +
+ ); +} diff --git a/apps/web/components/task/chat/clarification-input-overlay.test.tsx b/apps/web/components/task/chat/clarification-input-overlay.test.tsx index 293eddf82a..2f51b9b71a 100644 --- a/apps/web/components/task/chat/clarification-input-overlay.test.tsx +++ b/apps/web/components/task/chat/clarification-input-overlay.test.tsx @@ -13,6 +13,7 @@ import { type ClarificationEscapeGuardRegistry, } from "@/hooks/use-clarification-escape-guard"; import { ClarificationInputOverlay } from "./clarification-input-overlay"; +import type { ClarificationOutcome } from "@/hooks/domains/session/use-clarification-group"; vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "https://api.test" }), @@ -70,7 +71,11 @@ function clarMessage(opts: { function renderOverlay( messages: Message[], - overrides: Partial<{ onResolved: () => void; onDismiss: () => void }> = {}, + overrides: Partial<{ + onResolved: () => void; + onDismiss: () => void; + onOutcome: (outcome: ClarificationOutcome) => void; + }> = {}, ) { const scopeRef = createRef(); const onResolved = overrides.onResolved ?? vi.fn(); @@ -81,6 +86,7 @@ function renderOverlay( messages={messages} onResolved={onResolved} onDismiss={onDismiss} + onOutcome={overrides.onOutcome} shortcutScopeRef={scopeRef} />
, @@ -475,6 +481,47 @@ describe("ClarificationInputOverlay — submit failure feedback", () => { }); }); +describe("ClarificationInputOverlay — outcome lifetime", () => { + it("delivers a delayed losing outcome after the overlay unmounts", async () => { + let resolveResponse: ((response: Response) => void) | undefined; + fetchMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveResponse = resolve; + }), + ); + const onOutcome = vi.fn(); + const { unmount } = renderOverlay( + [clarMessage({ id: "m1", questionId: "q1", index: 0, total: 1 })], + { onOutcome }, + ); + + fireEvent.click(screen.getByTestId(TESTID_OPTION)); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + unmount(); + + resolveResponse?.( + new Response( + JSON.stringify({ + success: true, + claimed: false, + status: "answered", + response: { answers: [{ question_id: "q1", selected_options: ["o1"] }] }, + }), + { status: 200 }, + ), + ); + + await vi.waitFor(() => + expect(onOutcome).toHaveBeenCalledWith({ + kind: "resolved", + claimedByThisCaller: false, + status: "answered", + }), + ); + }); +}); + describe("ClarificationInputOverlay — bundle-local state", () => { it("resets custom drafts and the active question when the bundle changes", async () => { const bundleA = [ @@ -562,3 +609,101 @@ describe("ClarificationInputOverlay — lightweight Markdown", () => { }); }); }); + +// AC .39: onOutcome is an additive, optional prop. Omitting it (as the task +// session and Quick Chat hosts both do) must leave every existing behavior +// -- including onResolved -- byte-identical to before this prop existed. +describe("ClarificationInputOverlay — onOutcome (AC .39)", () => { + it("does not change onResolved or submit behavior when onOutcome is omitted", async () => { + const { onResolved } = renderOverlay([ + clarMessage({ id: "m1", questionId: "q1", index: 0, total: 1 }), + ]); + + fireEvent.click(screen.getByTestId(TESTID_OPTION)); + + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledTimes(1)); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reports kind: resolved, claimedByThisCaller: true when this caller's own submit wins", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, claimed: true, status: "answered" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const onOutcome = vi.fn(); + renderOverlay([clarMessage({ id: "m1", questionId: "q1", index: 0, total: 1 })], { + onOutcome, + }); + + fireEvent.click(screen.getByTestId(TESTID_OPTION)); + + await vi.waitFor(() => + expect(onOutcome).toHaveBeenCalledWith({ + kind: "resolved", + claimedByThisCaller: true, + status: "answered", + }), + ); + }); + + it("reports kind: resolved, claimedByThisCaller: false when another caller already answered", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + success: true, + claimed: false, + status: "answered", + response: { + answers: [{ question_id: "q1", selected_options: ["other-caller-option"] }], + }, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + const onOutcome = vi.fn(); + renderOverlay([clarMessage({ id: "m1", questionId: "q1", index: 0, total: 1 })], { + onOutcome, + }); + + fireEvent.click(screen.getByTestId(TESTID_OPTION)); + + await vi.waitFor(() => + expect(onOutcome).toHaveBeenCalledWith({ + kind: "resolved", + claimedByThisCaller: false, + status: "answered", + }), + ); + }); + + it("reports kind: no_longer_active on a 409 not_active conflict", async () => { + fetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ code: "not_active", error: "clarification request is no longer active" }), + { status: 409, headers: { "Content-Type": "application/json" } }, + ), + ); + const onOutcome = vi.fn(); + renderOverlay([clarMessage({ id: "m1", questionId: "q1", index: 0, total: 1 })], { + onOutcome, + }); + + fireEvent.click(screen.getByTestId(TESTID_OPTION)); + + await vi.waitFor(() => expect(onOutcome).toHaveBeenCalledWith({ kind: "no_longer_active" })); + }); + + it("reports kind: submission_failed on a network/server failure", async () => { + fetchMock.mockResolvedValueOnce(new Response("nope", { status: 500 })); + const onOutcome = vi.fn(); + renderOverlay([clarMessage({ id: "m1", questionId: "q1", index: 0, total: 1 })], { + onOutcome, + }); + + fireEvent.click(screen.getByTestId(TESTID_OPTION)); + + await vi.waitFor(() => expect(onOutcome).toHaveBeenCalledWith({ kind: "submission_failed" })); + }); +}); diff --git a/apps/web/components/task/chat/clarification-input-overlay.tsx b/apps/web/components/task/chat/clarification-input-overlay.tsx index d6c7b25c1d..cbf186229b 100644 --- a/apps/web/components/task/chat/clarification-input-overlay.tsx +++ b/apps/web/components/task/chat/clarification-input-overlay.tsx @@ -1,27 +1,28 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; -import { IconMessageQuestion, IconInfoCircle } from "@tabler/icons-react"; +import { IconInfoCircle } from "@tabler/icons-react"; import type { Message, ClarificationRequestMetadata, ClarificationAnswer, ClarificationQuestion, } from "@/lib/types/http"; -import { useClarificationGroup } from "@/hooks/domains/session/use-clarification-group"; +import { + useClarificationGroup, + type ClarificationOutcome, +} from "@/hooks/domains/session/use-clarification-group"; import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard"; import { CLARIFICATION_CUSTOM_TEXT_MAX_RUNES, ClarificationCarouselNav, ClarificationCustomInput, ClarificationOptions, - ClarificationStepper, countRunes, } from "./clarification-overlay-parts"; -import { ClarificationHeaderActions } from "./clarification-overlay-header"; +import { ClarificationOverlayTopBar } from "./clarification-overlay-header"; import { ClarificationStatusBanner } from "./clarification-status-banner"; import { ClarificationMarkdown } from "./clarification-markdown"; -import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; type ClarificationInputOverlayProps = { @@ -36,6 +37,14 @@ type ClarificationInputOverlayProps = { // Called by the expanded header's collapse control. onCollapse?: () => void; collapseContentId?: string; + // Additive: reports every settled submission outcome, distinct from + // onResolved's narrower "this caller's own answer landed" signal. The + // task session and Quick Chat hosts leave this unset and keep their + // existing behavior identical; the Needs-you Inbox is the first host that + // needs to tell "this caller won" apart from "another caller won" / + // "no longer active" / "submission failed" to decide whether to remove its + // row (design-02#Failure-and-recovery). + onOutcome?: (outcome: ClarificationOutcome) => void; }; type SingleQuestionMeta = { @@ -45,15 +54,6 @@ type SingleQuestionMeta = { questionId: string; }; -function clarificationHeaderClassName(total: number): string { - return cn( - "flex min-h-11 justify-between", - total > 1 - ? "flex-col items-stretch gap-2 px-3 py-2 md:flex-row md:items-center md:gap-3 md:px-4 md:py-0" - : "items-center gap-3 px-4", - ); -} - function readSingleQuestionMeta(message: Message | null | undefined): SingleQuestionMeta | null { if (!message) return null; const metadata = message.metadata as ClarificationRequestMetadata | undefined; @@ -583,6 +583,7 @@ function ClarificationCarouselBody({ export function ClarificationInputOverlay({ messages, onResolved, + onOutcome, shortcutScopeRef, keyboardShortcutsEnabled = true, onDismiss, @@ -593,7 +594,7 @@ export function ClarificationInputOverlay({ () => sortMessagesByQuestionIndex(resolveQuestionMessages(messages)), [messages], ); - const group = useClarificationGroup(sortedMessages); + const group = useClarificationGroup(sortedMessages, onOutcome); const isSubmitting = group.submitState === "submitting"; const [customDrafts, setCustomDrafts] = useState>({}); const [rawActiveIndex, setActiveIndex] = useState(0); @@ -631,40 +632,20 @@ export function ClarificationInputOverlay({ return (
-
-
- - {total > 1 && ( - isQuestionAnsweredAt(sortedMessages, group.answers, index)} - onJump={setActiveIndex} - isSubmitting={isSubmitting} - /> - )} - {total > 1 && ( - - {group.answeredCount} of {group.total} answered - - )} -
- void group.skipAll("User skipped")} - onCollapse={onCollapse} - collapseContentId={collapseContentId} - /> -
+ isQuestionAnsweredAt(sortedMessages, group.answers, index)} + onJump={setActiveIndex} + isSubmitting={isSubmitting} + answeredCount={group.answeredCount} + answerableTotal={group.total} + allAnswered={allAnswered} + onSubmit={handleSubmit} + onSkip={() => void group.skipAll("User skipped")} + onCollapse={onCollapse} + collapseContentId={collapseContentId} + /> {(group.submitState === "error" || group.submitState === "expired") && ( void group.retry()} /> )} diff --git a/apps/web/components/task/chat/clarification-overlay-header.tsx b/apps/web/components/task/chat/clarification-overlay-header.tsx index 060b38f224..548a99b1ec 100644 --- a/apps/web/components/task/chat/clarification-overlay-header.tsx +++ b/apps/web/components/task/chat/clarification-overlay-header.tsx @@ -1,6 +1,6 @@ "use client"; -import { IconChevronDown, IconCheck, IconX } from "@tabler/icons-react"; +import { IconChevronDown, IconCheck, IconX, IconMessageQuestion } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { Spinner } from "@kandev/ui/spinner"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -8,6 +8,84 @@ import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip" import { SHORTCUTS } from "@/lib/keyboard/constants"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; +import { ClarificationStepper } from "./clarification-overlay-parts"; + +function clarificationHeaderClassName(total: number): string { + return cn( + "flex min-h-11 justify-between", + total > 1 + ? "flex-col items-stretch gap-2 px-3 py-2 md:flex-row md:items-center md:gap-3 md:px-4 md:py-0" + : "items-center gap-3 px-4", + ); +} + +type ClarificationOverlayTopBarProps = { + total: number; + activeIndex: number; + isAnswered: (index: number) => boolean; + onJump: (index: number) => void; + isSubmitting: boolean; + answeredCount: number; + answerableTotal: number; + allAnswered: boolean; + onSubmit: () => void; + onSkip: () => void; + onCollapse?: () => void; + collapseContentId?: string; +}; + +// Groups the stepper, progress text, and header actions row shared by +// ClarificationInputOverlay -- kept here (rather than inline) so that file +// stays under the repo's max-lines-per-function/file limits. +export function ClarificationOverlayTopBar({ + total, + activeIndex, + isAnswered, + onJump, + isSubmitting, + answeredCount, + answerableTotal, + allAnswered, + onSubmit, + onSkip, + onCollapse, + collapseContentId, +}: ClarificationOverlayTopBarProps) { + const { t } = useTranslation(); + return ( +
+
+ + {total > 1 && ( + + )} + {total > 1 && ( + + {t("task:answeredOfTotal", { answeredCount, total: answerableTotal })} + + )} +
+ +
+ ); +} type ClarificationHeaderActionsProps = { total: number; diff --git a/apps/web/components/task/chat/clarification-panel-section.test.tsx b/apps/web/components/task/chat/clarification-panel-section.test.tsx index 40bd66f481..1c8a677440 100644 --- a/apps/web/components/task/chat/clarification-panel-section.test.tsx +++ b/apps/web/components/task/chat/clarification-panel-section.test.tsx @@ -90,6 +90,7 @@ afterEach(async () => { }); const SCROLL_REGION_TESTID = "clarification-scroll-region"; +const OPTION_TESTID = "clarification-option"; const CONTAINER_TESTID = "clarification-overlay-container"; const QUESTION_COUNT_TESTID = "clarification-question-count"; const SUBMITTING_STATUS_TESTID = "clarification-submitting-status"; @@ -202,7 +203,7 @@ describe("ClarificationPanelSection — submitting feedback", () => { expect(screen.queryByTestId(SUBMITTING_STATUS_TESTID)).toBeNull(); try { - fireEvent.click(screen.getByTestId("clarification-option")); + fireEvent.click(screen.getByTestId(OPTION_TESTID)); const status = await screen.findByTestId(SUBMITTING_STATUS_TESTID); const skip = screen.getByTestId("clarification-skip"); @@ -240,7 +241,7 @@ describe("ClarificationPanelSection — submitting feedback", () => { renderSection(true, messages); try { - fireEvent.click(screen.getByTestId("clarification-option")); + fireEvent.click(screen.getByTestId(OPTION_TESTID)); await screen.findByTestId(SUBMITTING_STATUS_TESTID); rejectResponse(new Error("network")); @@ -309,7 +310,7 @@ describe("ClarificationPanelSection — question-count localization", () => { ]; const { scopeRef } = renderSection(true, messages); - fireEvent.click(screen.getAllByTestId("clarification-option")[0]); + fireEvent.click(screen.getAllByTestId(OPTION_TESTID)[0]); fireEvent.keyDown(scopeRef.current!, { key: "Escape" }); expect(screen.getByTestId(QUESTION_COUNT_TESTID).textContent).toBe("2 questions"); @@ -363,3 +364,55 @@ describe("ClarificationPanelSection — dragged height does not survive a bundle expect(container.style.height).toBe(""); }); }); + +// AC .39: onOutcome is additive and forwarded straight through to +// ClarificationInputOverlay. Existing callers (task chat, Quick Chat) omit +// it and must see no behavior change; a new caller (the Needs-you Inbox) +// must receive every settled outcome. +describe("ClarificationPanelSection — onOutcome forwarding (AC .39)", () => { + it("forwards a resolved outcome to onOutcome when provided", async () => { + fetchMock.mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, claimed: true, status: "answered" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + const onOutcome = vi.fn(); + const scopeRef = createRef(); + const messages = [ + clarMessage({ pendingId: "p1", id: "m1", questionId: "q1", index: 0, total: 1 }), + ]; + render( +
+ +
, + ); + + fireEvent.click(screen.getByTestId(OPTION_TESTID)); + + await vi.waitFor(() => + expect(onOutcome).toHaveBeenCalledWith({ + kind: "resolved", + claimedByThisCaller: true, + status: "answered", + }), + ); + }); + + it("behaves identically to before when onOutcome is omitted", async () => { + const { onResolved } = renderSection(true, [ + clarMessage({ pendingId: "p1", id: "m1", questionId: "q1", index: 0, total: 1 }), + ]); + + fireEvent.click(screen.getByTestId(OPTION_TESTID)); + + await vi.waitFor(() => expect(onResolved).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/apps/web/components/task/chat/clarification-panel-section.tsx b/apps/web/components/task/chat/clarification-panel-section.tsx index 45417f68b4..f8d1a38f96 100644 --- a/apps/web/components/task/chat/clarification-panel-section.tsx +++ b/apps/web/components/task/chat/clarification-panel-section.tsx @@ -8,6 +8,7 @@ import { ClarificationInputOverlay } from "./clarification-input-overlay"; import { ResizeHandle } from "./resize-handle"; import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-overlay"; import type { ClarificationRequestMetadata, Message } from "@/lib/types/http"; +import type { ClarificationOutcome } from "@/hooks/domains/session/use-clarification-group"; type ClarificationPanelSectionProps = { pending: boolean; @@ -21,6 +22,9 @@ type ClarificationPanelSectionProps = { * responding before its own visible ceiling. */ maxHeightVh: number; + // Additive: forwarded straight through to ClarificationInputOverlay. + // Existing hosts (task chat, Quick Chat) leave this unset. + onOutcome?: (outcome: ClarificationOutcome) => void; }; function pendingIdFromMessages(messages: readonly Message[] | null | undefined): string | null { @@ -57,6 +61,7 @@ export function ClarificationPanelSection({ onResolved, shortcutScopeRef, maxHeightVh, + onOutcome, }: ClarificationPanelSectionProps) { const { t } = useTranslation(); const pendingId = pendingIdFromMessages(messages); @@ -141,6 +146,7 @@ export function ClarificationPanelSection({ setCollapsed(true)} diff --git a/apps/web/e2e/tests/chat/mobile-needs-you-inbox-answer.spec.ts b/apps/web/e2e/tests/chat/mobile-needs-you-inbox-answer.spec.ts new file mode 100644 index 0000000000..df6d97550e --- /dev/null +++ b/apps/web/e2e/tests/chat/mobile-needs-you-inbox-answer.spec.ts @@ -0,0 +1,66 @@ +// AC-UI-NEEDS-YOU-INBOX-001.30: the answer-in-place golden path must also work +// on a phone-sized viewport, where rows and controls fall back to the 44px +// touch target (control-sizing.tsx's coarse-pointer breakpoint) instead of the +// desktop-density button. This mirrors needs-you-inbox-answer.spec.ts's +// desktop coverage rather than duplicating its assertions. +import { test, expect } from "../../fixtures/test-base"; +import { waitForAgentMessage, waitForSessionState } from "../../helpers/session"; + +const MIN_TOUCH_TARGET_PX = 44; + +test.describe("Needs-you Inbox answer flow on mobile", () => { + test("answering a row removes it and resumes the task without navigation (AC .30)", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + const title = "Mobile Needs-you Inbox Answer Flow"; + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:clarification", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("expected an active session for the Inbox answer flow"); + + await waitForSessionState(apiClient, { + taskId: task.id, + sessionId: task.session_id, + expectedState: "WAITING_FOR_INPUT", + message: "clarification session should reach its blocking state before the Inbox is opened", + timeout: 60_000, + }); + + await testPage.goto("/needs-you-inbox"); + + const row = testPage.getByTestId("needs-you-inbox-row").filter({ hasText: title }); + await expect(row).toBeVisible({ timeout: 30_000 }); + + const toggle = row.getByTestId("needs-you-inbox-row-toggle"); + const toggleBox = await toggle.boundingBox(); + expect(toggleBox, "row toggle should have a box").not.toBeNull(); + expect(toggleBox!.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX); + + await toggle.click(); + const overlay = row.getByTestId("clarification-overlay"); + await expect(overlay).toBeVisible({ timeout: 15_000 }); + + await overlay.getByTestId("clarification-option").filter({ hasText: "PostgreSQL" }).click(); + + await expect(row).toHaveCount(0, { timeout: 15_000 }); + await waitForAgentMessage(apiClient, task.session_id, "You answered:", 30_000); + + // The document itself must not scroll horizontally on a phone viewport. + const overflow = await testPage.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth); + }); +}); diff --git a/apps/web/e2e/tests/chat/needs-you-inbox-answer.spec.ts b/apps/web/e2e/tests/chat/needs-you-inbox-answer.spec.ts new file mode 100644 index 0000000000..e3c41490bf --- /dev/null +++ b/apps/web/e2e/tests/chat/needs-you-inbox-answer.spec.ts @@ -0,0 +1,73 @@ +// AC-UI-NEEDS-YOU-INBOX-001.30: listing an answerable bundle, expanding it, +// and answering it in place must remove the row (the convergence re-read) +// and resume the blocked task, without ever navigating to the task page. +import path from "node:path"; +import { test, expect } from "../../fixtures/test-base"; +import { waitForAgentMessage, waitForSessionState } from "../../helpers/session"; +import { PrAssetCapture } from "../../helpers/pr-asset-capture"; + +test.describe("Needs-you Inbox answer flow", () => { + test("answering a row removes it and resumes the task without navigation (AC .30)", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + const prCapture = new PrAssetCapture( + testPage, + path.join(__dirname, "needs-you-inbox-answer.spec.ts"), + ); + const title = "Needs-you Inbox Answer Flow"; + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:clarification", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + if (!task.session_id) throw new Error("expected an active session for the Inbox answer flow"); + + await waitForSessionState(apiClient, { + taskId: task.id, + sessionId: task.session_id, + expectedState: "WAITING_FOR_INPUT", + message: "clarification session should reach its blocking state before the Inbox is opened", + timeout: 60_000, + }); + + // Never visits /t/:id: the row is listed, expanded, and answered entirely + // from the Inbox route. + await testPage.goto("/needs-you-inbox"); + + const row = testPage.getByTestId("needs-you-inbox-row").filter({ hasText: title }); + await expect(row).toBeVisible({ timeout: 30_000 }); + await prCapture.screenshot("inbox-list", { caption: "Needs-you Inbox list" }); + + await row.getByTestId("needs-you-inbox-row-toggle").click(); + const overlay = row.getByTestId("clarification-overlay"); + await expect(overlay).toBeVisible({ timeout: 15_000 }); + await prCapture.screenshot("inbox-row-expanded", { + caption: "Row expanded to answer in place", + }); + + await overlay.getByTestId("clarification-option").filter({ hasText: "PostgreSQL" }).click(); + + // The outcome callback fires a convergence re-read; the row disappears + // because the answered bundle is no longer answerable, not because the + // client spliced it out locally. + await expect(row).toHaveCount(0, { timeout: 15_000 }); + + // `WAITING_FOR_INPUT` is the session's normal resting state once a turn + // ends, so it is reached both while blocked on the clarification AND + // again once the agent's post-answer turn finishes -- it cannot + // distinguish "still blocked" from "resumed and settled". The mock + // agent's own post-answer message is the unambiguous proof the blocked + // tool call was actually released and the turn ran to completion. + await waitForAgentMessage(apiClient, task.session_id, "You answered:", 30_000); + prCapture.flush(); + }); +}); diff --git a/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts b/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts index beb50b24df..1743a8a04d 100644 --- a/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts +++ b/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts @@ -33,7 +33,12 @@ test.describe("Sidebar office gating", () => { await expect(sidebar).toBeVisible(); await expect(sidebar.getByText("Projects", { exact: true })).toHaveCount(0); await expect(sidebar.getByText("Agents", { exact: true })).toHaveCount(0); - await expect(sidebar.getByRole("link", { name: "Inbox", exact: true })).toHaveCount(0); + // Needs-you Inbox is workspace-scoped, so it remains available outside + // Office. Only the Office-specific sections disappear after the switch. + await expect(sidebar.getByRole("link", { name: "Inbox", exact: true })).toHaveAttribute( + "href", + "/needs-you-inbox", + ); }); test("a Settings round trip keeps the active office workspace", async ({ diff --git a/apps/web/hooks/domains/needs-you-inbox/use-needs-you-inbox-controller.test.tsx b/apps/web/hooks/domains/needs-you-inbox/use-needs-you-inbox-controller.test.tsx new file mode 100644 index 0000000000..c3b4293ef0 --- /dev/null +++ b/apps/web/hooks/domains/needs-you-inbox/use-needs-you-inbox-controller.test.tsx @@ -0,0 +1,241 @@ +import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; +import { defaultFeatureFlags } from "@/lib/state/slices/features/types"; +import type { HydrationState } from "@/lib/state/store"; + +const listClarificationInboxMock = vi.fn(); +vi.mock("@/lib/api/domains/clarification-inbox-api", () => ({ + listClarificationInbox: (...args: unknown[]) => listClarificationInboxMock(...args), +})); + +vi.mock("@/lib/ws/connection", () => ({ + getWebSocketClient: () => null, +})); + +const readBootPayloadMock = vi.fn(); +vi.mock("@/src/boot-payload", () => ({ + readBootPayload: () => readBootPayloadMock(), +})); + +import { useNeedsYouInboxController } from "./use-needs-you-inbox-controller"; + +const WORKSPACE_ID = "w1"; + +function page(overrides: Partial>> = {}) { + return { + bundles: [], + count: 0, + hidden_count: 0, + next_snooze_expiry: null, + ...overrides, + }; +} + +function initialState(enabled: boolean): HydrationState { + return { + features: { ...defaultFeatureFlags, needsYouInbox: enabled }, + workspaces: { activeId: WORKSPACE_ID, byId: {}, allIds: [] }, + } as unknown as HydrationState; +} + +function renderController(enabled = true) { + return renderHook( + () => { + useNeedsYouInboxController(); + return useAppStoreApi(); + }, + { + wrapper: ({ children }) => ( + {children} + ), + }, + ); +} + +beforeEach(() => { + listClarificationInboxMock.mockReset(); + listClarificationInboxMock.mockResolvedValue(page()); + readBootPayloadMock.mockReset(); + readBootPayloadMock.mockReturnValue({ initialState: {} }); +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); + +describe("useNeedsYouInboxController", () => { + it("reads on mount for the active workspace when the flag is enabled", async () => { + renderController(true); + + await waitFor(() => expect(listClarificationInboxMock).toHaveBeenCalledWith(WORKSPACE_ID)); + }); + + it("never reads when the flag is disabled", async () => { + renderController(false); + + await act(async () => { + await Promise.resolve(); + }); + expect(listClarificationInboxMock).not.toHaveBeenCalled(); + }); + + it("applies a successful read into the count slice", async () => { + listClarificationInboxMock.mockResolvedValue( + page({ count: 2, bundles: [], hidden_count: 1, next_snooze_expiry: null }), + ); + const { result } = renderController(true); + + await waitFor(() => { + const state = result.current.getState().needsYouInbox.byWorkspaceId[WORKSPACE_ID]; + expect(state?.status).toBe("ready"); + expect(state?.count).toBe(2); + expect(state?.hiddenCount).toBe(1); + }); + }); + + it("re-reads when the WS refresh trigger tick is bumped", async () => { + const { result } = renderController(true); + await waitFor(() => expect(listClarificationInboxMock).toHaveBeenCalledTimes(1)); + + act(() => { + result.current.getState().bumpNeedsYouInboxRefreshTick(); + }); + + await waitFor(() => expect(listClarificationInboxMock).toHaveBeenCalledTimes(2)); + }); + + it("re-reads once at the periodic interval while the tab stays visible", async () => { + vi.useFakeTimers(); + renderController(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(listClarificationInboxMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + + expect(listClarificationInboxMock).toHaveBeenCalledTimes(2); + }); + + it("arms a snooze-expiry timer from the read response and re-reads once it fires", async () => { + vi.useFakeTimers(); + const soon = new Date(Date.now() + 10_000).toISOString(); + listClarificationInboxMock.mockResolvedValueOnce(page({ next_snooze_expiry: soon })); + listClarificationInboxMock.mockResolvedValue(page()); + + renderController(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(listClarificationInboxMock).toHaveBeenCalledTimes(1); + + // The reported expiry is whole-second RFC3339; the reschedule buffer + // (below) pushes the actual timer 1s past it so the re-read always + // crosses the real, possibly-subsecond boundary. + await act(async () => { + await vi.advanceTimersByTimeAsync(11_000); + }); + + expect(listClarificationInboxMock).toHaveBeenCalledTimes(2); + }); + + it("delays the re-read a second past the reported second-precision expiry", async () => { + vi.useFakeTimers(); + const soon = new Date(Date.now() + 10_000).toISOString(); + listClarificationInboxMock.mockResolvedValueOnce(page({ next_snooze_expiry: soon })); + listClarificationInboxMock.mockResolvedValue(page()); + + renderController(true); + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(listClarificationInboxMock).toHaveBeenCalledTimes(1); + + // Firing exactly at the reported (truncated) instant would still see the + // bundle as snoozed on a server storing subsecond precision; the buffer + // means nothing has re-read yet at this point. + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + expect(listClarificationInboxMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(listClarificationInboxMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("useNeedsYouInboxController boot-hydration seed (AC .34, .40, .41)", () => { + it("seeds the count from the boot payload before the live read resolves", async () => { + readBootPayloadMock.mockReturnValue({ + initialState: { + needsYouInboxBoot: { + workspaceId: WORKSPACE_ID, + count: 5, + hasMore: true, + nextSnoozeExpiry: "2026-01-01T00:00:00Z", + }, + }, + }); + + const { result } = renderController(true); + + // Synchronous, before the mocked fetch's promise has settled: only the + // layout-effect seed and beginNeedsYouInboxRead's own effect have run. + const seeded = result.current.getState().needsYouInbox.byWorkspaceId[WORKSPACE_ID]; + expect(seeded?.count).toBe(5); + expect(seeded?.hasMore).toBe(true); + expect(seeded?.nextSnoozeExpiry).toBe("2026-01-01T00:00:00Z"); + + await waitFor(() => { + const settled = result.current.getState().needsYouInbox.byWorkspaceId[WORKSPACE_ID]; + expect(settled?.status).toBe("ready"); + expect(settled?.count).toBe(0); + }); + }); + + it("does not seed when the boot payload names a different workspace", async () => { + readBootPayloadMock.mockReturnValue({ + initialState: { + needsYouInboxBoot: { + workspaceId: "some-other-workspace", + count: 5, + hasMore: true, + nextSnoozeExpiry: null, + }, + }, + }); + + const { result } = renderController(true); + + const seeded = result.current.getState().needsYouInbox.byWorkspaceId[WORKSPACE_ID]; + expect(seeded?.count ?? 0).toBe(0); + + await waitFor(() => expect(listClarificationInboxMock).toHaveBeenCalledWith(WORKSPACE_ID)); + }); + + it("does not seed when the flag is disabled", async () => { + readBootPayloadMock.mockReturnValue({ + initialState: { + needsYouInboxBoot: { + workspaceId: WORKSPACE_ID, + count: 5, + hasMore: true, + nextSnoozeExpiry: null, + }, + }, + }); + + const { result } = renderController(false); + + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.getState().needsYouInbox.byWorkspaceId[WORKSPACE_ID]).toBeUndefined(); + }); +}); diff --git a/apps/web/hooks/domains/needs-you-inbox/use-needs-you-inbox-controller.ts b/apps/web/hooks/domains/needs-you-inbox/use-needs-you-inbox-controller.ts new file mode 100644 index 0000000000..921eeee251 --- /dev/null +++ b/apps/web/hooks/domains/needs-you-inbox/use-needs-you-inbox-controller.ts @@ -0,0 +1,162 @@ +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { useForegroundRefresh } from "@/hooks/use-foreground-refresh"; +import { getWebSocketClient } from "@/lib/ws/connection"; +import { listClarificationInbox } from "@/lib/api/domains/clarification-inbox-api"; +import { selectNeedsYouInboxNextSnoozeExpiry } from "@/lib/state/slices/needs-you-inbox/selectors"; +import { readBootPayload } from "@/src/boot-payload"; + +// "At most once every 60 seconds" (design-02#Control-flow): the residual +// catch-all for exits none of the other four triggers observes. +const PERIODIC_REFRESH_MS = 60_000; +// A skewed client clock asking early must not spin; floor the reschedule. +const MIN_SNOOZE_RESCHEDULE_MS = 5_000; +// `next_snooze_expiry` is serialized at whole-second RFC3339 precision while +// the stored expiry can carry subsecond precision, so firing exactly at the +// reported instant can land up to a second before the row is actually +// eligible: the re-read then reports the same (still-future) expiry string, +// and an unchanged effect dependency never rearms the timeout. Firing a +// second late instead guarantees the read crosses the real boundary. +const SNOOZE_RESCHEDULE_BUFFER_MS = 1_000; + +// Applies the boot-hydration producer (needs-you-inbox +// design-01#Data-and-contracts) via seedNeedsYouInboxBoot, so the badge +// carries a value before the live read below ever resolves. A +// useLayoutEffect, not useEffect, so it always runs before this hook's own +// refresh-trigger effects in the same commit -- seedNeedsYouInboxBoot's own +// generation-0 guard makes the ordering a belt-and-suspenders correctness +// property rather than the only thing preventing a stale overwrite, but boot +// data seeding after the first live read started would otherwise waste the +// one chance it has to avoid the pre-read flash. +function useNeedsYouInboxBootSeed( + enabled: boolean, + workspaceId: string | null, + storeApi: ReturnType, +) { + const seededRef = useRef(false); + useLayoutEffect(() => { + if (!enabled || !workspaceId || seededRef.current) return; + seededRef.current = true; + const boot = readBootPayload().initialState?.needsYouInboxBoot; + if (!boot || boot.workspaceId !== workspaceId) return; + storeApi.getState().seedNeedsYouInboxBoot(workspaceId, { + count: boot.count, + hasMore: boot.hasMore, + nextSnoozeExpiry: boot.nextSnoozeExpiry, + }); + }, [enabled, workspaceId, storeApi]); +} + +/** + * Owns every Needs-you Inbox refresh trigger (design-02#Control-flow) and the + * snooze-expiry timer. Mounted once, unconditionally of route, so the badge + * stays right whether or not the Inbox is open. The Inbox route component + * subscribes to the same slice and adds no triggers of its + * own -- it may call `refresh` again on mount, which is a harmless extra + * bounded read. + */ +export function useNeedsYouInboxController() { + const enabled = useFeature("needsYouInbox"); + const workspaceId = useAppStore((s) => s.workspaces.activeId); + const connectionStatus = useAppStore((s) => s.connection.status); + const nextSnoozeExpiry = useAppStore(selectNeedsYouInboxNextSnoozeExpiry); + const refreshTick = useAppStore((s) => s.needsYouInbox.refreshTick); + const storeApi = useAppStoreApi(); + + useNeedsYouInboxBootSeed(enabled, workspaceId, storeApi); + + const refresh = useCallback( + async (targetWorkspaceId: string) => { + const { beginNeedsYouInboxRead, setNeedsYouInboxPage, setNeedsYouInboxError } = + storeApi.getState(); + const generation = beginNeedsYouInboxRead(targetWorkspaceId); + try { + const page = await listClarificationInbox(targetWorkspaceId); + setNeedsYouInboxPage(targetWorkspaceId, generation, { + bundles: page.bundles, + count: page.count, + hiddenCount: page.hidden_count, + nextSnoozeExpiry: page.next_snooze_expiry, + hasMore: page.next_cursor !== undefined, + }); + } catch { + setNeedsYouInboxError(targetWorkspaceId, generation); + } + }, + [storeApi], + ); + + // Trigger: route mount / workspace change is handled by whichever component + // reads the active workspace first landing here too, since this hook itself + // re-reads the moment `workspaceId` changes. + useEffect(() => { + if (!enabled || !workspaceId) return; + void refresh(workspaceId); + }, [enabled, workspaceId, refresh]); + + // Trigger: WS action-based (session.pending_action_changed, + // session.state_changed) and edge-detected WS (re)connect. Both live in one + // effect keyed on connectionStatus so a client created after this hook + // mounts is picked up the moment status first changes. + const wasConnectedRef = useRef(false); + useEffect(() => { + if (!enabled) return; + const isConnected = connectionStatus === "connected"; + if (isConnected && !wasConnectedRef.current && workspaceId) { + void refresh(workspaceId); + } + wasConnectedRef.current = isConnected; + + const client = getWebSocketClient(); + if (!client) return; + const bump = () => storeApi.getState().bumpNeedsYouInboxRefreshTick(); + const offPending = client.on("session.pending_action_changed", bump); + const offStateChanged = client.on("session.state_changed", bump); + return () => { + offPending(); + offStateChanged(); + }; + }, [enabled, connectionStatus, workspaceId, refresh, storeApi]); + + // Applies the WS-triggered refresh tick bumped above. + const lastTickRef = useRef(refreshTick); + useEffect(() => { + if (lastTickRef.current === refreshTick) return; + lastTickRef.current = refreshTick; + if (enabled && workspaceId) void refresh(workspaceId); + }, [refreshTick, enabled, workspaceId, refresh]); + + // Trigger: tab visibility regained (coalesced with focus/pageshow/online). + useForegroundRefresh( + () => { + if (workspaceId) void refresh(workspaceId); + }, + enabled && !!workspaceId, + workspaceId, + ); + + // Trigger: bounded periodic re-read, only while the tab is visible. + useEffect(() => { + if (!enabled || !workspaceId) return; + const interval = window.setInterval(() => { + if (document.visibilityState === "visible") void refresh(workspaceId); + }, PERIODIC_REFRESH_MS); + return () => window.clearInterval(interval); + }, [enabled, workspaceId, refresh]); + + // Snooze-expiry timer: cancelled and re-armed whenever the workspace or the + // carried expiry changes, so it never fires for a workspace the operator + // has left. + useEffect(() => { + if (!enabled || !workspaceId || !nextSnoozeExpiry) return; + const delay = Math.max( + new Date(nextSnoozeExpiry).getTime() - Date.now() + SNOOZE_RESCHEDULE_BUFFER_MS, + MIN_SNOOZE_RESCHEDULE_MS, + ); + const timeout = window.setTimeout(() => void refresh(workspaceId), delay); + return () => window.clearTimeout(timeout); + }, [enabled, workspaceId, nextSnoozeExpiry, refresh]); + + return refresh; +} diff --git a/apps/web/hooks/domains/session/use-clarification-group.ts b/apps/web/hooks/domains/session/use-clarification-group.ts index e642f9558f..291fa71eaf 100644 --- a/apps/web/hooks/domains/session/use-clarification-group.ts +++ b/apps/web/hooks/domains/session/use-clarification-group.ts @@ -20,13 +20,13 @@ const CLARIFICATION_RESPONSE_TIMEOUT_MS = 40_000; // The bundle status the backend can report on a resolved response (R10). // Upstream's claim cannot produce a cancelled winner, so a loss only ever // resolves to one of these two (W3a is retired: no "cancelled" union member). -type ResolvedStatus = "answered" | "rejected"; +export type ResolvedStatus = "answered" | "rejected"; // Parsed shape of the clarification respond/cancel envelope // (internal/clarification/handlers.go's writeResolutionResult). Both the // answer batch and the skip/reject call hit the same endpoint and get the // same envelope back. -type ClarificationRespondResult = { +export type ClarificationRespondResult = { state: SubmitState; // Present only when the server returned a parseable 200 body. Absent on a // 409 (legacy backend, no body), malformed 200 body, or a network/non-2xx @@ -108,6 +108,27 @@ function parseClarificationResponseBody(value: unknown): ClarificationRespondRes return { state: "ok", claimed, status, ...responseFields }; } +// ClarificationOutcome is the settled-submission report every outcome +// consumer needs: built directly from the same ClarificationRespondResult +// this hook already parses (design-01#Components / +// design-02#Failure-and-recovery), so no host (including the Needs-you +// Inbox) ever derives a second representation of "what happened" from raw +// wire fields. +export type ClarificationOutcome = + | { kind: "resolved"; claimedByThisCaller: boolean; status?: ResolvedStatus } + | { kind: "no_longer_active" } + | { kind: "submission_failed" }; + +function clarificationOutcome(result: ClarificationRespondResult): ClarificationOutcome { + if (result.state === "expired") return { kind: "no_longer_active" }; + if (result.state === "error") return { kind: "submission_failed" }; + return { + kind: "resolved", + claimedByThisCaller: result.claimed !== false, + status: result.status, + }; +} + export type ClarificationGroupApi = { pendingId: string | null; total: number; @@ -127,6 +148,13 @@ export type ClarificationGroupApi = { // retries use the current live answers; skip retries keep the original // reason. A no-op before either has been called. retry: () => Promise; + // The most recent settled ClarificationRespondResult this submission still + // owned (see runClarificationRequest's ownsRequest fence), or null before + // any submission has settled. Callers that need to distinguish "this + // caller won" from "another caller won" read this alongside submitState; + // see ClarificationOutcome / the outcome callback threaded through + // ClarificationInputOverlay -> ClarificationPanelSection. + lastResult: ClarificationRespondResult | null; }; function questionIdsFromMessages(messages: readonly Message[]): string[] { @@ -265,6 +293,8 @@ type RunClarificationRequestArgs = { requestGenerationRef: { current: number }; inflightRef: { current: boolean }; setSubmitState: (state: SubmitState) => void; + setLastResult: (result: ClarificationRespondResult) => void; + onOutcome?: (outcome: ClarificationOutcome) => void; updateMessage: (message: Message) => void; }; @@ -282,6 +312,8 @@ async function runClarificationRequest(args: RunClarificationRequestArgs) { requestGenerationRef, inflightRef, setSubmitState, + setLastResult, + onOutcome, updateMessage, } = args; const requestGeneration = ++requestGenerationRef.current; @@ -292,7 +324,11 @@ async function runClarificationRequest(args: RunClarificationRequestArgs) { setSubmitState("submitting"); try { const result = await post(); - if (ownsRequest()) setSubmitState(result.state); + if (ownsRequest()) { + setSubmitState(result.state); + setLastResult(result); + onOutcome?.(clarificationOutcome(result)); + } if (result.state === "ok") { // Applies against the submit-time bundle snapshot regardless of which // bundle is now on screen -- this client's own messages really were @@ -306,7 +342,11 @@ async function runClarificationRequest(args: RunClarificationRequestArgs) { } } catch (err) { console.error("Clarification request threw:", err); - if (ownsRequest()) setSubmitState("error"); + if (ownsRequest()) { + setSubmitState("error"); + setLastResult({ state: "error" }); + onOutcome?.({ kind: "submission_failed" }); + } } finally { // Only release the mutex if this exact request still owns it. A bundle // swap can return to the same pending ID before this request settles, so @@ -363,10 +403,32 @@ type UseClarificationSubmissionArgs = { inflightRef: { current: boolean }; setAnswers: (answers: Record) => void; setSubmitState: (state: SubmitState) => void; + setLastResult: (result: ClarificationRespondResult) => void; + onOutcome?: (outcome: ClarificationOutcome) => void; updateMessage: (message: Message) => void; defaultSkipReason: string; }; +// Factors out the request-plumbing fields shared by submitCollected and +// skipAll's runClarificationRequest calls, keeping useClarificationSubmission +// itself under the file's max-lines-per-function limit. +function baseClarificationRequestArgs( + requestPendingId: string, + common: UseClarificationSubmissionArgs, +) { + return { + bundle: common.submitBundleRef.current.slice(), + requestPendingId, + activePendingIdRef: common.activePendingIdRef, + requestGenerationRef: common.requestGenerationRef, + inflightRef: common.inflightRef, + setSubmitState: common.setSubmitState, + setLastResult: common.setLastResult, + onOutcome: common.onOutcome, + updateMessage: common.updateMessage, + }; +} + // Submission plumbing shared by useClarificationGroup: submitCollected/skipAll // each POST through runClarificationRequest, and retry() replays whichever of // the two was last attempted. Submit retries read the live answer map so edits @@ -382,6 +444,8 @@ function useClarificationSubmission(args: UseClarificationSubmissionArgs) { inflightRef, setAnswers, setSubmitState, + setLastResult, + onOutcome, updateMessage, defaultSkipReason, } = args; @@ -407,13 +471,7 @@ function useClarificationSubmission(args: UseClarificationSubmissionArgs) { post: () => postClarificationBatch(pendingId, ordered), ownStatus: "answered", ownAnswers: current, - bundle: submitBundleRef.current.slice(), - requestPendingId: pendingId, - activePendingIdRef, - requestGenerationRef, - inflightRef, - setSubmitState, - updateMessage, + ...baseClarificationRequestArgs(pendingId, args), }); }, [ @@ -426,6 +484,8 @@ function useClarificationSubmission(args: UseClarificationSubmissionArgs) { inflightRef, setAnswers, setSubmitState, + setLastResult, + onOutcome, updateMessage, ], ); @@ -440,13 +500,7 @@ function useClarificationSubmission(args: UseClarificationSubmissionArgs) { post: () => postClarificationSkip(pendingId, effectiveReason), ownStatus: "rejected", ownAnswers: {}, - bundle: submitBundleRef.current.slice(), - requestPendingId: pendingId, - activePendingIdRef, - requestGenerationRef, - inflightRef, - setSubmitState, - updateMessage, + ...baseClarificationRequestArgs(pendingId, args), }); }, [ @@ -456,6 +510,8 @@ function useClarificationSubmission(args: UseClarificationSubmissionArgs) { requestGenerationRef, inflightRef, setSubmitState, + setLastResult, + onOutcome, updateMessage, defaultSkipReason, ], @@ -490,6 +546,7 @@ function useClarificationSubmission(args: UseClarificationSubmissionArgs) { // presses ArrowRight on the last step. export function useClarificationGroup( messages: readonly Message[] | null | undefined, + onOutcome?: (outcome: ClarificationOutcome) => void, ): ClarificationGroupApi { const { t } = useTranslation(); const storeApi = useAppStoreApi(); @@ -499,6 +556,7 @@ export function useClarificationGroup( answersRef.current = answers; }, [answers]); const [submitState, setSubmitState] = useState("idle"); + const [lastResult, setLastResult] = useState(null); // Re-entry guard: multiple submit paths can race (Cmd+Enter inside the // custom input fires both the input's onSubmit and onRequestFinalSubmit; // a double-click on the Submit button can also race). The hook owns the @@ -557,6 +615,8 @@ export function useClarificationGroup( inflightRef, setAnswers, setSubmitState, + setLastResult, + onOutcome, updateMessage: storeApi.getState().updateMessage, defaultSkipReason: t("task:userSkippedClarification"), }); @@ -579,6 +639,7 @@ export function useClarificationGroup( answersRef.current = {}; setAnswers({}); setSubmitState("idle"); + setLastResult(null); resetLastAction(); inflightRef.current = false; } @@ -595,5 +656,6 @@ export function useClarificationGroup( submitCollected, skipAll, retry, + lastResult, }; } diff --git a/apps/web/lib/api/domains/clarification-inbox-api.ts b/apps/web/lib/api/domains/clarification-inbox-api.ts new file mode 100644 index 0000000000..d88b6b99e3 --- /dev/null +++ b/apps/web/lib/api/domains/clarification-inbox-api.ts @@ -0,0 +1,71 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { + ClarificationInboxHiddenPage, + ClarificationInboxPage, + ClarificationInboxSnoozeDuration, +} from "@/lib/types/clarification-inbox"; + +const BASE = "/api/v1/clarification-inbox"; + +// One canonical parameter set (design-02#Control-flow): every caller passes +// only workspaceId. No cursor -- v1 never forward-pages. +export function listClarificationInbox( + workspaceId: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson( + `${BASE}?workspace_id=${encodeURIComponent(workspaceId)}`, + options, + ); +} + +export function listHiddenClarificationInbox( + workspaceId: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson( + `${BASE}/hidden?workspace_id=${encodeURIComponent(workspaceId)}`, + options, + ); +} + +export function dismissClarificationInboxBundle( + pendingId: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`${BASE}/sidecar/${encodeURIComponent(pendingId)}`, { + ...options, + init: { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: "dismissed" }), + ...options?.init, + }, + }); +} + +export function snoozeClarificationInboxBundle( + pendingId: string, + duration: ClarificationInboxSnoozeDuration, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`${BASE}/sidecar/${encodeURIComponent(pendingId)}`, { + ...options, + init: { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: "snoozed", snooze_duration: duration }), + ...options?.init, + }, + }); +} + +export function restoreClarificationInboxBundle( + pendingId: string, + options?: ApiRequestOptions, +): Promise { + return fetchJson(`${BASE}/sidecar/${encodeURIComponent(pendingId)}`, { + ...options, + init: { method: "DELETE", ...options?.init }, + }); +} diff --git a/apps/web/lib/navigation/needs-you-inbox-destination.ts b/apps/web/lib/navigation/needs-you-inbox-destination.ts new file mode 100644 index 0000000000..239c58e120 --- /dev/null +++ b/apps/web/lib/navigation/needs-you-inbox-destination.ts @@ -0,0 +1,5 @@ +// Single source of truth for the Needs-you Inbox route path, shared by the +// sidebar nav entry and the SPA route resolver. Not part of core-destinations' +// manifest: like Office's own Inbox entry, this is an action-shaped primary +// nav item rather than a catalog destination. +export const NEEDS_YOU_INBOX_HREF = "/needs-you-inbox"; diff --git a/apps/web/lib/needs-you-inbox/inbox-threads-agreement.test.ts b/apps/web/lib/needs-you-inbox/inbox-threads-agreement.test.ts new file mode 100644 index 0000000000..5d92e0c813 --- /dev/null +++ b/apps/web/lib/needs-you-inbox/inbox-threads-agreement.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import type { KanbanState, WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; +import { selectThreadCandidates } from "@/lib/threads/thread-view-query"; + +/** + * AC-UI-NEEDS-YOU-INBOX-001.27: given a fixture where the only pending action + * on every task is an answerable clarification bundle, the Inbox row set and + * the Threads needs-action preset name the same tasks. The two membership + * predicates are otherwise independent by design — Threads derives + * `needs_action` from session/task pending-action fields + * (`lib/threads/active-threads.ts`), while the Inbox row set is the backend + * bundle query's answerability predicate + * (`apps/backend/internal/task/repository/sqlite/clarification_bundle_query.go`). + * This file asserts the Threads half of each named divergence: a fixture the + * Inbox structurally cannot or must not list still reads `needs_action` here, + * because Threads has no field carrying the excluding fact. The Inbox-side + * exclusion for each case is covered by the backend's own bundle-query tests. + */ + +type TaskOverrides = Partial & { id: string }; + +function task(overrides: TaskOverrides): KanbanState["tasks"][number] { + const { id, ...rest } = overrides; + return { + id, + workspaceId: "workspace-1", + workflowId: "workflow-1", + workflowStepId: "step-build", + title: `Task ${id}`, + position: 0, + state: "IN_PROGRESS", + primarySessionId: `session-${id}`, + primarySessionState: "RUNNING", + updatedAt: "2026-08-31T10:00:00Z", + createdAt: "2026-08-30T10:00:00Z", + ...rest, + }; +} + +function snapshot(tasks: KanbanState["tasks"]): Record { + return { + "workflow-1": { + workflowId: "workflow-1", + workflowName: "Delivery", + steps: [{ id: "step-build", title: "Build", color: "blue", position: 0 }], + tasks, + }, + }; +} + +function needsActionStatuses(tasks: KanbanState["tasks"]) { + return new Map( + selectThreadCandidates(snapshot(tasks)).map((candidate) => [ + candidate.taskId, + candidate.threadStatus, + ]), + ); +} + +describe("Needs-you Inbox and Threads needs-action agreement (AC .27)", () => { + it("coincides on an answerable clarification bundle: waiting session, clarification pending action", () => { + const statuses = needsActionStatuses([ + task({ + id: "coincide", + primarySessionState: "WAITING_FOR_INPUT", + primarySessionPendingAction: "clarification", + }), + ]); + + expect(statuses.get("coincide")).toBe("needs_action"); + }); + + it("diverges on a pending tool-permission request: Threads reads needs_action, the bundle path never returns permission requests", () => { + const statuses = needsActionStatuses([ + task({ + id: "permission", + primarySessionState: "WAITING_FOR_INPUT", + primarySessionPendingAction: "permission", + }), + ]); + + expect(statuses.get("permission")).toBe("needs_action"); + }); + + it("diverges on a terminal session: Threads has no terminal check and still reads needs_action from a stale pending action", () => { + const statuses = needsActionStatuses([ + task({ + id: "terminal", + primarySessionState: "COMPLETED", + primarySessionPendingAction: "clarification", + }), + ]); + + expect(statuses.get("terminal")).toBe("needs_action"); + }); + + it("diverges silently on a parent-question record and on a bundle with no resolvable question identifier: Threads carries neither fact, so both fixtures are indistinguishable from the coincide case here", () => { + const statuses = needsActionStatuses([ + task({ + id: "parent-question-or-unresolvable", + primarySessionState: "WAITING_FOR_INPUT", + primarySessionPendingAction: "clarification", + }), + ]); + + expect(statuses.get("parent-question-or-unresolvable")).toBe("needs_action"); + }); +}); diff --git a/apps/web/lib/needs-you-inbox/row-presentation.test.ts b/apps/web/lib/needs-you-inbox/row-presentation.test.ts new file mode 100644 index 0000000000..133683c03d --- /dev/null +++ b/apps/web/lib/needs-you-inbox/row-presentation.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { sessionId as toSessionId, taskId as toTaskId } from "@/lib/types/http"; +import type { ClarificationInboxBundle } from "@/lib/types/clarification-inbox"; +import { rowPrimaryText, rowSecondaryText } from "./row-presentation"; + +const FALLBACK = "fallback"; +const QUESTION_FROM_AGENT = "Question from agent"; +const QUESTION_PROMPT = "Which plan?"; + +function bundle(overrides: Partial = {}): ClarificationInboxBundle { + return { + pending_id: "p1", + task_id: "t1", + session_id: "s1", + session_state: "WAITING_FOR_INPUT", + task_title: "", + created_at: "2026-09-03T04:54:02Z", + context: "", + messages: [], + ...overrides, + }; +} + +function messageWithQuestion(question: { title?: string; prompt?: string }) { + return { + id: "m1", + session_id: toSessionId("s1"), + task_id: toTaskId("t1"), + author_type: "agent" as const, + content: "", + type: "clarification_request" as const, + created_at: "2026-09-03T04:54:02Z", + metadata: { + pending_id: "p1", + session_id: "s1", + question: { + id: "q1", + title: question.title ?? "", + prompt: question.prompt ?? "", + options: [], + }, + }, + }; +} + +describe("rowPrimaryText", () => { + it("uses the first question's title when non-empty", () => { + const b = bundle({ messages: [messageWithQuestion({ title: "Pick a plan" })] }); + expect(rowPrimaryText(b, FALLBACK)).toBe("Pick a plan"); + }); + + it("falls back to the prompt when the title is empty", () => { + const b = bundle({ messages: [messageWithQuestion({ prompt: QUESTION_PROMPT })] }); + expect(rowPrimaryText(b, FALLBACK)).toBe(QUESTION_PROMPT); + }); + + it("falls back to the prompt when the title contains only whitespace", () => { + const b = bundle({ + messages: [messageWithQuestion({ title: " \n", prompt: QUESTION_PROMPT })], + }); + expect(rowPrimaryText(b, FALLBACK)).toBe(QUESTION_PROMPT); + }); + + it("falls back to the bundle context when title and prompt are both empty", () => { + const b = bundle({ context: "Deploying to prod", messages: [messageWithQuestion({})] }); + expect(rowPrimaryText(b, FALLBACK)).toBe("Deploying to prod"); + }); + + it("falls back to the localized default when nothing else is available", () => { + const b = bundle({ messages: [messageWithQuestion({})] }); + expect(rowPrimaryText(b, QUESTION_FROM_AGENT)).toBe(QUESTION_FROM_AGENT); + }); + + it("falls back to the default when there are no messages at all", () => { + const b = bundle({ messages: [] }); + expect(rowPrimaryText(b, QUESTION_FROM_AGENT)).toBe(QUESTION_FROM_AGENT); + }); +}); + +describe("rowSecondaryText", () => { + it("prefers the task title", () => { + expect(rowSecondaryText(bundle({ task_title: "Fix the build", task_id: "t1" }))).toBe( + "Fix the build", + ); + }); + + it("falls back to the task id when the title is empty", () => { + expect(rowSecondaryText(bundle({ task_title: "", task_id: "t1" }))).toBe("t1"); + }); +}); diff --git a/apps/web/lib/needs-you-inbox/row-presentation.ts b/apps/web/lib/needs-you-inbox/row-presentation.ts new file mode 100644 index 0000000000..a128c33f7f --- /dev/null +++ b/apps/web/lib/needs-you-inbox/row-presentation.ts @@ -0,0 +1,32 @@ +import type { ClarificationRequestMetadata } from "@/lib/types/http-agents"; +import type { ClarificationInboxBundle } from "@/lib/types/clarification-inbox"; + +function firstQuestion(bundle: ClarificationInboxBundle) { + const metadata = bundle.messages[0]?.metadata as ClarificationRequestMetadata | undefined; + return metadata?.question; +} + +// design-01#Data-and-contracts: title, else prompt, else the bundle's shared +// context, else the caller-supplied localized fallback. Never blank. +export function rowPrimaryText(bundle: ClarificationInboxBundle, fallback: string): string { + const question = firstQuestion(bundle); + if (question?.title?.trim()) return question.title; + if (question?.prompt) return question.prompt; + if (bundle.context) return bundle.context; + return fallback; +} + +// Secondary text: task title then task identifier (design-01#Data-and-contracts). +export function rowSecondaryText(bundle: ClarificationInboxBundle): string { + return bundle.task_title || bundle.task_id; +} + +// How many questions the bundle carries, counted by the presence of a +// clarification question rather than by message count: a bundle's message list +// can hold context messages that are not themselves questions (design-03#D3). +export function rowQuestionCount(bundle: ClarificationInboxBundle): number { + return bundle.messages.filter((message) => { + const metadata = message.metadata as ClarificationRequestMetadata | undefined; + return Boolean(metadata?.question); + }).length; +} diff --git a/apps/web/lib/state/app-state-extra-actions.ts b/apps/web/lib/state/app-state-extra-actions.ts new file mode 100644 index 0000000000..dfe848217b --- /dev/null +++ b/apps/web/lib/state/app-state-extra-actions.ts @@ -0,0 +1,34 @@ +import type { UISliceActions as UIA } from "./slices/ui/types"; +import type { + SystemSliceActions, + AutomationsSliceActions, + FeaturesSliceActions, + AuthSliceActions, + GitHubSliceActions, + GitLabSliceActions, + AzureDevOpsSliceActions, + JiraSliceActions, + LinearSliceActions, + OfficeSliceActions, + PluginsSliceActions, + ReviewSliceActions, + NeedsYouInboxSliceActions, +} from "./slices"; + +// Split out of app-state-types.ts to stay under the file line limit. These +// slices contribute actions only (no AppState fields declared here). +export type AppStateExtraActions = Pick & + GitHubSliceActions & + GitLabSliceActions & + JiraSliceActions & + LinearSliceActions & + OfficeSliceActions & + import("./store-reexports").WorkspaceSourceStoreState & + AzureDevOpsSliceActions & + SystemSliceActions & + FeaturesSliceActions & + AuthSliceActions & + AutomationsSliceActions & + PluginsSliceActions & + ReviewSliceActions & + NeedsYouInboxSliceActions; diff --git a/apps/web/lib/state/app-state-types.ts b/apps/web/lib/state/app-state-types.ts index 43c382f37b..fbfdd1ea10 100644 --- a/apps/web/lib/state/app-state-types.ts +++ b/apps/web/lib/state/app-state-types.ts @@ -43,6 +43,7 @@ import { defaultSystemState, defaultPluginsState, defaultReviewState, + defaultNeedsYouInboxState, } from "./slices"; import type { WorkspaceState, @@ -69,20 +70,10 @@ import type { PreviewViewMode, PreviewDevicePreset, ConnectionState, - SystemSliceActions, - AutomationsSliceActions, - FeaturesSliceActions, - AuthSliceActions, - GitHubSliceActions, - GitLabSliceActions, - AzureDevOpsSliceActions, - JiraSliceActions, - LinearSliceActions, - OfficeSliceActions, - PluginsSliceActions, - ReviewSliceActions, KanbanSlice, + NeedsYouInboxBootSeed, } from "./slices"; +import type { AppStateExtraActions } from "./app-state-extra-actions"; import type { AvailableCommand, SessionModeEntry, @@ -224,6 +215,10 @@ export type AppState = KanbanSlice & { // Review slice (actions merged via ReviewSliceActions intersection on AppState) taskReview: (typeof defaultReviewState)["taskReview"]; + // Needs-you Inbox slice (actions merged via NeedsYouInboxSliceActions + // intersection on AppState) + needsYouInbox: (typeof defaultNeedsYouInboxState)["needsYouInbox"]; + // UI slice previewPanel: (typeof defaultUIState)["previewPanel"]; rightPanel: (typeof defaultUIState)["rightPanel"]; @@ -635,20 +630,7 @@ export type AppState = KanbanSlice & { restoreRichOutputAnimations: UIA["restoreRichOutputAnimations"]; acknowledgeAgentErrors: UIA["acknowledgeAgentErrors"]; dismissAgentError: UIA["dismissAgentError"]; -} & Pick & - GitHubSliceActions & - GitLabSliceActions & - JiraSliceActions & - LinearSliceActions & - OfficeSliceActions & - import("./store-reexports").WorkspaceSourceStoreState & - AzureDevOpsSliceActions & - SystemSliceActions & - FeaturesSliceActions & - AuthSliceActions & - AutomationsSliceActions & - PluginsSliceActions & - ReviewSliceActions; +} & AppStateExtraActions; // Most callers hydrate a fully-shaped slice per top-level key (see // mergeInitialState / hydrateState), but `system` is a grab-bag of many @@ -659,4 +641,8 @@ export type AppState = KanbanSlice & { export type HydrationState = Omit, "system" | "quickChat"> & { quickChat?: Partial; system?: Partial; + // The Needs-you Inbox boot-hydration producer's raw wire shape, carried + // alongside (not inside) the `needsYouInbox` slice's own hydration key. See + // useNeedsYouInboxController's boot-seed effect, the only consumer. + needsYouInboxBoot?: NeedsYouInboxBootSeed; }; diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index a2dff64d88..b6c9272638 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -16,6 +16,7 @@ import { defaultAutomationsState, defaultSystemState, defaultReviewState, + defaultNeedsYouInboxState, } from "./slices"; import { mergeHydratedQuickChatSessions } from "@/lib/state/slices/ui/quick-chat-sync"; import type { AgentRuntimeAvailability } from "@/lib/types/agent-runtime"; @@ -67,6 +68,7 @@ export const defaultState = { taskPlans: defaultSessionState.taskPlans, walkthroughs: defaultSessionState.walkthroughs, taskReview: defaultReviewState.taskReview, + needsYouInbox: defaultNeedsYouInboxState.needsYouInbox, queue: defaultSessionState.queue, terminal: defaultSessionRuntimeState.terminal, shell: defaultSessionRuntimeState.shell, @@ -510,6 +512,7 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState { ...initialState.linearIssueWatches, }, office: { ...defaultState.office, ...initialState.office }, + needsYouInbox: { ...defaultState.needsYouInbox, ...initialState.needsYouInbox }, features: { ...defaultState.features, ...initialState.features }, auth: { ...defaultState.auth, ...initialState.auth }, ...mergeSessionHostnamesState(initialState), diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 747d18c78a..4dc947c745 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -15,6 +15,7 @@ export const defaultFeatureFlags = { claudeBackgroundPromptHandoff: false, claudeMidTurnSteering: false, officeSessionIdentity: false, + needsYouInbox: false, agentSurvival: false, } as const; diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 0ba6645d98..d4df550361 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -20,6 +20,10 @@ export { createAutomationsSlice, defaultAutomationsState } from "./automations/a export { createSystemSlice, defaultSystemState } from "./system/system-slice"; export { createPluginsSlice, defaultPluginsState } from "./plugins/plugins-slice"; export { createReviewSlice, defaultReviewState } from "./review/review-slice"; +export { + createNeedsYouInboxSlice, + defaultNeedsYouInboxState, +} from "./needs-you-inbox/needs-you-inbox-slice"; // Export types export type { KanbanSlice, KanbanSliceState, KanbanSliceActions } from "./kanban/types"; @@ -92,6 +96,14 @@ export type { PluginsState, } from "./plugins/types"; export type { ReviewSlice, ReviewSliceActions, ReviewSliceState } from "./review/types"; +export type { + NeedsYouInboxSlice, + NeedsYouInboxSliceActions, + NeedsYouInboxSliceState, + NeedsYouInboxWorkspaceState, + NeedsYouInboxReadStatus, + NeedsYouInboxBootSeed, +} from "./needs-you-inbox/types"; // Re-export commonly used types from each domain export type { diff --git a/apps/web/lib/state/slices/needs-you-inbox/index.ts b/apps/web/lib/state/slices/needs-you-inbox/index.ts new file mode 100644 index 0000000000..7d3cbcf7b0 --- /dev/null +++ b/apps/web/lib/state/slices/needs-you-inbox/index.ts @@ -0,0 +1,8 @@ +export { createNeedsYouInboxSlice, defaultNeedsYouInboxState } from "./needs-you-inbox-slice"; +export type { + NeedsYouInboxSlice, + NeedsYouInboxSliceActions, + NeedsYouInboxSliceState, + NeedsYouInboxWorkspaceState, + NeedsYouInboxReadStatus, +} from "./types"; diff --git a/apps/web/lib/state/slices/needs-you-inbox/needs-you-inbox-slice.test.ts b/apps/web/lib/state/slices/needs-you-inbox/needs-you-inbox-slice.test.ts new file mode 100644 index 0000000000..8143e314b7 --- /dev/null +++ b/apps/web/lib/state/slices/needs-you-inbox/needs-you-inbox-slice.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import type { ClarificationInboxBundle } from "@/lib/types/clarification-inbox"; +import { createNeedsYouInboxSlice } from "./needs-you-inbox-slice"; +import type { NeedsYouInboxSlice } from "./types"; + +function newStore() { + // `createNeedsYouInboxSlice` types `set` against the full `AppState` (the + // zustand slices pattern, so composing it in store.ts needs no cast); this + // isolated test store only has `NeedsYouInboxSlice`, so the two `set` + // shapes need a cast here, in test-only code the store.ts architecture + // rule does not cover. + return create()( + immer((set) => + createNeedsYouInboxSlice(set as unknown as Parameters[0]), + ), + ); +} + +function bundle(overrides: Partial = {}): ClarificationInboxBundle { + return { + pending_id: "p1", + task_id: "t1", + session_id: "s1", + session_state: "WAITING_FOR_INPUT", + task_title: "Task", + created_at: "2026-09-03T04:54:02Z", + context: "", + messages: [], + ...overrides, + }; +} + +describe("needs-you-inbox slice", () => { + it("has no rows or count for a workspace before any read (AC .13: absent is not zero)", () => { + const store = newStore(); + expect(store.getState().needsYouInbox.byWorkspaceId.w1).toBeUndefined(); + }); + + it("applies a page read tagged with the current generation", () => { + const store = newStore(); + const generation = store.getState().beginNeedsYouInboxRead("w1"); + + store.getState().setNeedsYouInboxPage("w1", generation, { + bundles: [bundle()], + count: 1, + hiddenCount: 2, + nextSnoozeExpiry: "2026-09-03T05:00:00Z", + hasMore: true, + }); + + const state = store.getState().needsYouInbox.byWorkspaceId.w1; + expect(state.status).toBe("ready"); + expect(state.count).toBe(1); + expect(state.bundles).toHaveLength(1); + expect(state.hiddenCount).toBe(2); + expect(state.hasMore).toBe(true); + expect(state.nextSnoozeExpiry).toBe("2026-09-03T05:00:00Z"); + }); + + // AC .38: a fast workspace switch or overlapping refresh trigger must not + // let a slower, older response overwrite what a newer one already wrote. + it("drops a page response whose generation was superseded by a newer read", () => { + const store = newStore(); + const staleGeneration = store.getState().beginNeedsYouInboxRead("w1"); + const freshGeneration = store.getState().beginNeedsYouInboxRead("w1"); + expect(freshGeneration).not.toBe(staleGeneration); + + store.getState().setNeedsYouInboxPage("w1", freshGeneration, { + bundles: [bundle({ pending_id: "fresh" })], + count: 1, + hiddenCount: 0, + nextSnoozeExpiry: null, + hasMore: false, + }); + // The stale response lands after the fresh one and must be dropped. + store.getState().setNeedsYouInboxPage("w1", staleGeneration, { + bundles: [bundle({ pending_id: "stale" })], + count: 1, + hiddenCount: 0, + nextSnoozeExpiry: null, + hasMore: false, + }); + + const state = store.getState().needsYouInbox.byWorkspaceId.w1; + expect(state.bundles[0].pending_id).toBe("fresh"); + }); + + it("clears rows and count in the same update on a failed read that follows a successful one", () => { + const store = newStore(); + const okGeneration = store.getState().beginNeedsYouInboxRead("w1"); + store.getState().setNeedsYouInboxPage("w1", okGeneration, { + bundles: [bundle()], + count: 1, + hiddenCount: 0, + nextSnoozeExpiry: null, + hasMore: false, + }); + + const failGeneration = store.getState().beginNeedsYouInboxRead("w1"); + store.getState().setNeedsYouInboxError("w1", failGeneration); + + const state = store.getState().needsYouInbox.byWorkspaceId.w1; + expect(state.status).toBe("error"); + expect(state.bundles).toHaveLength(0); + expect(state.count).toBe(0); + }); + + it("drops a stale error response the same way it drops a stale page response", () => { + const store = newStore(); + const staleGeneration = store.getState().beginNeedsYouInboxRead("w1"); + const freshGeneration = store.getState().beginNeedsYouInboxRead("w1"); + store.getState().setNeedsYouInboxPage("w1", freshGeneration, { + bundles: [bundle()], + count: 1, + hiddenCount: 0, + nextSnoozeExpiry: null, + hasMore: false, + }); + + store.getState().setNeedsYouInboxError("w1", staleGeneration); + + const state = store.getState().needsYouInbox.byWorkspaceId.w1; + expect(state.status).toBe("ready"); + expect(state.count).toBe(1); + }); +}); + +describe("needs-you-inbox slice — boot seeding", () => { + it("seeds boot state at generation 0 only when no real read has started yet", () => { + const store = newStore(); + store.getState().seedNeedsYouInboxBoot("w1", { + count: 3, + hasMore: false, + nextSnoozeExpiry: null, + }); + + expect(store.getState().needsYouInbox.byWorkspaceId.w1.count).toBe(3); + }); + + it("lets the first real read supersede a boot seed", () => { + const store = newStore(); + store.getState().seedNeedsYouInboxBoot("w1", { + count: 3, + hasMore: false, + nextSnoozeExpiry: null, + }); + + const generation = store.getState().beginNeedsYouInboxRead("w1"); + store.getState().setNeedsYouInboxPage("w1", generation, { + bundles: [], + count: 0, + hiddenCount: 0, + nextSnoozeExpiry: null, + hasMore: false, + }); + + expect(store.getState().needsYouInbox.byWorkspaceId.w1.count).toBe(0); + expect(store.getState().needsYouInbox.byWorkspaceId.w1.status).toBe("ready"); + }); + + it("never lets a boot seed overwrite a read already in flight", () => { + const store = newStore(); + store.getState().beginNeedsYouInboxRead("w1"); + store.getState().seedNeedsYouInboxBoot("w1", { + count: 99, + hasMore: false, + nextSnoozeExpiry: null, + }); + + // The seed must not have written anything once a read generation exists. + expect(store.getState().needsYouInbox.byWorkspaceId.w1.count).toBe(0); + expect(store.getState().needsYouInbox.byWorkspaceId.w1.status).toBe("loading"); + }); +}); diff --git a/apps/web/lib/state/slices/needs-you-inbox/needs-you-inbox-slice.ts b/apps/web/lib/state/slices/needs-you-inbox/needs-you-inbox-slice.ts new file mode 100644 index 0000000000..3a878e6b28 --- /dev/null +++ b/apps/web/lib/state/slices/needs-you-inbox/needs-you-inbox-slice.ts @@ -0,0 +1,103 @@ +import type { StateCreator } from "zustand"; +import type { AppState } from "../../app-state-types"; +import type { + NeedsYouInboxSlice, + NeedsYouInboxSliceState, + NeedsYouInboxWorkspaceState, +} from "./types"; + +export const defaultNeedsYouInboxState: NeedsYouInboxSliceState = { + needsYouInbox: { + byWorkspaceId: {}, + generationByWorkspaceId: {}, + refreshTick: 0, + }, +}; + +const emptyWorkspaceState = (): NeedsYouInboxWorkspaceState => ({ + bundles: [], + count: 0, + hiddenCount: 0, + nextSnoozeExpiry: null, + hasMore: false, + status: "idle", + appliedGeneration: 0, +}); + +// Typed against the full `AppState` (the zustand slices-pattern shape), not +// this slice's own narrower type -- that keeps `set` structurally identical +// to the root store's `set`, so composing this slice in store.ts needs no +// `as any` escape (ARCH-FRONTEND-ROOT-STATE-CAST). +type ImmerSet = Parameters< + StateCreator +>[0]; + +/** + * Typed as a plain factory over `set` (mirrors the review slice): every + * action here only needs to write, never read prior state through `get`. + */ +export const createNeedsYouInboxSlice = (set: ImmerSet): NeedsYouInboxSlice => ({ + ...defaultNeedsYouInboxState, + + beginNeedsYouInboxRead: (workspaceId) => { + let generation = 0; + set((draft) => { + const next = (draft.needsYouInbox.generationByWorkspaceId[workspaceId] ?? 0) + 1; + draft.needsYouInbox.generationByWorkspaceId[workspaceId] = next; + const workspace = draft.needsYouInbox.byWorkspaceId[workspaceId] ?? emptyWorkspaceState(); + workspace.status = "loading"; + draft.needsYouInbox.byWorkspaceId[workspaceId] = workspace; + generation = next; + }); + return generation; + }, + + setNeedsYouInboxPage: (workspaceId, generation, page) => + set((draft) => { + if (draft.needsYouInbox.generationByWorkspaceId[workspaceId] !== generation) return; + draft.needsYouInbox.byWorkspaceId[workspaceId] = { + bundles: page.bundles, + count: page.count, + hiddenCount: page.hiddenCount, + nextSnoozeExpiry: page.nextSnoozeExpiry, + hasMore: page.hasMore, + status: "ready", + appliedGeneration: generation, + }; + }), + + setNeedsYouInboxError: (workspaceId, generation) => + set((draft) => { + if (draft.needsYouInbox.generationByWorkspaceId[workspaceId] !== generation) return; + // A failed read clears the rows it was replacing in the SAME update + // (design-02#Failure-and-recovery) -- stale rows over an absent badge + // is a disagreement the badge and the list must never show. + draft.needsYouInbox.byWorkspaceId[workspaceId] = { + ...emptyWorkspaceState(), + status: "error", + appliedGeneration: generation, + }; + }), + + seedNeedsYouInboxBoot: (workspaceId, seed) => + set((draft) => { + // Generation 0 seeds only; if a real read already applied (generation + // >= 1 was issued), boot arriving late must not overwrite fresher rows. + if (draft.needsYouInbox.generationByWorkspaceId[workspaceId]) return; + if (draft.needsYouInbox.byWorkspaceId[workspaceId]) return; + draft.needsYouInbox.byWorkspaceId[workspaceId] = { + bundles: [], + count: seed.count, + hiddenCount: 0, + nextSnoozeExpiry: seed.nextSnoozeExpiry, + hasMore: seed.hasMore, + status: "idle", + appliedGeneration: 0, + }; + }), + + bumpNeedsYouInboxRefreshTick: () => + set((draft) => { + draft.needsYouInbox.refreshTick += 1; + }), +}); diff --git a/apps/web/lib/state/slices/needs-you-inbox/selectors.ts b/apps/web/lib/state/slices/needs-you-inbox/selectors.ts new file mode 100644 index 0000000000..b17435ad5d --- /dev/null +++ b/apps/web/lib/state/slices/needs-you-inbox/selectors.ts @@ -0,0 +1,53 @@ +import type { AppState } from "@/lib/state/store"; +import type { ClarificationInboxBundle } from "@/lib/types/clarification-inbox"; +import type { NeedsYouInboxWorkspaceState } from "./types"; + +const EMPTY_BUNDLES: ClarificationInboxBundle[] = []; + +const EMPTY_WORKSPACE_STATE: NeedsYouInboxWorkspaceState = { + bundles: EMPTY_BUNDLES, + count: 0, + hiddenCount: 0, + nextSnoozeExpiry: null, + hasMore: false, + status: "idle", + appliedGeneration: 0, +}; + +function selectActiveWorkspaceState(state: AppState): NeedsYouInboxWorkspaceState { + const workspaceId = state.workspaces.activeId; + if (!workspaceId) return EMPTY_WORKSPACE_STATE; + return state.needsYouInbox.byWorkspaceId[workspaceId] ?? EMPTY_WORKSPACE_STATE; +} + +/** The sidebar badge count for the active workspace. Absent workspace or + * absent state renders no badge, not a zero -- distinguishing "not seeded" + * from "confirmed empty" at the presentation layer (callers gate visibility + * separately). */ +export function selectNeedsYouInboxCount(state: AppState): number { + return selectActiveWorkspaceState(state).count; +} + +export function selectNeedsYouInboxBundles(state: AppState): ClarificationInboxBundle[] { + return selectActiveWorkspaceState(state).bundles; +} + +export function selectNeedsYouInboxStatus(state: AppState) { + return selectActiveWorkspaceState(state).status; +} + +export function selectNeedsYouInboxHiddenCount(state: AppState): number { + return selectActiveWorkspaceState(state).hiddenCount; +} + +export function selectNeedsYouInboxRevision(state: AppState): number { + return selectActiveWorkspaceState(state).appliedGeneration; +} + +export function selectNeedsYouInboxHasMore(state: AppState): boolean { + return selectActiveWorkspaceState(state).hasMore; +} + +export function selectNeedsYouInboxNextSnoozeExpiry(state: AppState): string | null { + return selectActiveWorkspaceState(state).nextSnoozeExpiry; +} diff --git a/apps/web/lib/state/slices/needs-you-inbox/types.ts b/apps/web/lib/state/slices/needs-you-inbox/types.ts new file mode 100644 index 0000000000..83f7c1a10e --- /dev/null +++ b/apps/web/lib/state/slices/needs-you-inbox/types.ts @@ -0,0 +1,71 @@ +import type { ClarificationInboxBundle } from "@/lib/types/clarification-inbox"; + +export type NeedsYouInboxReadStatus = "idle" | "loading" | "ready" | "error"; + +// The boot-hydration producer's wire shape (needs-you-inbox +// design-01#Data-and-contracts): carried on the boot payload's +// `initialState.needsYouInboxBoot`, distinct from the `needsYouInbox` slice +// shape itself, and consumed once via seedNeedsYouInboxBoot. +export type NeedsYouInboxBootSeed = { + workspaceId: string; + count: number; + hasMore: boolean; + nextSnoozeExpiry: string | null; +}; + +export type NeedsYouInboxWorkspaceState = { + bundles: ClarificationInboxBundle[]; + count: number; + hiddenCount: number; + nextSnoozeExpiry: string | null; + hasMore: boolean; + status: NeedsYouInboxReadStatus; + // The generation of the last response actually applied to this workspace's + // rows, for the stale-response guard. + appliedGeneration: number; +}; + +export type NeedsYouInboxSliceState = { + needsYouInbox: { + byWorkspaceId: Record; + // Latest generation ISSUED per workspace (bumped once per read this + // client starts). A response is applied only when its generation still + // equals this counter -- anything older lost the race and is dropped. + generationByWorkspaceId: Record; + // Bumped by the WS-action refresh triggers (design-02#Control-flow): + // session.pending_action_changed and session.state_changed. Not + // per-workspace -- every trigger re-reads whichever workspace is active + // when it fires, never the one the event happened to name. + refreshTick: number; + }; +}; + +export type NeedsYouInboxSliceActions = { + /** Bumps and returns the new request generation for a workspace read. */ + beginNeedsYouInboxRead: (workspaceId: string) => number; + /** Applies a successful page read if its generation is still current. */ + setNeedsYouInboxPage: ( + workspaceId: string, + generation: number, + page: { + bundles: ClarificationInboxBundle[]; + count: number; + hiddenCount: number; + nextSnoozeExpiry: string | null; + hasMore: boolean; + }, + ) => void; + /** Applies a failed read if its generation is still current -- clears rows + * and count in the same update (design-02#Failure-and-recovery). */ + setNeedsYouInboxError: (workspaceId: string, generation: number) => void; + /** Seeds a workspace from boot hydration at generation 0, so the first real + * read (generation >= 1) always supersedes it. */ + seedNeedsYouInboxBoot: ( + workspaceId: string, + seed: { count: number; hasMore: boolean; nextSnoozeExpiry: string | null }, + ) => void; + /** Bumps the WS-action refresh trigger tick (design-02#Control-flow). */ + bumpNeedsYouInboxRefreshTick: () => void; +}; + +export type NeedsYouInboxSlice = NeedsYouInboxSliceState & NeedsYouInboxSliceActions; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index de3589e7f1..7be436c043 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -24,6 +24,7 @@ import { createSystemSlice, createPluginsSlice, createReviewSlice, + createNeedsYouInboxSlice, } from "./slices"; // Re-export all types from slices for backwards compatibility. @@ -80,6 +81,7 @@ export function createAppStore(initialState?: HydrationState) { // arguments (CodeQL js/superfluous-trailing-arguments). // eslint-disable-next-line @typescript-eslint/no-explicit-any ...createReviewSlice(set as any), + ...createNeedsYouInboxSlice(set), // 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/types/clarification-inbox.ts b/apps/web/lib/types/clarification-inbox.ts new file mode 100644 index 0000000000..3023638f99 --- /dev/null +++ b/apps/web/lib/types/clarification-inbox.ts @@ -0,0 +1,41 @@ +// Needs-you Inbox wire types (docs/specs/ui/system-design/needs-you-inbox-01.md +// #Data-and-contracts). Mirrors apps/backend/internal/clarification/inbox_handlers.go's +// inboxBundleView / inboxListResponse / inboxHiddenBundleView / inboxHiddenListResponse. +import type { Message } from "@/lib/types/http"; + +export type ClarificationInboxBundle = { + pending_id: string; + task_id: string; + session_id: string; + session_state: string; + task_title: string; + created_at: string; + context: string; + messages: Message[]; +}; + +export type ClarificationInboxPage = { + bundles: ClarificationInboxBundle[]; + count: number; + hidden_count: number; + next_snooze_expiry: string | null; + // Present exactly when the page was truncated; absent means exhausted. + // Used only as a truncation boolean; v1 never echoes it back as a request + // cursor. + next_cursor?: string; +}; + +export type ClarificationInboxSidecarState = "dismissed" | "snoozed"; + +export type ClarificationInboxHiddenBundle = ClarificationInboxBundle & { + state: ClarificationInboxSidecarState; + snooze_until: string | null; +}; + +export type ClarificationInboxHiddenPage = { + bundles: ClarificationInboxHiddenBundle[]; + count: number; + total: number; +}; + +export type ClarificationInboxSnoozeDuration = "1h" | "4h" | "24h"; diff --git a/apps/web/src/app-shell.test.tsx b/apps/web/src/app-shell.test.tsx index cff3110b79..eb3857a157 100644 --- a/apps/web/src/app-shell.test.tsx +++ b/apps/web/src/app-shell.test.tsx @@ -22,6 +22,9 @@ vi.mock("@/components/diff-worker-pool-provider", () => ({ vi.mock("@/components/desktop-command-host", () => ({ DesktopCommandHost: () => null })); vi.mock("@/components/global-commands", () => ({ GlobalCommands: () => null })); vi.mock("@/components/log-buffer-bridge", () => ({ LogBufferBridge: () => null })); +vi.mock("@/components/needs-you-inbox/needs-you-inbox-bridge", () => ({ + NeedsYouInboxBridge: () => null, +})); vi.mock("@/components/quick-chat/quick-chat-provider", () => ({ QuickChatProvider: mocks.passthrough, })); diff --git a/apps/web/src/app-shell.tsx b/apps/web/src/app-shell.tsx index 7c530ff6c5..4c8fb59a36 100644 --- a/apps/web/src/app-shell.tsx +++ b/apps/web/src/app-shell.tsx @@ -20,6 +20,7 @@ import { ThemeProvider } from "@/components/theme-provider"; import { ToastProvider } from "@/components/toast-provider"; import { WorkspaceScopeProvider } from "@/components/workspace-scope-provider"; import { WebSocketConnector } from "@/components/ws-connector"; +import { NeedsYouInboxBridge } from "@/components/needs-you-inbox/needs-you-inbox-bridge"; import { useWindowControlsOverlay } from "@/hooks/use-window-controls-overlay"; import { useTaskColorMigration } from "@/hooks/use-task-color-migration"; import { CommandRegistryProvider } from "@/lib/commands/command-registry"; @@ -78,6 +79,7 @@ export function AppShell({ children }: AppShellProps) { + diff --git a/apps/web/src/boot-payload.ts b/apps/web/src/boot-payload.ts index e685d89b04..05634d0f59 100644 --- a/apps/web/src/boot-payload.ts +++ b/apps/web/src/boot-payload.ts @@ -1,4 +1,4 @@ -import type { AppState } from "@/lib/state/store"; +import type { HydrationState } from "@/lib/state/store"; import { getBackendConfig } from "@/lib/config"; import type { FetchedSessionData } from "@/lib/ssr/session-page-state"; import type { @@ -75,7 +75,7 @@ export type BootPayload = { version?: number; route?: BootRoute; runtime?: BootRuntime; - initialState?: Partial; + initialState?: HydrationState; routeData?: BootRouteData; plugins?: ActivePlugin[]; /** Replayable per-boot CSRF/accidental-mutation interlock; not authentication. */ @@ -99,7 +99,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 HydrationState) : {}, routeData: isRecord(payload.routeData) ? (payload.routeData as BootRouteData) : undefined, plugins: Array.isArray(payload.plugins) ? readPlugins(payload.plugins) : undefined, interimSettingsInterlockToken: readNonEmptyString(payload.interimSettingsInterlockToken), diff --git a/apps/web/src/canvas-route.tsx b/apps/web/src/canvas-route.tsx new file mode 100644 index 0000000000..9c5540f686 --- /dev/null +++ b/apps/web/src/canvas-route.tsx @@ -0,0 +1,21 @@ +import { CanvasHostRoute } from "@/components/settings/canvas-host-route"; +import { SettingsLayoutClient } from "@/components/settings/settings-layout-client"; +import { WorkspaceCanvasesPage } from "@/components/settings/workspace-canvases-page"; +import { WorkspaceSettingsShell } from "@/components/settings/workspaces/workspace-settings-shell"; +import { AuthRouteRedirect } from "./spa-route-chrome"; + +type CanvasRouteProps = + | { kind: "canvas"; canvasId: string } + | { kind: "canvasSettings"; workspaceId: string }; + +export function CanvasRoute({ route, enabled }: { route: CanvasRouteProps; enabled: boolean }) { + if (!enabled) return ; + if (route.kind === "canvas") return ; + return ( + + + + + + ); +} diff --git a/apps/web/src/locales/en/needsYouInbox.json b/apps/web/src/locales/en/needsYouInbox.json new file mode 100644 index 0000000000..fa949056e3 --- /dev/null +++ b/apps/web/src/locales/en/needsYouInbox.json @@ -0,0 +1,35 @@ +{ + "questionFromAgent": "Question from agent", + "rowActions": "Row actions", + "dismiss": "Dismiss", + "dismissFailed": "Could not dismiss this question. Try again.", + "snooze": "Snooze", + "snoozeDuration1h": "1 hour", + "snoozeDuration4h": "4 hours", + "snoozeDuration24h": "24 hours", + "snoozeFailed": "Could not snooze this question. Try again.", + "anotherCallerResolved": "Someone else already answered \"{{question}}\".", + "anotherCallerRejected": "Someone else already rejected \"{{question}}\".", + "bundleNoLongerActive": "\"{{question}}\" is no longer waiting on an answer.", + "errorTitle": "Could not load your inbox", + "errorDescription": "Something went wrong loading the questions waiting on you.", + "retry": "Retry", + "emptyTitle": "Nothing is waiting on you in {{workspace}}.", + "emptyDescription": "This list only covers questions blocking an agent in this workspace. It does not count bundles in other workspaces, pending permission requests, parent-question records, or your own snoozed or dismissed rows.", + "hiddenNotice_one": "{{count}} question is hidden by your own dismiss or snooze.", + "hiddenNotice_other": "{{count}} questions are hidden by your own dismiss or snooze.", + "showHidden": "Show hidden", + "hideHidden": "Hide", + "hiddenLoading": "Loading hidden questions…", + "hiddenLoadFailed": "Could not load hidden questions. Try again.", + "hiddenEmpty": "Nothing is currently hidden.", + "snoozedUntil": "Snoozed, reappears {{time}}", + "dismissed": "Dismissed", + "restore": "Restore", + "restoreFailed": "Could not restore this question. Try again.", + "truncatedNotice": "Showing the first questions. More are waiting.", + "openTask": "Open task", + "questionCount_one": "{{count}} question in this bundle", + "questionCount_other": "{{count}} questions in this bundle", + "emptyTitleUnnamedWorkspace": "Nothing is waiting on you in this workspace." +} diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json index 38a6ec26e9..d89d1f586d 100644 --- a/apps/web/src/locales/en/sidebar.json +++ b/apps/web/src/locales/en/sidebar.json @@ -50,6 +50,7 @@ "maxViews_one": "{{count}} max", "maxViews_other": "{{count}} max", "morePluginItems": "More plugin items", + "needsYouInbox": "Needs you", "nestUnder": "Nest under {{title}}", "newKanbanWorkspace": "New kanban workspace", "newOfficeWorkspace": "New office workspace", diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 5eadeba13a..b70d9a4133 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -93,6 +93,7 @@ "annotationCount_other": "{{count}} annotations", "annotationPin": "Pin", "answer": "answer", + "answeredOfTotal": "{{answeredCount}} of {{total}} answered", "anyUncommittedOrUnpushedChangesAre": "Any uncommitted or unpushed changes are lost.", "anyUncommittedOrUnpushedChangesIn": "Any uncommitted or unpushed changes in the workspace will be lost.", "anyoneWithThisLinkCanView": "Anyone with this link can view this conversation. Review the preview carefully before publishing; once published, the snapshot is uploaded to GitHub Gist on your account.", diff --git a/apps/web/src/locales/pseudo/needsYouInbox.json b/apps/web/src/locales/pseudo/needsYouInbox.json new file mode 100644 index 0000000000..c7538c6b44 --- /dev/null +++ b/apps/web/src/locales/pseudo/needsYouInbox.json @@ -0,0 +1,35 @@ +{ + "questionFromAgent": "Qũēśţĩōń ƒŕōḿ àĝēńţ", + "rowActions": "Ŕōŵ àćţĩōńś", + "dismiss": "Ďĩśḿĩśś", + "dismissFailed": "Ćōũĺď ńōţ ďĩśḿĩśś ţĥĩś qũēśţĩōń. Ţŕŷ àĝàĩń.", + "snooze": "Śńōōźē", + "snoozeDuration1h": "1 ĥōũŕ", + "snoozeDuration4h": "4 ĥōũŕś", + "snoozeDuration24h": "24 ĥōũŕś", + "snoozeFailed": "Ćōũĺď ńōţ śńōōźē ţĥĩś qũēśţĩōń. Ţŕŷ àĝàĩń.", + "anotherCallerResolved": "Śōḿēōńē ēĺśē àĺŕēàďŷ àńśŵēŕēď \"{{question}}\".", + "anotherCallerRejected": "Śōḿēōńē ēĺśē àĺŕēàďŷ ŕēĵēćţēď \"{{question}}\".", + "bundleNoLongerActive": "\"{{question}}\" ĩś ńō ĺōńĝēŕ ŵàĩţĩńĝ ōń àń àńśŵēŕ.", + "errorTitle": "Ćōũĺď ńōţ ĺōàď ŷōũŕ ĩńƀōx", + "errorDescription": "Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ ĺōàďĩńĝ ţĥē qũēśţĩōńś ŵàĩţĩńĝ ōń ŷōũ.", + "retry": "Ŕēţŕŷ", + "emptyTitle": "Ńōţĥĩńĝ ĩś ŵàĩţĩńĝ ōń ŷōũ ĩń {{workspace}}.", + "emptyDescription": "Ţĥĩś ĺĩśţ ōńĺŷ ćōvēŕś qũēśţĩōńś ƀĺōćķĩńĝ àń àĝēńţ ĩń ţĥĩś ŵōŕķśƥàćē. Ĩţ ďōēś ńōţ ćōũńţ ƀũńďĺēś ĩń ōţĥēŕ ŵōŕķśƥàćēś, ƥēńďĩńĝ ƥēŕḿĩśśĩōń ŕēqũēśţś, ƥàŕēńţ-qũēśţĩōń ŕēćōŕďś, ōŕ ŷōũŕ ōŵń śńōōźēď ōŕ ďĩśḿĩśśēď ŕōŵś.", + "hiddenNotice_one": "{{count}} qũēśţĩōń ĩś ĥĩďďēń ƀŷ ŷōũŕ ōŵń ďĩśḿĩśś ōŕ śńōōźē.", + "hiddenNotice_other": "{{count}} qũēśţĩōńś àŕē ĥĩďďēń ƀŷ ŷōũŕ ōŵń ďĩśḿĩśś ōŕ śńōōźē.", + "showHidden": "Śĥōŵ ĥĩďďēń", + "hideHidden": "Ĥĩďē", + "hiddenLoading": "Ĺōàďĩńĝ ĥĩďďēń qũēśţĩōńś…", + "hiddenLoadFailed": "Ćōũĺď ńōţ ĺōàď ĥĩďďēń qũēśţĩōńś. Ţŕŷ àĝàĩń.", + "hiddenEmpty": "Ńōţĥĩńĝ ĩś ćũŕŕēńţĺŷ ĥĩďďēń.", + "snoozedUntil": "Śńōōźēď, ŕēàƥƥēàŕś {{time}}", + "dismissed": "Ďĩśḿĩśśēď", + "restore": "Ŕēśţōŕē", + "restoreFailed": "Ćōũĺď ńōţ ŕēśţōŕē ţĥĩś qũēśţĩōń. Ţŕŷ àĝàĩń.", + "truncatedNotice": "Śĥōŵĩńĝ ţĥē ƒĩŕśţ qũēśţĩōńś. Ḿōŕē àŕē ŵàĩţĩńĝ.", + "openTask": "Ōƥēń ţàśķ", + "questionCount_one": "{{count}} qũēśţĩōń ĩń ţĥĩś ƀũńďĺē", + "questionCount_other": "{{count}} qũēśţĩōńś ĩń ţĥĩś ƀũńďĺē", + "emptyTitleUnnamedWorkspace": "Ńōţĥĩńĝ ĩś ŵàĩţĩńĝ ōń ŷōũ ĩń ţĥĩś ŵōŕķśƥàćē." +} diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json index cd2d4654a7..b5a83daedb 100644 --- a/apps/web/src/locales/pseudo/sidebar.json +++ b/apps/web/src/locales/pseudo/sidebar.json @@ -50,6 +50,7 @@ "maxViews_one": "{{count}} ḿàx", "maxViews_other": "{{count}} ḿàx", "morePluginItems": "Ḿōŕē ƥĺũĝĩń ĩţēḿś", + "needsYouInbox": "Ńēēďś ŷōũ", "nestUnder": "Ńēśţ ũńďēŕ {{title}}", "newKanbanWorkspace": "Ńēŵ ķàńƀàń ŵōŕķśƥàćē", "newOfficeWorkspace": "Ńēŵ ōƒƒĩćē ŵōŕķśƥàćē", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index e7ab432269..102f951d35 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -93,6 +93,7 @@ "annotationCount_other": "{{count}} àńńōţàţĩōńś", "annotationPin": "Ƥĩń", "answer": "àńśŵēŕ", + "answeredOfTotal": "{{answeredCount}} ōƒ {{total}} àńśŵēŕēď", "anyUncommittedOrUnpushedChangesAre": "Àńŷ ũńćōḿḿĩţţēď ōŕ ũńƥũśĥēď ćĥàńĝēś àŕē ĺōśţ.", "anyUncommittedOrUnpushedChangesIn": "Àńŷ ũńćōḿḿĩţţēď ōŕ ũńƥũśĥēď ćĥàńĝēś ĩń ţĥē ŵōŕķśƥàćē ŵĩĺĺ ƀē ĺōśţ.", "anyoneWithThisLinkCanView": "Àńŷōńē ŵĩţĥ ţĥĩś ĺĩńķ ćàń vĩēŵ ţĥĩś ćōńvēŕśàţĩōń. Ŕēvĩēŵ ţĥē ƥŕēvĩēŵ ćàŕēƒũĺĺŷ ƀēƒōŕē ƥũƀĺĩśĥĩńĝ; ōńćē ƥũƀĺĩśĥēď, ţĥē śńàƥśĥōţ ĩś ũƥĺōàďēď ţō ĜĩţĤũƀ Ĝĩśţ ōń ŷōũŕ àććōũńţ.", diff --git a/apps/web/src/locales/pt-pt/needsYouInbox.json b/apps/web/src/locales/pt-pt/needsYouInbox.json new file mode 100644 index 0000000000..ef2077696c --- /dev/null +++ b/apps/web/src/locales/pt-pt/needsYouInbox.json @@ -0,0 +1,35 @@ +{ + "questionFromAgent": "Pergunta do agente", + "rowActions": "Ações da linha", + "dismiss": "Ignorar", + "dismissFailed": "Não foi possível ignorar esta pergunta. Tente novamente.", + "snooze": "Adiar", + "snoozeDuration1h": "1 hora", + "snoozeDuration4h": "4 horas", + "snoozeDuration24h": "24 horas", + "snoozeFailed": "Não foi possível adiar esta pergunta. Tente novamente.", + "anotherCallerResolved": "Outra pessoa já respondeu a \"{{question}}\".", + "anotherCallerRejected": "Outra pessoa já rejeitou \"{{question}}\".", + "bundleNoLongerActive": "\"{{question}}\" já não está à espera de uma resposta.", + "errorTitle": "Não foi possível carregar a sua caixa de entrada", + "errorDescription": "Ocorreu um erro ao carregar as perguntas que aguardam a sua resposta.", + "retry": "Repetir", + "emptyTitle": "Nada está à sua espera em {{workspace}}.", + "emptyDescription": "Esta lista abrange apenas perguntas que bloqueiam um agente neste espaço de trabalho. Não conta bundles noutros espaços de trabalho, pedidos de permissão pendentes, registos de perguntas-mãe, nem as suas próprias linhas adiadas ou ignoradas.", + "hiddenNotice_one": "{{count}} pergunta está oculta pelo seu próprio ignorar ou adiar.", + "hiddenNotice_other": "{{count}} perguntas estão ocultas pelo seu próprio ignorar ou adiar.", + "showHidden": "Mostrar ocultas", + "hideHidden": "Ocultar", + "hiddenLoading": "A carregar perguntas ocultas…", + "hiddenLoadFailed": "Não foi possível carregar as perguntas ocultas. Tente novamente.", + "hiddenEmpty": "Não há nada oculto atualmente.", + "snoozedUntil": "Adiada, reaparece {{time}}", + "dismissed": "Ignorada", + "restore": "Restaurar", + "restoreFailed": "Não foi possível restaurar esta pergunta. Tente novamente.", + "truncatedNotice": "A mostrar as primeiras perguntas. Há mais à espera.", + "openTask": "Abrir tarefa", + "questionCount_one": "{{count}} pergunta neste conjunto", + "questionCount_other": "{{count}} perguntas neste conjunto", + "emptyTitleUnnamedWorkspace": "Nada está à sua espera neste espaço de trabalho." +} diff --git a/apps/web/src/locales/pt-pt/sidebar.json b/apps/web/src/locales/pt-pt/sidebar.json index 2aaa266f1b..a92f8c663b 100644 --- a/apps/web/src/locales/pt-pt/sidebar.json +++ b/apps/web/src/locales/pt-pt/sidebar.json @@ -47,6 +47,7 @@ "logOut": "Terminar sessão", "maxViews_one": "máx. {{count}}", "maxViews_other": "máx. {{count}}", + "needsYouInbox": "Precisa de si", "nestUnder": "Aninhar em {{title}}", "newKanbanWorkspace": "Nova área de trabalho kanban", "newOfficeWorkspace": "Nova área de trabalho de escritório", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index c7f586ddd2..b0f488b63f 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -92,6 +92,7 @@ "annotationCount_other": "{{count}} anotações", "annotationPin": "Marcador", "answer": "resposta", + "answeredOfTotal": "{{answeredCount}} de {{total}} respondidas", "anyUncommittedOrUnpushedChangesAre": "Quaisquer alterações sem commit ou sem push são perdidas.", "anyUncommittedOrUnpushedChangesIn": "Quaisquer alterações sem commit ou sem push na área de trabalho serão perdidas.", "anyoneWithThisLinkCanView": "Qualquer pessoa com esta ligação pode ver esta conversa. Reveja a pré-visualização com atenção antes de publicar; depois de publicado, o retrato é carregado para o GitHub Gist na sua conta.", diff --git a/apps/web/src/locales/zh-cn/needsYouInbox.json b/apps/web/src/locales/zh-cn/needsYouInbox.json new file mode 100644 index 0000000000..57259d563d --- /dev/null +++ b/apps/web/src/locales/zh-cn/needsYouInbox.json @@ -0,0 +1,35 @@ +{ + "questionFromAgent": "代理提出的问题", + "rowActions": "行操作", + "dismiss": "忽略", + "dismissFailed": "无法忽略此问题,请重试。", + "snooze": "延后", + "snoozeDuration1h": "1 小时", + "snoozeDuration4h": "4 小时", + "snoozeDuration24h": "24 小时", + "snoozeFailed": "无法延后此问题,请重试。", + "anotherCallerResolved": "其他人已经回答了“{{question}}”。", + "anotherCallerRejected": "其他人已经拒绝了“{{question}}”。", + "bundleNoLongerActive": "“{{question}}”已不再等待回答。", + "errorTitle": "无法加载您的收件箱", + "errorDescription": "加载等待您处理的问题时出错。", + "retry": "重试", + "emptyTitle": "{{workspace}} 中没有任何事项在等待您处理。", + "emptyDescription": "此列表仅涵盖在此工作区中阻塞代理的问题,不计入其他工作区中的问题集、待处理的权限请求、父问题记录,或您自己已延后或已忽略的行。", + "hiddenNotice_one": "有 {{count}} 个问题因您自己的忽略或延后操作而被隐藏。", + "hiddenNotice_other": "有 {{count}} 个问题因您自己的忽略或延后操作而被隐藏。", + "showHidden": "显示隐藏项", + "hideHidden": "隐藏", + "hiddenLoading": "正在加载隐藏的问题…", + "hiddenLoadFailed": "无法加载隐藏的问题,请重试。", + "hiddenEmpty": "目前没有隐藏的内容。", + "snoozedUntil": "已延后,将于 {{time}} 重新出现", + "dismissed": "已忽略", + "restore": "恢复", + "restoreFailed": "无法恢复此问题,请重试。", + "truncatedNotice": "仅显示最先出现的问题,还有更多等待处理。", + "openTask": "打开任务", + "questionCount_one": "此问题集中有 {{count}} 个问题", + "questionCount_other": "此问题集中有 {{count}} 个问题", + "emptyTitleUnnamedWorkspace": "此工作区中没有任何事项在等待您处理。" +} diff --git a/apps/web/src/locales/zh-cn/sidebar.json b/apps/web/src/locales/zh-cn/sidebar.json index a640fff09a..85ab652706 100644 --- a/apps/web/src/locales/zh-cn/sidebar.json +++ b/apps/web/src/locales/zh-cn/sidebar.json @@ -49,6 +49,7 @@ "logOut": "退出登录", "maxViews_one": "最多 {{count}} 个", "maxViews_other": "最多 {{count}} 个", + "needsYouInbox": "需要你处理", "nestUnder": "嵌套到“{{title}}”下", "newKanbanWorkspace": "新建看板工作区", "newOfficeWorkspace": "新建办公室工作区", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index b77a1de766..cff8a8103a 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -92,6 +92,7 @@ "annotationCount_other": "{{count}} 个标注", "annotationPin": "图钉", "answer": "回答", + "answeredOfTotal": "已回答 {{answeredCount}} / {{total}}", "anyUncommittedOrUnpushedChangesAre": "任何未提交或未推送的更改都会丢失。", "anyUncommittedOrUnpushedChangesIn": "工作区中任何未提交或未推送的更改都会丢失。", "anyoneWithThisLinkCanView": "任何拥有此链接的人都可以查看此对话。发布前请仔细检查预览;发布后,快照会上传到你账户的 GitHub Gist。", diff --git a/apps/web/src/locales/zh-hk/needsYouInbox.json b/apps/web/src/locales/zh-hk/needsYouInbox.json new file mode 100644 index 0000000000..9300ee9385 --- /dev/null +++ b/apps/web/src/locales/zh-hk/needsYouInbox.json @@ -0,0 +1,35 @@ +{ + "questionFromAgent": "代理提出的問題", + "rowActions": "行操作", + "dismiss": "忽略", + "dismissFailed": "無法忽略此問題,請重試。", + "snooze": "延後", + "snoozeDuration1h": "1 小時", + "snoozeDuration4h": "4 小時", + "snoozeDuration24h": "24 小時", + "snoozeFailed": "無法延後此問題,請重試。", + "anotherCallerResolved": "其他人已經回答了「{{question}}」。", + "anotherCallerRejected": "其他人已經拒絕了「{{question}}」。", + "bundleNoLongerActive": "「{{question}}」已不再等待回答。", + "errorTitle": "無法載入您的收件箱", + "errorDescription": "載入等待您處理的問題時出錯。", + "retry": "重試", + "emptyTitle": "{{workspace}} 中沒有任何事項在等待您處理。", + "emptyDescription": "此列表僅涵蓋在此工作區中阻塞代理的問題,不計入其他工作區中的問題集、待處理的權限請求、父問題記錄,或您自己已延後或已忽略的行。", + "hiddenNotice_one": "有 {{count}} 個問題因您自己的忽略或延後操作而被隱藏。", + "hiddenNotice_other": "有 {{count}} 個問題因您自己的忽略或延後操作而被隱藏。", + "showHidden": "顯示隱藏項", + "hideHidden": "隱藏", + "hiddenLoading": "正在載入隱藏的問題…", + "hiddenLoadFailed": "無法載入隱藏的問題,請重試。", + "hiddenEmpty": "目前沒有隱藏的內容。", + "snoozedUntil": "已延後,將於 {{time}} 重新出現", + "dismissed": "已忽略", + "restore": "恢復", + "restoreFailed": "無法恢復此問題,請重試。", + "truncatedNotice": "僅顯示最先出現的問題,還有更多等待處理。", + "openTask": "開啟任務", + "questionCount_one": "此問題集中有 {{count}} 個問題", + "questionCount_other": "此問題集中有 {{count}} 個問題", + "emptyTitleUnnamedWorkspace": "此工作區中沒有任何事項在等待您處理。" +} diff --git a/apps/web/src/locales/zh-hk/sidebar.json b/apps/web/src/locales/zh-hk/sidebar.json index d54b788030..1e2aab8257 100644 --- a/apps/web/src/locales/zh-hk/sidebar.json +++ b/apps/web/src/locales/zh-hk/sidebar.json @@ -49,6 +49,7 @@ "logOut": "登出", "maxViews_one": "最多 {{count}} 個", "maxViews_other": "最多 {{count}} 個", + "needsYouInbox": "需要你處理", "nestUnder": "嵌套到「{{title}}」下", "newKanbanWorkspace": "新建看板工作區", "newOfficeWorkspace": "新建辦公室工作區", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index e5d2e474eb..09e0bd0286 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -92,6 +92,7 @@ "annotationCount_other": "{{count}} 個標註", "annotationPin": "圖釘", "answer": "回答", + "answeredOfTotal": "已回答 {{answeredCount}} / {{total}}", "anyUncommittedOrUnpushedChangesAre": "任何未提交或未推送的更改都會丟失。", "anyUncommittedOrUnpushedChangesIn": "工作區中任何未提交或未推送的更改都會丟失。", "anyoneWithThisLinkCanView": "任何擁有此連結的人都可以查看此對話。發佈前請仔細檢查預覽;發佈後,快照會上傳到你帳戶的 GitHub Gist。", diff --git a/apps/web/src/locales/zh-tw/needsYouInbox.json b/apps/web/src/locales/zh-tw/needsYouInbox.json new file mode 100644 index 0000000000..3b4bc00a9b --- /dev/null +++ b/apps/web/src/locales/zh-tw/needsYouInbox.json @@ -0,0 +1,35 @@ +{ + "questionFromAgent": "代理提出的問題", + "rowActions": "行操作", + "dismiss": "忽略", + "dismissFailed": "無法忽略此問題,請重試。", + "snooze": "延後", + "snoozeDuration1h": "1 小時", + "snoozeDuration4h": "4 小時", + "snoozeDuration24h": "24 小時", + "snoozeFailed": "無法延後此問題,請重試。", + "anotherCallerResolved": "其他人已經回答了「{{question}}」。", + "anotherCallerRejected": "其他人已經拒絕了「{{question}}」。", + "bundleNoLongerActive": "「{{question}}」已不再等待回答。", + "errorTitle": "無法載入您的收件箱", + "errorDescription": "載入等待您處理的問題時出錯。", + "retry": "重試", + "emptyTitle": "{{workspace}} 中沒有任何事項在等待您處理。", + "emptyDescription": "此清單僅涵蓋在此工作區中阻塞代理的問題,不計入其他工作區中的問題集、待處理的權限請求、父問題記錄,或您自己已延後或已忽略的行。", + "hiddenNotice_one": "有 {{count}} 個問題因您自己的忽略或延後操作而被隱藏。", + "hiddenNotice_other": "有 {{count}} 個問題因您自己的忽略或延後操作而被隱藏。", + "showHidden": "顯示隱藏項", + "hideHidden": "隱藏", + "hiddenLoading": "正在載入隱藏的問題…", + "hiddenLoadFailed": "無法載入隱藏的問題,請重試。", + "hiddenEmpty": "目前沒有隱藏的內容。", + "snoozedUntil": "已延後,將於 {{time}} 重新出現", + "dismissed": "已忽略", + "restore": "恢復", + "restoreFailed": "無法恢復此問題,請重試。", + "truncatedNotice": "僅顯示最先出現的問題,還有更多等待處理。", + "openTask": "開啟任務", + "questionCount_one": "此問題集中有 {{count}} 個問題", + "questionCount_other": "此問題集中有 {{count}} 個問題", + "emptyTitleUnnamedWorkspace": "此工作區中沒有任何事項在等待您處理。" +} diff --git a/apps/web/src/locales/zh-tw/sidebar.json b/apps/web/src/locales/zh-tw/sidebar.json index 8ce4a3e151..80e5cf75ac 100644 --- a/apps/web/src/locales/zh-tw/sidebar.json +++ b/apps/web/src/locales/zh-tw/sidebar.json @@ -49,6 +49,7 @@ "logOut": "登出", "maxViews_one": "最多 {{count}} 個", "maxViews_other": "最多 {{count}} 個", + "needsYouInbox": "需要你處理", "nestUnder": "巢狀到「{{title}}」下", "newKanbanWorkspace": "新建看板工作區", "newOfficeWorkspace": "新建辦公室工作區", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index cd38fef82b..6b92026087 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -92,6 +92,7 @@ "annotationCount_other": "{{count}} 個標註", "annotationPin": "圖釘", "answer": "回答", + "answeredOfTotal": "已回答 {{answeredCount}} / {{total}}", "anyUncommittedOrUnpushedChangesAre": "任何未提交或未推送的更改都會丟失。", "anyUncommittedOrUnpushedChangesIn": "工作區中任何未提交或未推送的更改都會丟失。", "anyoneWithThisLinkCanView": "任何擁有此連結的人都可以檢視此對話。釋出前請仔細檢查預覽;釋出後,快照會上傳到你帳戶的 GitHub Gist。", diff --git a/apps/web/src/needs-you-inbox-route.tsx b/apps/web/src/needs-you-inbox-route.tsx new file mode 100644 index 0000000000..f92fd352c4 --- /dev/null +++ b/apps/web/src/needs-you-inbox-route.tsx @@ -0,0 +1,17 @@ +import { lazy, Suspense } from "react"; +import { AuthRouteRedirect, RouteLoading } from "./spa-route-chrome"; + +const NeedsYouInboxPageClient = lazy(() => + import("@/app/needs-you-inbox/needs-you-inbox-page-client").then((mod) => ({ + default: mod.NeedsYouInboxPageClient, + })), +); + +export function NeedsYouInboxRoute({ enabled }: { enabled: boolean }) { + if (!enabled) return ; + return ( + }> + + + ); +} diff --git a/apps/web/src/spa-route-chrome.tsx b/apps/web/src/spa-route-chrome.tsx new file mode 100644 index 0000000000..c706ba079a --- /dev/null +++ b/apps/web/src/spa-route-chrome.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { useRouter } from "@/lib/routing/client-router"; + +// Takes the route name as a catalog KEY, not resolved copy: a `t()` at the call +// site would sit in a plain route-dispatch function with no hook of its own. +export function RouteLoading({ routeNameKey }: { routeNameKey: string }) { + const { t } = useTranslation(); + return ( +
+

+ {t("common:loadingRoute", { routeName: t(routeNameKey) })} +

+
+ ); +} + +export function AuthRouteRedirect() { + const router = useRouter(); + useEffect(() => { + router.replace("/"); + }, [router]); + return null; +} diff --git a/apps/web/src/spa-routes.tsx b/apps/web/src/spa-routes.tsx index d526f6e544..5fa0e12ed1 100644 --- a/apps/web/src/spa-routes.tsx +++ b/apps/web/src/spa-routes.tsx @@ -31,7 +31,7 @@ import { listWorkflows } from "@/lib/api/domains/kanban-api"; import { fetchUserSettings } from "@/lib/api/domains/settings-api"; import { listRepositories, listWorkspaces } from "@/lib/api/domains/workspace-api"; import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; -import { useRouter, usePathname, useSearchParams } from "@/lib/routing/client-router"; +import { usePathname, useSearchParams } from "@/lib/routing/client-router"; import { pluginRegistry, usePluginRegistry } from "@/lib/plugins/registry"; import { PluginErrorBoundary, @@ -59,11 +59,10 @@ import type { WorkflowStep, } from "@/lib/types/http"; import { TaskDetailRoute } from "./task-detail-route"; -import { useTranslation } from "react-i18next"; -import { CanvasHostRoute } from "@/components/settings/canvas-host-route"; -import { SettingsLayoutClient } from "@/components/settings/settings-layout-client"; -import { WorkspaceCanvasesPage } from "@/components/settings/workspace-canvases-page"; -import { WorkspaceSettingsShell } from "@/components/settings/workspaces/workspace-settings-shell"; +import { CanvasRoute } from "./canvas-route"; +import { NeedsYouInboxRoute } from "./needs-you-inbox-route"; +import { AuthRouteRedirect, RouteLoading } from "./spa-route-chrome"; +import { NEEDS_YOU_INBOX_HREF } from "@/lib/navigation/needs-you-inbox-destination"; import { generateUUID } from "@/lib/utils"; const OfficeRoutes = lazy(() => @@ -79,7 +78,6 @@ const ThreadsPageClient = lazy(() => default: mod.ThreadsPageClient, })), ); - const EMPTY_REPOSITORIES: Repository[] = []; type SpaRoute = @@ -110,6 +108,7 @@ type SpaRoute = | { kind: "runDetail"; automationId: string; tab?: string; runId?: string } | { kind: "canvas"; canvasId: string } | { kind: "canvasSettings"; workspaceId: string } + | { kind: "needsYouInbox" } | { kind: "settings"; pathname: string } | { kind: "office"; pathname: string } | { kind: "plugin"; path: string } @@ -124,6 +123,7 @@ type DataBackedSpaRoute = Exclude< | "kanban" | "canvas" | "canvasSettings" + | "needsYouInbox" | "settings" | "office" | "login" @@ -142,6 +142,7 @@ type RouteDataState = { type SpaRouteOptions = { canvasesEnabled?: boolean; + needsYouInboxEnabled?: boolean; }; export function resolveSpaRoute( @@ -155,12 +156,20 @@ export function resolveSpaRoute( resolveRunsRoute(normalized, searchParams) ?? resolveTopLevelRoute(normalized, searchParams) ?? resolveCanvasRoute(normalized, options.canvasesEnabled === true) ?? + resolveNeedsYouInboxRoute(normalized, options.needsYouInboxEnabled === true) ?? resolveNestedRoute(normalized) ?? resolvePluginRoute(normalized) ?? resolveKanbanRoute(searchParams) ); } +// The destination resolves only where the flag is enabled; disabled falls +// through to the kanban catch-all like an unrecognized path would. +function resolveNeedsYouInboxRoute(normalized: string, enabled: boolean): SpaRoute | null { + if (!enabled) return null; + return normalized === NEEDS_YOU_INBOX_HREF ? { kind: "needsYouInbox" } : null; +} + function resolveCanvasRoute(normalized: string, canvasesEnabled: boolean): SpaRoute | null { if (!canvasesEnabled) return null; const direct = normalized.match(/^\/canvases\/([^/]+)$/); @@ -292,7 +301,8 @@ export function SpaRoutes({ routeData }: { routeData?: BootRouteData }) { const pathname = usePathname(); const searchParams = useSearchParams(); const canvasesEnabled = useFeature("canvases"); - const route = resolveSpaRoute(pathname, searchParams, { canvasesEnabled }); + const needsYouInboxEnabled = useFeature("needsYouInbox"); + const route = resolveSpaRoute(pathname, searchParams, { canvasesEnabled, needsYouInboxEnabled }); // Reaching /login, /setup, or /invite here means the pre-auth gate in // main.tsx already decided the app shell should render (authenticated, or @@ -301,15 +311,10 @@ export function SpaRoutes({ routeData }: { routeData?: BootRouteData }) { return ; } if (route.kind === "canvas" || route.kind === "canvasSettings") { - if (!canvasesEnabled) return ; - if (route.kind === "canvas") return ; - return ( - - - - - - ); + return ; + } + if (route.kind === "needsYouInbox") { + return ; } if (route.kind === "plugin") { return ; @@ -354,27 +359,6 @@ export function SpaRoutes({ routeData }: { routeData?: BootRouteData }) { return ; } -// Takes the route name as a catalog KEY, not resolved copy: a `t()` at the call -// site would sit in a plain route-dispatch function with no hook of its own. -function RouteLoading({ routeNameKey }: { routeNameKey: string }) { - const { t } = useTranslation(); - return ( -
-

- {t("common:loadingRoute", { routeName: t(routeNameKey) })} -

-
- ); -} - -function AuthRouteRedirect() { - const router = useRouter(); - useEffect(() => { - router.replace("/"); - }, [router]); - return null; -} - /** * Renders the plugin-registered component for a `kind: "plugin"` route, * inside the normal app shell. `PluginPageFrame` gives it the same title-bar diff --git a/docs/plans/needs-you-inbox/plan.md b/docs/plans/needs-you-inbox/plan.md new file mode 100644 index 0000000000..987991a8a7 --- /dev/null +++ b/docs/plans/needs-you-inbox/plan.md @@ -0,0 +1,65 @@ +--- +created: 2026-09-12 +status: complete +requirements: + - REQ-UI-NEEDS-YOU-INBOX-001 +system_design: + - ../../specs/ui/system-design/needs-you-inbox-01.md + - ../../specs/ui/system-design/needs-you-inbox-02.md +legacy_specs: [] +--- + +# Implementation Plan: Needs-you Inbox + +## Overview + +Deliver a workspace-scoped sidebar destination that lists every clarification +bundle genuinely waiting on a human and lets the operator answer it without +leaving the page, per +[requirements](../../specs/ui/requirements/needs-you-inbox.md) and +[system design](../../specs/ui/system-design/needs-you-inbox-01.md) (part 2: +[control flow, failure, persistence, security](../../specs/ui/system-design/needs-you-inbox-02.md)). + +This feature was built through Kandev's own spec-driven-development workflow +(5 spec-review rounds, 4 build rounds, security/test/code review, an +operator-authorized direct fix round, then PR integration) with its running +record kept in the task's own plan store rather than as narrative here. This +document exists to satisfy the repository's PR documentation coverage gate +(`ci: enforce pull request documentation coverage`), which was not wired into +that workflow when this feature's spec canon was authored. + +## Scope + +### In scope + +- Sidebar nav entry and route for the Needs-you Inbox, gated on + `features.needsYouInbox`. +- A bounded, workspace-scoped list-and-count read path built on + `clarificationBundleQuery` / `currentTurnAuthority`. +- In-place answer via the shipped `ClarificationPanelSection`. +- Per-user dismiss/snooze/restore sidecar that never mutates the underlying + clarification record. +- i18n in all five shipped locales. + +### Out of scope + +Re-ask, the history bucket, the failed-task bucket, cross-workspace rows, and +any change to the current-turn predicate's meaning. See the requirements +document's own "Out of scope" section for the full list. + +## Work orders + +- [x] [Task 01: Needs-you Inbox delivery](task-01-needs-you-inbox-delivery.md) + +Single work order; no dependents. + +## Verification results + +See the work order for the full verification record. Summary: backend and +frontend build/lint/typecheck clean; targeted and full package test suites +pass (pre-existing, unrelated failures proven against merge-base in a scratch +worktree); desktop and mobile E2E specs for the answer-in-place flow pass; +`pnpm run i18n:check` and `pnpm run i18n:ratchet` clean; `python3 +scripts/lint-spec-files.py --all` passes. + +PR: https://github.com/kdlbs/kandev/pull/3641 diff --git a/docs/plans/needs-you-inbox/task-01-needs-you-inbox-delivery.md b/docs/plans/needs-you-inbox/task-01-needs-you-inbox-delivery.md new file mode 100644 index 0000000000..0eb29c09ba --- /dev/null +++ b/docs/plans/needs-you-inbox/task-01-needs-you-inbox-delivery.md @@ -0,0 +1,125 @@ +--- +id: "01-needs-you-inbox-delivery" +title: "Needs-you Inbox delivery" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-UI-NEEDS-YOU-INBOX-001 +acceptance_criteria: + - AC-UI-NEEDS-YOU-INBOX-001.1 + - AC-UI-NEEDS-YOU-INBOX-001.2 + - AC-UI-NEEDS-YOU-INBOX-001.3 + - AC-UI-NEEDS-YOU-INBOX-001.4 + - AC-UI-NEEDS-YOU-INBOX-001.5 + - AC-UI-NEEDS-YOU-INBOX-001.6 + - AC-UI-NEEDS-YOU-INBOX-001.7 + - AC-UI-NEEDS-YOU-INBOX-001.8 + - AC-UI-NEEDS-YOU-INBOX-001.9 + - AC-UI-NEEDS-YOU-INBOX-001.10 + - AC-UI-NEEDS-YOU-INBOX-001.11 + - AC-UI-NEEDS-YOU-INBOX-001.12 + - AC-UI-NEEDS-YOU-INBOX-001.13 + - AC-UI-NEEDS-YOU-INBOX-001.14 + - AC-UI-NEEDS-YOU-INBOX-001.15 + - AC-UI-NEEDS-YOU-INBOX-001.16 + - AC-UI-NEEDS-YOU-INBOX-001.17 + - AC-UI-NEEDS-YOU-INBOX-001.18 + - AC-UI-NEEDS-YOU-INBOX-001.19 + - AC-UI-NEEDS-YOU-INBOX-001.20 + - AC-UI-NEEDS-YOU-INBOX-001.21 + - AC-UI-NEEDS-YOU-INBOX-001.22 + - AC-UI-NEEDS-YOU-INBOX-001.23 + - AC-UI-NEEDS-YOU-INBOX-001.24 + - AC-UI-NEEDS-YOU-INBOX-001.25 + - AC-UI-NEEDS-YOU-INBOX-001.26 + - AC-UI-NEEDS-YOU-INBOX-001.27 + - AC-UI-NEEDS-YOU-INBOX-001.28 + - AC-UI-NEEDS-YOU-INBOX-001.29 + - AC-UI-NEEDS-YOU-INBOX-001.30 + - AC-UI-NEEDS-YOU-INBOX-001.31 + - AC-UI-NEEDS-YOU-INBOX-001.32 + - AC-UI-NEEDS-YOU-INBOX-001.33 + - AC-UI-NEEDS-YOU-INBOX-001.34 + - AC-UI-NEEDS-YOU-INBOX-001.35 + - AC-UI-NEEDS-YOU-INBOX-001.36 + - AC-UI-NEEDS-YOU-INBOX-001.37 + - AC-UI-NEEDS-YOU-INBOX-001.38 + - AC-UI-NEEDS-YOU-INBOX-001.39 + - AC-UI-NEEDS-YOU-INBOX-001.40 + - AC-UI-NEEDS-YOU-INBOX-001.41 +system_design: + - ../../specs/ui/system-design/needs-you-inbox-01.md + - ../../specs/ui/system-design/needs-you-inbox-02.md +--- + +# Task 01: Needs-you Inbox Delivery + +## Summary + +Add the workspace-scoped Needs-you Inbox: sidebar entry and route, a bounded +list-and-count read path built on `clarificationBundleQuery` / +`currentTurnAuthority`, in-place answering through the shipped +`ClarificationPanelSection`, and a per-user dismiss/snooze/restore sidecar. +Gate the feature (routes, nav entry, and boot-hydration producer) behind +`features.needsYouInbox` / `KANDEV_FEATURES_NEEDS_YOU_INBOX`. + +## In scope + +- Backend: `internal/clarification/inbox_handlers.go` (4 endpoints under + `/api/v1/clarification-inbox`, flag-gated route registration), the sidecar + schema/repository for dismiss/snooze/restore, and a boot-hydration producer + that runs the identical bounded read as the live endpoint. +- Frontend: `needsYouInbox` store slice, sidebar nav entry, `/needs-you-inbox` + route, row/empty/error/hidden-panel components, an additive `onOutcome` + callback on `ClarificationPanelSection`, and i18n in all five locales. +- Desktop and mobile E2E coverage for the list -> expand -> answer -> resume + flow (AC .30). + +## Out of scope + +Re-ask, the history bucket, the failed bucket, cross-workspace rows, and any +change to the current-turn predicate's meaning. + +## Acceptance + +All 41 acceptance criteria under `REQ-UI-NEEDS-YOU-INBOX-001`; see +[requirements](../../specs/ui/requirements/needs-you-inbox.md) for the full +text. Notable ones with dedicated regression coverage: + +- AC .10: row ordering (`question_index` ascending, ties broken by + `question_id` ascending) — `TestHttpListInbox_OrderInboxMessages_NegativeAndAbsentIndexTieBreakByQuestionID`. + covers negative and absent index normalization. +- AC .20/.21: `200` + zero rows + `next_cursor` present renders the error + state, never "caught up" — `resolveViewMode`. +- AC .30: list an answerable bundle, expand, answer, row removed, task resumed + without navigation — `apps/web/e2e/tests/chat/needs-you-inbox-answer.spec.ts` + and its mobile counterpart. +- AC .34/.40/.41: boot-hydration producer shares the live endpoint's bound + (limit 50, same sidecar exclusion) — `TestBootNeedsYouInbox_*` / + `TestInboxBootSummary_*`. +- AC .37: hidden-panel disclosed total reconciles with the enumeration read, + not a stale prop. + +## Verification + +- `go build ./...`, `go vet ./...` clean (apps/backend). +- `go test ./internal/clarification/... ./internal/backendapp/... + ./internal/mcp/handlers/...` — all packages `ok`. +- `make -C apps/backend lint` — 0 issues. +- `pnpm run typecheck`, `pnpm exec eslint` (touched files) — clean. +- `pnpm exec vitest run` (targeted `needs-you-inbox`, `clarification-inbox`) — + all passing, including boundary and workspace-switch regression tests. +- `pnpm run i18n:check` / `pnpm run i18n:ratchet` — five locales complete, no + em-dash violations, guard allowlist intact. +- `pnpm e2e:run` — desktop `needs-you-inbox-answer.spec.ts` and mobile + `mobile-needs-you-inbox-answer.spec.ts` both pass with a full rebuild. +- `python3 scripts/lint-spec-files.py --all` — all specification files + passed. +- Full-suite pre-existing failures (worktree/launcher/config/task-service, + macOS TMPDIR-symlink signature) proven pre-existing against + `git merge-base HEAD origin/main` in a scratch worktree; unrelated to this + branch's diff. + +PR: https://github.com/kdlbs/kandev/pull/3641 diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 0088720899..9f6c8cb450 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -508,6 +508,7 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym | `features.multiTenancy` | `KANDEV_FEATURES_MULTI_TENANCY` | off | Experimental organizations above authenticated users. Requires `features.auth`; startup is refused otherwise. | | `features.dynamicAgentRouting` | `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` | off | Experimental dynamic profiles with ordered provider-error fallback. | | `features.canvases` | `KANDEV_FEATURES_CANVASES` | off | Experimental agent-authored isolated web-app canvases for tasks and workspaces. High risk. | +| `features.needsYouInbox` | `KANDEV_FEATURES_NEEDS_YOU_INBOX` | off | Needs-you Inbox for clarification questions across tasks in the active workspace. | | `features.officeSessionIdentity` | `KANDEV_FEATURES_OFFICE_SESSION_IDENTITY` | on | Office participant sessions: each participant agent gets its own session per task. The live `(task_id, agent_profile_id)` pair is guarded in-transaction, not by a table-level index. Pre-existing duplicate rows are retained and resolved by selection. Two Kandev processes must not write the same SQLite file. | | `features.agentSurvival` | `KANDEV_FEATURES_AGENT_SURVIVAL` | off | Experimental. Lets the `agentctl` control server and its running agents outlive a backend restart or upgrade, for the `local_pc` and worktree executors only. The restarted backend takes the surviving server over and reconnects to the sessions still running on it. **Unavailable on Windows**, where surviving the backend means giving up the Job Object that guarantees agent processes are cleaned up. | | `debug.devMode` | `KANDEV_DEBUG_DEV_MODE` | off | High-risk diagnostic endpoints and ACP frame logging. | @@ -532,6 +533,11 @@ The database can contain canvas migrations, but Kandev does not read or change canvas data while the flag is off. See [Agent-authored Canvases](canvases.md) for the experimental user workflow. +`features.needsYouInbox` is off in the production profile and on in the +development and E2E profiles. Restart Kandev after changing it because the +backend registers the Inbox routes at startup. With the flag off, the Inbox +routes, boot count, sidebar entry, and page are unavailable. + For a risky release feature that has not graduated, keep the flag off in the shipped profiles, enable it only on a selected install through an admin override or explicit environment, restart, and test it there. Promote the `prod` profile @@ -633,6 +639,7 @@ no public YAML key: - Runtime flags and diagnostics: `KANDEV_FEATURES_OFFICE`, `KANDEV_FEATURES_AUTH`, `KANDEV_FEATURES_MULTI_TENANCY`, + `KANDEV_FEATURES_NEEDS_YOU_INBOX`, `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF`, `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING`, `KANDEV_DEBUG_AGENT_MESSAGES`, `KANDEV_DEBUG_ACP_MAX_FILES`, diff --git a/docs/specs/ui/assets/needs-you-inbox-mockup-r4.html b/docs/specs/ui/assets/needs-you-inbox-mockup-r4.html new file mode 100644 index 0000000000..e0bf079453 --- /dev/null +++ b/docs/specs/ui/assets/needs-you-inbox-mockup-r4.html @@ -0,0 +1,688 @@ + + + + + +Kandev Inbox — mockup r4, aligned to the shipped shell + + +
+
+

Inbox — drawn on Kandev's own shell

+

Revision 4. The previous revisions got the colour tokens exactly right and the chrome + around them wrong: the sidebar, the tabs, the rows and the badges were invented. This pass + redraws every frame from the shipped components, and applies your two answers — the badge + and the list both read the clarification-bundle path, and permission rows + appear in the history tab, labelled.

+

Only colour Kandev already paints appears below. Everything in grey is commentary.

+
+ repo 76ab36270 + data 2026-09-12 01:06 UTC, mode=ro + reference your screenshot, 01:11 UTC + scope workspace Kandev +
+
+ +
+
01

The destination, on the real sidebar

+

The nav row exists already — app-sidebar-primary-nav.tsx:41-49 renders + IconInbox with badge={inboxCount}, wrapped in {inOffice && …}. + Kanban never sees it. Three details the earlier revisions drew wrong, each fixed here:

+

The active row is a 3px left bar, not a filled block. + app-sidebar-constants.ts says why, in the file: "a saturated fill reads as garish + against the brand theme." The badge is gated on badge > 0 — so with + Kandev at zero answerable questions, no badge renders at all, and the "0" pills I drew beside other + workspaces cannot exist in that component. And the sections carry their count only while shut + (collapsedSummary) — that is the Automations 4 in your screenshot.

+
+
+
+
+ Kandev/ + + +
+
Inbox + +
+
+
+ +
+
+
+
Needs you
+
Asked, unanswered 115
+
Failed 17
+
+
+
+
+

Nothing is waiting on you in Kandev.

+

115 questions were asked here and never answered. The agents that asked have moved on, so they sit read-only in Asked, unanswered.

+
+
+
+
+
+

This is the honest first frame: measured at 01:06 UTC, the Kandev workspace has + 0 answerable questions, so the Inbox opens empty and the sidebar badge does not render. + The row design is in frame 02, drawn on the one real answerable question in the instance.

+
+
+
02

The row, and answering without leaving

+

Rebuilt on the shipped row: inbox-item-row.tsx — flat rows divided inside one + bordered container, a 32px bg-muted icon chip, title, description, and timeAgo as + plain muted text. Actions are Button size="sm" on the right. Gone: the per-row card border, the + indigo ring, the red age chip, and the → on the link.

+

The icon carries the type, in the hue the board already uses. + lib/ui/state-icons.ts assigns yellow + IconMessageQuestion to + WAITING_FOR_INPUT and amber + IconShieldQuestion to a pending permission — + the same marks on the kanban card. My earlier draft spent amber on a tab count, which already means + "over WIP limit" in StepCount, and red on an age chip, where red already means failed.

+
+
+
Kandev/ +
+
Inbox
+
+
+
+
Needs you 1
+
Asked, unanswered
+
+
+
+
+
+

Pick the reply archetype before the #39 hand-off draft can be written

+

[FE] #39 — Cross-surface MA+WA pilot hand-off

+

2 questions, asked 8 days ago

+
+ +
+
+
The draft needs one decision before I can write it: which archetype the reply follows. +I have the thread, the pilot scope and the two prior hand-offs. What I do not have is your call on tone.
+

Which reply archetype should the hand-off follow?

+
+
Status hand-off: what shipped, what is open, who owns each thread
+
Decision request: lead with the two open calls, evidence underneath
+
Narrative: walk the pilot chronologically, decisions in place
+
+
+ + + + Resumes session e154c20c in place +
+
+
+
+
+

Answer and resume is the row that earns the feature. It mounts + ClarificationPanelSection unchanged — that component takes + {pending, messages, onResolved} and reads no task-chat context, so the Inbox can render the + shipped answer UI rather than a copy of it. Display-only is the version that fails: you would still be + jumping around, just from a tidier list.

+

This row lives in Kegmil V2, not Kandev — it is the only answerable question in + the whole instance, asked 2026-09-03 04:54 UTC. It is drawn here so the row design is visible on real + content; on the Kandev board this tab is empty today.

+
+
+
03

Asked, unanswered — now with permissions, labelled

+

Your answer applied: permission requests appear here, read-only, with a label that says + what they are. On the Kandev board that is 109 clarification rows across 25 live tasks + plus 6 permission rows carrying no terminal status — 115 in total. Every one sits on a + superseded turn, so nothing here is answerable; the tab has no write path at all.

+

85 of the 109 are parked in a step where nothing runs — 70 in + PR Review, 15 in PR Fixup, whose own prompt says "NOTHING RUNS HERE." An + invisible question on a parked card with no agent to re-raise it. Grouping by task rather than by row + is what keeps one stuck task from filling the screen.

+
+
+
Kandev/ +
+
Inbox
+
+
+
+
Needs you
+
Asked, unanswered 115
+
Failed 17
+
+
+
+
Wakeup-gate transient failure retries  9 questions, parked in PR Review, oldest 16 days ago
+
+
+

Should the retry budget be per-run or per-workspace?

+

asked 16 days ago, superseded by a later turn

+ +
+
+
+

Run git push --no-verify to bypass the secret-scanner hook

+

asked 16 days ago, and the push has since landed

+ Permission + +
+
Chunk run-id batches in workspace sweep  4 questions, parked in PR Fixup, oldest 10 days ago
+
+
+

Chunk size 100 or 500? The sweep holds the lock for the whole batch.

+

asked 10 days ago, superseded by a later turn

+ +
+
+
+
+

Two things this tab must never do. It must not feed the badge — none of it is + answerable, and a badge you cannot drive to zero is a badge you stop reading. And it must not offer + Re-ask, which you cut: five of the nine push-blocked questions measured on 09-11 had already been + settled by events, so re-asking restarts an agent on a question that answered itself. The honest + consequence is that these 115 have no exit in v1. They had none before either — the Inbox makes them + visible, it does not clear them.

+
+ +
+
04

Failed — present, and deliberately off the badge

+

Correcting the reason I gave you. Revision 2 kept Failed off the badge + because "16 failed tasks, newest 6 days old — nothing urgent." That reason has expired: measured now, + 17 live Kandev tasks have a failed primary session, the newest 9.6 hours + ago, and 3 inside 24 hours. The exclusion still stands, on a reason that does not expire: + a failed task is triage, not a decision you owe. The badge counts questions only.

+
+
+
Kandev/ +
+
Inbox
+
+
+
+
Needs you
Asked, unanswered 115
+
Failed 17
+
+
+

Lifecycle sweep: GetTask failure path

+

Orchestrator

+

failed 10 hours ago

+
+
+

Guard MCP_TOOL_TIMEOUT override

+

Agent runtime & ACP

+

failed 19 hours ago

+
+
+

Restore legacy dynamic routes sweep

+

Dynamic Agent

+

failed 23 hours ago

+
+
+
+
+
+
+
05

The empty state is the product

+

Measured 25 hours apart and again this morning: the Kandev workspace has 0 + answerable questions. This is not an edge case to sketch and move past — on your board it is the screen + you open most days. It gets the most design attention of anything in this deck.

+

One rule governs all three variants: an empty inbox must name what it is not + counting. "You're all caught up" while 115 questions sit invisible is how this feature loses your + trust in week one, and it is the same failure as the 351-row version — just quieter.

+
+
+
Kandev/
Inbox
+
+
+
Needs you
Asked, unanswered 115
Failed 17
+
+

Nothing is waiting on you in Kandev.

+

115 questions were asked here and never answered, and 17 tasks failed. Neither needs a decision from you, so both sit in the other two tabs.

+
+
+ +
+
+

Quiet here, busy next door. + The count comes from inboxCountByWorkspaceId, already a per-workspace map — a number, + never a row, so nothing crosses a permission boundary.

+
+
+

Nothing is waiting on you in Kandev.

+

1 question is waiting in Kegmil V2.

+

+
+
+
+
+

Empty because you filtered it. + A different sentence, because it is a different situation and the fix is one click.

+
+
+

No questions match "rebase".

+

115 questions are in this tab.

+

+
+
+
+
+
+
+

Copy notes, since the words are the design here. No apology and no exclamation mark — + an empty queue is a normal state, not a celebration. The number is the subject of the second line, not a + footnote. And the verb on every button says exactly what happens: Switch to Kegmil V2, not + View.

+
+ +
+
06

An entry gate, not a filter over a noisy state

+

The issue proposed showing every task that is not Running. Same database, same instant, + Kandev workspace, 2026-09-12 01:06 UTC:

+
+ + + + + + +
What the view would showRowsOf which you can act on
"Not Running" — sessions in WAITING_FOR_INPUT on live tasks2750
Every pending question row, any turn1090
Answerable now — the shipped clarification-bundle path00
Same, measured across the whole instance11
+

WAITING_FOR_INPUT on this board mostly means + "the agent finished its turn and is idle", not "a human is blocking it". A 275-row inbox at a 100% + false-positive rate trains you to stop opening it — the same failure as an empty one that lies. + Paperclip's rule is the fix, and it is an entry rule rather than a filter: a row cannot enter unless + it carries something concrete you can do. Rows 1 and 2 above are exactly the rows that rule + excludes.

+
+
+
+
07

Which tab control, and why it is not the default one

+

A design call I can make from the shipped component rather than from taste. + packages/ui/src/tabs.tsx has two variants. The Office inbox uses default. On the + dark theme that variant's selected state resolves to the track colour it sits on: + bg-muted is #2a2a2a, and the active trigger paints + dark:bg-input/30 over it — --input is also #2a2a2a, so 30% of + #2a2a2a on #2a2a2a is #2a2a2a. The active border is + --input, the same colour again. Selection is carried by the label's brightness alone.

+
+
+

default — the shipped Office inbox

+
+
+ Needs you + Asked, unanswered + Failed +
+

A secondary count badge here would be + 17 + on a #2a2a2a track — invisible.

+
+
+

line — recommended

+
+
+
Needs you
+
Asked, unanswered 115
+
Failed 17
+
+

Transparent track, a 2px + after:bg-foreground underline. Selection reads at a + glance, and the counts sit on --background where a + secondary badge has contrast.

+
+
+
+

Both variants ship today, so this costs nothing to choose — + <TabsList variant="line">. It also settles a smaller question: counts belong in the tab, + not in the page header, because the header is where a title would go and this page has no title. The + app's top bar owns that, as your screenshot shows with Home.

+
+
+ +
+
08

Beside Threads, on one shared signal

+

Your answer was "beside, shared selector", and the code makes that literal. + threads-view-filter-registry.ts already ships a threadStatus dimension whose + default value is needs_action and a pendingAction dimension defaulting to + clarification. What is missing is that the one built-in view ships filters: [] + (thread-view-builtins.ts), so nobody ever sees the preset — and there is no badge and no + destination.

+

The Inbox is a second consumer of resolveThreadStatus, not a second + classifier. That is the whole reason the two surfaces cannot drift: one function decides what + needs action, two surfaces render it.

+
+
+
+
+

Inbox

+

Decisions you owe, one row each, answerable in place. Badge = this count.

+
+
same
source
+
+

Threads

+

Every conversation, filterable. A new built-in view carries threadStatus is needs_action.

+
+
+
+

both read

+

resolveThreadStatus() → clarification-bundle path

+
+
+

Your read-path answer, applied. The badge and the Needs you list both + come from the clarification-bundle path (clarification_bundle_query.go:119), which excludes + lifecycle_only turns. The other shipped resolver, + pending_interactions.go:69, does not exclude them, and the two disagree today on exactly + the row frame 02 draws — 1 bundle against 0. Binding the badge to one and the list to the other would + give you a badge reading 1 over an empty page. One path, both consumers.

+
+
+
+
09

What you reuse, what you build

+

Correcting myself again, and in the expensive direction. I told you this + was "a kanban route and a different count source, not a new component." The Office inbox shares the nav + slot, not the feature: its InboxItem has eight types — approval, + budget_alert, agent_run_failed, task_review, + provider_degraded and others — and none of them is a clarification. Its rows + come from /api/v1/office/workspaces/{id}/inbox. A kanban Inbox needs its own row model, read + path and count producer.

+
+ + + + + + + + + + +
PieceStatusWhere
Sidebar nav row + badgereuse unchangedAppSidebarNavItem — drop the inOffice gate
Answer UIreuse unchangedClarificationPanelSection — props only, no task-chat context
Page shell, list container, empty blockcopy the patterninbox-page-client.tsx — p-6 space-y-4, bordered divided list
Tabsreuse, variant="line"packages/ui/src/tabs.tsx
Row icons and huesreuse the board's vocabularylib/ui/state-icons.ts
needs-action classificationreuseresolveThreadStatus / lib/utils/needs-action.ts
Row model + read pathbuildnew — the Office InboxItem has no clarification type
Count producerbuild, carefullyinboxCountByWorkspaceId has one writer today; a second on the same key collides
+
+
+ +
+
10

The rules this mockup encodes

+

Lift these into the card as acceptance criteria; each one is a decision the drawings + already make.

+
+
    +
  1. Entry gate, not a state filter. A row enters only if it carries an action you can take now. + "Not Running" is 275 rows at a 100% false-positive rate; the gate is 0.
  2. +
  3. The badge counts decisions you owe, and nothing else. Never the queue size, never the + history tab, never failures. It must be drivable to zero, and badge > 0 already means a + zero renders nothing.
  4. +
  5. One read path. Badge and list both read the clarification-bundle path. Two shipped + resolvers disagree today; pick one for both consumers.
  6. +
  7. Answering happens in the row. A display-only inbox still makes you jump around, from a + tidier list. Answer and resume posts the answer and restarts the session in place.
  8. +
  9. An empty inbox names what it is not counting. Never "all caught up" alone.
  10. +
  11. History is read-only. No Re-ask: five of nine push-blocked questions had already settled + themselves. Permissions appear here, labelled, and never in the badge.
  12. +
  13. Dismiss and snooze are a per-user sidecar and never mutate the queue — Paperclip's rule, + adopted verbatim.
  14. +
  15. An agent may only name itself as the owner of a row it creates. 430 of 430 questions in the + last ten days were agent-authored and Kandev has no equivalent gate. Paperclip enforces this one + against its own agents — I had it returned to me as a 403 while trying to hand you a row.
  16. +
  17. Workspace-scoped. Other workspaces contribute a count in the empty state and the switcher, + never a row.
  18. +
  19. Reuse the board's colour vocabulary. Yellow is a question, amber is a permission, red is a + failure, emerald is running, violet is background work. The Inbox introduces no new hue.
  20. +
+
+
+ +
+
—

What is still unverified

+

Three things, so nobody reads this deck as more settled than it is. The frames are + drawn from source, not from the running build — I had no browser to walk :8817 with, + so the fidelity claims rest on the component files at 76ab36270 and on your screenshot, not on + a live comparison. Button and input metrics are approximated to within a pixel or two; + the sidebar, tabs, badge, row and list metrics are taken from the class strings verbatim. + And the numbers are 01:06 UTC, reused rather than re-measured — thirteen minutes on a + population that turns over in days would look like verification and would not be.

+
+ + +
diff --git a/docs/specs/ui/requirements/needs-you-inbox.md b/docs/specs/ui/requirements/needs-you-inbox.md new file mode 100644 index 0000000000..d850e9bf38 --- /dev/null +++ b/docs/specs/ui/requirements/needs-you-inbox.md @@ -0,0 +1,337 @@ +--- +status: draft +system: ui +created: 2026-09-12 +owners: + - nova28 +--- + +# Needs-you Inbox Requirements + +## Overview + +A Kandev agent that calls `ask_user_question_kandev` blocks until a human +answers, and that question is answerable in exactly one place: a panel over the +chat input of the owning task's session. Nothing in the kanban workspace says a +question exists, so a blocked agent is discoverable only by opening the task +that is already blocked. Measured with the shipped bundle read path on +2026-09-12 at 01:06Z, the Kandev workspace held zero answerable bundles and the +whole instance held one, created 2026-09-03T04:54:02Z and unanswered for 8 days +and 20 hours. That is the failure this capability removes. + +The Needs-you Inbox is a workspace-scoped destination that lists every +clarification bundle genuinely waiting on a human and lets the operator answer +it without leaving the page. UI owns this contract because the observable +outcome is a navigation destination, a count, and an answer affordance. The +durable clarification record, its resolution semantics, and the bundle +visibility rules remain owned by the integrations system. Because the +population is small on a healthy board, the empty state is the primary screen +rather than an edge case, and must always name what it is not counting. + +## Terminology + +- **Bundle:** Every question an agent asked in one `ask_user_question_kandev` + call, resolved as a unit and identified by a `pending_id`. +- **Answerable bundle:** A bundle a caller can resolve right now, as the + clarification contract already defines it: at least one question still open, + the bundle's turn is its session's current turn, the session is not terminal, + every question resolves an identifier, and it is not a parent-question record. +- **Actionable count:** The number of rows the operator can act on now, never a + queue size or a lifetime total. +- **Sidecar:** Per-user dismiss and snooze state stored beside the + clarification record, never inside it. + +## Requirements + +### REQ-UI-NEEDS-YOU-INBOX-001: Needs-you Inbox + +**Intent:** Give an operator one workspace-scoped place that names every +question blocking an agent, and lets them clear it to zero without navigating +to each task. + +**User story:** As an operator, I want one list of the questions waiting on me, +so that a blocked agent is never invisible and I can unblock it in place. + +#### Acceptance criteria + +**Destination and gating** + +- **AC-UI-NEEDS-YOU-INBOX-001.1:** Where the Needs-you Inbox feature flag is + enabled, the workspace sidebar shall present a Needs-you entry that navigates + to the Inbox destination for the active workspace. +- **AC-UI-NEEDS-YOU-INBOX-001.2:** Where the feature flag is disabled, the + sidebar shall present no Needs-you entry and the Inbox destination shall not + render Inbox content. +- **AC-UI-NEEDS-YOU-INBOX-001.3:** The Needs-you entry's presence shall not + depend on Office mode. When Office mode is off and the feature flag is on, + the entry shall be present; enabling or disabling Office mode alone shall not + change whether the entry is present. +- **AC-UI-NEEDS-YOU-INBOX-001.4:** The feature flag shall default to disabled + in the production profile and enabled in the development and end-to-end + profiles. + +**Row set** + +- **AC-UI-NEEDS-YOU-INBOX-001.5:** The Inbox shall list exactly the answerable + bundles whose owning task belongs to the active workspace, and no others. + Switching the active workspace shall replace the list with that workspace's + bundles. +- **AC-UI-NEEDS-YOU-INBOX-001.6:** A row shall exist only when its bundle is + answerable. The Inbox shall not derive a row from session state. Given a + fixture of sessions in a waiting state that hold no answerable bundle, the + Inbox shall list zero rows. +- **AC-UI-NEEDS-YOU-INBOX-001.7:** Given a bundle whose newest turns are + lifecycle-only, the Inbox shall still list that bundle, because the current + turn is resolved by the shared turn-authority rule that excludes + lifecycle-only turns. +- **AC-UI-NEEDS-YOU-INBOX-001.8:** The Inbox shall not list a bundle that is a + parent-question record, that has no resolvable question identifier, whose + turn is superseded, or whose session is terminal. +- **AC-UI-NEEDS-YOU-INBOX-001.31:** If a read names a workspace outside the + caller's visible workspace set, then the server shall reject the read with a + non-disclosing not-found outcome rather than an empty success, and the Inbox + shall render the error state of AC-UI-NEEDS-YOU-INBOX-001.21 rather than the + empty state of AC-UI-NEEDS-YOU-INBOX-001.20, so a permission boundary is + never reported to the operator as "nothing is waiting". Where the scope an + endpoint requires is one that a caller reaching the workspace can lack, the + server shall reject with a forbidden outcome and the Inbox shall render the + same error state; where the required scope is the reach scope itself, the + not-found outcome above is the only reachable rejection and the forbidden + outcome shall be unreachable by construction rather than unimplemented. The + design shall name, per endpoint, which of the two applies. Workspace + scoping shall be enforced by the server from the request identity; a + client-supplied workspace identifier shall not by itself grant access. Where + authentication is disabled, the single-user identity shall be treated as + having access to every workspace, and this AC shall be satisfied by the + identity resolution rather than bypassed. + +**Ordering and boundaries** + +- **AC-UI-NEEDS-YOU-INBOX-001.9:** Rows shall be ordered by bundle creation + time ascending, and ties shall be broken by `pending_id` ascending. This + order shall be total and reproducible: the same set of bundles shall always + produce the same row order. The oldest waiting question shall appear first. +- **AC-UI-NEEDS-YOU-INBOX-001.10:** Questions inside an expanded row shall + appear in the bundle's canonical question order, the same order the task + session's own clarification panel uses. That order shall be question index + ascending, ties broken by question identifier ascending. A question whose + index is absent, negative, or not a number shall sort as index zero and then + by identifier, so the order is total and reproducible for any bundle. +- **AC-UI-NEEDS-YOU-INBOX-001.11:** A single read shall return at most a + bounded page, defaulting to 50 rows and capped at 200. When more listable + bundles exist than the page returns, the Inbox shall indicate that the list + is truncated rather than silently showing a partial list as complete. A + bundle hidden by the operator's own dismiss or snooze shall not consume a + page slot: given a fixture of 60 answerable bundles of which 20 are hidden + and a limit of 50, the first page shall return 40 rows and shall not report + truncation. +- **AC-UI-NEEDS-YOU-INBOX-001.38:** A read shall reject a missing or empty + workspace identifier, a non-numeric or non-positive limit, and a cursor it + cannot decode, each as a client error that lists no rows and renders the + error state; it shall not silently substitute a default for any of them. A + read that matches nothing shall return an empty list rather than an absent + one, and shall render the empty state rather than the error state. When two + reads for different workspaces are in flight, only the response matching the + currently active workspace shall be applied, so a slower earlier response can + neither replace the active workspace's list nor resurrect a resolved row. + +**Count** + +- **AC-UI-NEEDS-YOU-INBOX-001.12:** The sidebar count and the list shall be + derived from the same response of the same read path. For any fixture, the + count shall equal the number of rows the Inbox lists, including a fixture + whose newest turns are lifecycle-only. Any change the Inbox applies to the + listed rows before the next response confirms it shall apply to the count in + the same update, so no rendered state exists in which the count disagrees + with the number of listed rows. +- **AC-UI-NEEDS-YOU-INBOX-001.13:** When the actionable count is zero, no count + shall be rendered on the sidebar entry. Removing every listed row shall behave + identically whether those rows were answered or hidden. Where the list is not + truncated, removing every listed row shall drive the rendered count to absent. Where the list is truncated, + removing every listed row shall replace the page with the next one and the + count shall continue to reflect that page, so the count reaches absent only + when no listable bundle remains in the workspace. Given a fixture of 60 + listable bundles with a limit of 50, dismissing all 50 listed rows shall leave + the Inbox listing the remaining 10 and shall not render the empty state, so a + truncated page emptied by hiding can never be reported as caught up. +- **AC-UI-NEEDS-YOU-INBOX-001.14:** When the page is truncated, the rendered + count shall present the bounded value as a capped indicator rather than as an + exact total. +- **AC-UI-NEEDS-YOU-INBOX-001.34:** The sidebar count shall be present and + correct before the operator has opened the Inbox in the current browser + session. Given a fixture with answerable bundles in the active workspace, a + fresh load of any workspace route other than the Inbox shall render the count + on the sidebar entry, and resolving a bundle elsewhere shall reduce that count + without the Inbox route ever being mounted. A count that becomes correct only + after the destination is visited does not satisfy this criterion, because the + operator would have to open the Inbox to learn that they need to. +- **AC-UI-NEEDS-YOU-INBOX-001.40:** Every produced value of the actionable count + shall carry the bound it was computed under, and every producer shall apply + the same bound, the same workspace scope, and the same per-operator hiding + rule as the list read. A produced count shall be accompanied by whether + further listable bundles exist beyond that bound, so the capped presentation + of AC-UI-NEEDS-YOU-INBOX-001.14 is representable by every producer. Given a + fixture of 60 listable bundles and the default limit of 50, the count before the + Inbox is opened and the count rendered after it is opened shall be the same + value and shall both present as capped. A produced count shall never be + applied to the badge while the list it labels is stale: where one response + cannot supply both the count and the rows it counts, the Inbox shall re-read + rather than apply the count alone. + +**Answering in place** + +- **AC-UI-NEEDS-YOU-INBOX-001.15:** When the operator expands a row, the Inbox + shall present that bundle's real questions, their options, and the bundle's + shared context, using the same clarification answer component the task + session renders. +- **AC-UI-NEEDS-YOU-INBOX-001.16:** When the operator submits an answer or a + rejection from an expanded row, the system shall resolve that bundle and + resume the owning task without navigating away from the Inbox, and shall + remove the resolved row from the list. +- **AC-UI-NEEDS-YOU-INBOX-001.17:** While two callers resolve the same bundle + concurrently, exactly one shall be recorded as the winner. When the Inbox is + the losing caller, it shall remove the row and shall not present a failure. + It shall report the winning outcome in a transient non-error notice that + names the bundle and survives the row's removal, so the outcome is observable + after the row is gone. +- **AC-UI-NEEDS-YOU-INBOX-001.18:** When the same resolution is submitted twice + for one bundle, the second submission shall not create a second resolution + and shall not change the recorded outcome. +- **AC-UI-NEEDS-YOU-INBOX-001.19:** When a listed bundle stops being answerable + for any reason, the Inbox shall remove its row and reduce the count without a + manual reload. This shall hold for every exit in + AC-UI-NEEDS-YOU-INBOX-001.8, not only for the ones a push notification + announces: the row set shall converge from re-reading the bundle path, and no + exit shall be able to strand a row by emitting no event. A design that + removes the row only when a specific event arrives does not satisfy this + criterion. +- **AC-UI-NEEDS-YOU-INBOX-001.35:** If a submitted resolution fails for any + reason other than losing to a winner or finding the bundle no longer active, + then the Inbox shall restore the row and the count to their pre-submission + values, shall keep the operator's entered answer, and shall present an error + that offers to submit again. A failed submission shall never leave the row + removed, because a removed row for an unresolved bundle hides a blocked agent + with no way back. +- **AC-UI-NEEDS-YOU-INBOX-001.39:** The shared clarification answer component + shall report each submission's outcome to whichever surface hosts it, + distinguishing at least: this caller's resolution was recorded; another caller + had already won, with the winning outcome available to the host; the bundle is + no longer active; and the submission failed and may be retried. + AC-UI-NEEDS-YOU-INBOX-001.17 and AC-UI-NEEDS-YOU-INBOX-001.35 shall be + satisfied through that report, not through a second submission path: the Inbox + shall not post the resolution itself. Extending the component shall be + additive: with the task session and Quick Chat hosts + unchanged, their rendered behaviour and their existing resolution callback + shall be identical before and after, asserted by a test. + +**Empty and error** + +- **AC-UI-NEEDS-YOU-INBOX-001.20:** When the Inbox lists no rows for the active + workspace, it shall render an empty state that names what it is not counting: + bundles in other workspaces, pending tool-permission requests, + parent-question records, and the operator's own snoozed or dismissed rows. + The trigger shall be the absence of listed rows, not the absence of + answerable bundles, because a dismissed or snoozed bundle remains answerable + under AC-UI-NEEDS-YOU-INBOX-001.22. When the operator's own dismiss or snooze + is hiding at least one bundle, the empty state shall say so and offer the + restore affordance of AC-UI-NEEDS-YOU-INBOX-001.33; when nothing is hidden it + shall not imply that anything is. +- **AC-UI-NEEDS-YOU-INBOX-001.21:** If the read fails, then the Inbox shall + render an error state distinct from the empty state, shall not state that + the operator is caught up, and no count shall be rendered. + +**Dismiss and snooze** + +- **AC-UI-NEEDS-YOU-INBOX-001.22:** When the operator dismisses or snoozes a + row, the underlying clarification record shall be unchanged and the bundle + shall remain answerable from the task session and from the external + question-answering tools. +- **AC-UI-NEEDS-YOU-INBOX-001.23:** A dismissed or snoozed bundle shall be + excluded from both the list and the count by the same read, so the count + shall continue to equal the number of listed rows. +- **AC-UI-NEEDS-YOU-INBOX-001.24:** When a snooze expires, the bundle shall + reappear in the list and the count, provided it is still answerable, without + the operator reloading or re-navigating. A snooze shall be treated as expired + when its expiry instant is less than or equal to server time, so an expiry + exactly at the current instant reappears rather than remaining hidden. +- **AC-UI-NEEDS-YOU-INBOX-001.25:** Dismiss and snooze shall be per user and + idempotent: repeating the same dismiss or snooze for one bundle shall leave + the same single sidecar state. +- **AC-UI-NEEDS-YOU-INBOX-001.32:** Snooze shall offer a bounded set of + durations of 1 hour, 4 hours, and 24 hours, shall apply 4 hours when the + operator selects no duration, and shall reject a duration outside that set. + Expiry shall be evaluated against server time. +- **AC-UI-NEEDS-YOU-INBOX-001.33:** Dismissal shall be reversible. The Inbox + shall disclose how many answerable bundles in the active workspace are hidden + by the operator's own dismiss or snooze, both when the list is empty and when + it is not, and shall provide a way to restore a hidden bundle to the list. A + bundle blocking an agent shall never become permanently unreachable through + the Inbox as a result of one dismissal. +- **AC-UI-NEEDS-YOU-INBOX-001.36:** Dismiss, snooze, and restore shall each be a + server-side write with an addressable contract, authorized on the workspace + owning the addressed bundle by the same rule as the read. Addressing a bundle + the caller cannot see shall produce the same non-disclosing not-found outcome + as AC-UI-NEEDS-YOU-INBOX-001.31, and shall write no sidecar state. Restoring + a bundle that is not hidden shall succeed and change nothing. +- **AC-UI-NEEDS-YOU-INBOX-001.37:** The operator's hidden bundles shall be + enumerable, not merely countable: the Inbox shall be able to list the bundles + its own dismiss or snooze is hiding in the active workspace, with enough + identity to restore a chosen one. Each enumerated bundle shall carry which of + dismiss or snooze is hiding it and, when snoozed, when that snooze expires. + The enumeration shall report a workspace-wide total that is independent of its + own page bound, and the hidden count of AC-UI-NEEDS-YOU-INBOX-001.33 shall + equal that total for the same operator and workspace. A page-scoped length + shall not be used to satisfy this criterion: given a fixture of 60 hidden + bundles and a limit of 50, the total and the hidden count shall both be 60. +- **AC-UI-NEEDS-YOU-INBOX-001.41:** A snooze expiring shall restore its bundle to + the count even when the Inbox has never been mounted in the current browser + session. Given a fixture holding one snoozed answerable bundle, with the + operator on any other workspace route, the sidebar count shall increase when + that snooze expires, without a reload and without the Inbox being opened. The + client shall be able to learn when the operator's earliest snooze expires from + a response it already reads. + +**Consistency with Threads** + +- **AC-UI-NEEDS-YOU-INBOX-001.26:** The Inbox shall render each row's status + using the shared thread status vocabulary, and shall not define a second + status classifier. +- **AC-UI-NEEDS-YOU-INBOX-001.27:** Given a fixture in which the only pending + action on every task is an answerable clarification bundle, the Inbox row set + and the Threads needs-action preset shall name the same tasks. Where the two + differ by contract, a test shall assert the difference rather than hide it: + a pending tool-permission request, a parent-question record, a bundle with no + resolvable question identifier, and a terminal session shall each appear in + the Threads preset and shall not appear in the Inbox. + +**Non-collision, copy, and coverage** + +- **AC-UI-NEEDS-YOU-INBOX-001.28:** The Needs-you count shall be stored under + its own per-workspace state key. No Needs-you code path shall write the + Office inbox count key. +- **AC-UI-NEEDS-YOU-INBOX-001.29:** All Inbox copy shall be localized in the + five shipped locales, shall contain no Unicode em dash, and the repository + i18n ratchet shall pass. +- **AC-UI-NEEDS-YOU-INBOX-001.30:** An end-to-end specification shall cover + listing an answerable bundle, expanding it, answering it, and observing the + row removed and the task resumed without navigation. + +## Out of scope + +- **Re-ask.** Cut from v1 by product decision on 2026-09-11. The Inbox answers + or rejects a bundle; it never re-issues one. +- **The history bucket and the failed-task bucket.** Separate sibling + capabilities. v1 renders one bucket and therefore no tab strip; the variant a + future strip uses is recorded in the system design. +- **Pending tool-permission requests.** They already surface in the Office + inbox, and admitting them here would give the Inbox two sources and break the + guarantee that the count and the list come from one read. The empty state + must name them as uncounted. +- **Cross-workspace rows.** Workspace-scoped by product decision on + 2026-09-11; permissions are the barrier. +- **Any change to the meaning of the current-turn predicate**, the bundle + visibility rules, or the resolution semantics. This capability consumes them + unchanged; a change to them is a change to the integrations contract. +- **Backfill or notification on first enable.** Turning the flag on shall not + emit notifications for bundles that already exist. +- **Retention or archival of sidecar rows.** diff --git a/docs/specs/ui/system-design/needs-you-inbox-01.md b/docs/specs/ui/system-design/needs-you-inbox-01.md new file mode 100644 index 0000000000..d61ebb3696 --- /dev/null +++ b/docs/specs/ui/system-design/needs-you-inbox-01.md @@ -0,0 +1,529 @@ +--- +status: draft +system: ui +requirements: + - REQ-UI-NEEDS-YOU-INBOX-001 +--- +# Needs-you Inbox System Design Part 1 + +Part 2 (control flow, failure, persistence, status vocabulary, security, prior +art) is in [`needs-you-inbox-02.md`](needs-you-inbox-02.md); Part 3 (design +source, fidelity decisions) is in +[`needs-you-inbox-03.md`](needs-you-inbox-03.md). + +## Purpose and boundaries + +UI owns the destination, the count, the row presentation, and the per-user +dismiss and snooze sidecar. It consumes, and does not own: + +- The answerable-bundle predicate and bundle visibility rules, owned by + integrations (`REQ-INTEGRATIONS-EXTERNAL-QUESTION-ANSWERING-001`, labels + L1a-L16, D4, D6). +- The current-turn predicate and its total order + (`docs/specs/current-turn-authority`, AC-3). +- Clarification resolution semantics: first-write-wins, idempotent replay, and + task resume. + +The single most important boundary: the row set is defined by the bundle read +path alone, never derived from session state. + +## Requirement mapping + +All rows are `REQ-UI-NEEDS-YOU-INBOX-001`; the AC suffixes map as: + +| ACs | Design section | +| --- | --- | +| .1-.4 | [Feature flag](#feature-flag) | +| .5-.11, .31, .38 | [Data and contracts](#data-and-contracts), [Security](needs-you-inbox-02.md#security) | +| .12-.14, .40 | [Data and contracts](#data-and-contracts), [Control flow](needs-you-inbox-02.md#control-flow) | +| .15-.19, .34 | [Control flow](needs-you-inbox-02.md#control-flow) | +| .20-.21, .35, .39 | [Failure and recovery](needs-you-inbox-02.md#failure-and-recovery), [Components and responsibilities](#components-and-responsibilities) | +| .22-.25, .32-.33, .36-.37, .41 | [Persistence](needs-you-inbox-02.md#persistence), [Data and contracts](#data-and-contracts), [Control flow](needs-you-inbox-02.md#control-flow) | +| .26-.27 | [Status vocabulary](needs-you-inbox-02.md#status-vocabulary) | +| .28-.30 | [Components and responsibilities](#components-and-responsibilities) | + +## Verified input inventory + +Every path and line below was read at commit `20efe4855`. Nine citations the +originating card and early review rounds got wrong are corrected here; a builder +following the card without this list looks for files that do not exist, and C6 is +a silent authorization bypass. + +1. **The bounded, workspace-scoped, cursor-paginated list query already exists** + (the card says it does not; that is true only of the HTTP layer). + `ListUnresolvedClarificationBundles` + (`apps/backend/internal/task/repository/sqlite/clarification_bundle_query.go:19`) + already takes `WorkspaceID`, `CreatedSince`, a `(created_at, pending_id)` + cursor and a `Limit` in one query. New backend work is HTTP exposure plus the + sidecar predicate, not a new query. +2. **Ordering is already defined and already total:** `ORDER BY b.created_at ASC, + b.pending_id ASC` (`:150`), matching the cursor comparison at `:65`, documented + as spec L6 on `models/clarification_bundle.go:45`. AC .9 restates it. +3. **A count equal to list length already exists:** `resp.Count = + len(resp.Bundles)` (`mcp/handlers/question_handlers.go:225`). +4. **`resolveThreadStatus` names two functions with incompatible return types.** + The card cites `thread-view-query.ts:92-93`, NON-exported, returning the string + union at `:15`. A second, exported one (`components/threads/thread-column.tsx:21`) + returns the object `ThreadStatus` (`lib/threads/thread-session-status.ts:23`). + AC .26 resolves to the latter family and needs no rename or re-export: the Inbox + calls the already-exported `resolveThreadSessionStatus`. See + [Status vocabulary](needs-you-inbox-02.md#status-vocabulary). +5. **Office DOES emit a clarification-ENTITY inbox row** + (`office/dashboard/service_inbox.go:451-483`, `Type: "permission_request"`, + `EntityType: "clarification"`, from `ListPendingPermissions()`). The card's + substantive point survives: no Office row comes from the clarification BUNDLE + path. Hence permission requests are excluded here and named in the empty state. +6. **The MCP visibility resolver cannot be reused from HTTP.** + `resolveBundleVisibility` is unexported and its scoped branch is gated on + `callerScoped(ctx)` = `resolvedByFromContext(ctx) != ""`, set only by an MCP tool + call, so from a browser-facing handler it takes the UNSCOPED branch and disables + workspace filtering. Use the identity mechanism in + [Security](needs-you-inbox-02.md#security). Caution: the task service has its own + `callerScope`/`callerSubject` (`task/service/service_access.go`) reading + `authn.IdentityFromContext` — same names, different package, and those are the + correct ones. +7. `lib/threads/thread-session-status.ts` declares `const STATUS = {` with NO + `export`; only `resolveThreadSessionStatus` and `resolveThreadColumnStatus` are. +8. `models.ClarificationBundleSummary` is `{PendingID, SessionID, TaskID, + CreatedAt}` — no task title. +9. `models.ClarificationBundlePage.HasMore` is computed by the shipped query under + the same filters; do not re-derive truncation from cursor presence. + +Reused unchanged: + +| Contract | Location | +| --- | --- | +| `currentTurnAuthority` returns predicate **and** order; excludes lifecycle-only turns | `apps/backend/internal/task/repository/sqlite/turn_authority.go:74-87` | +| Bundle list query, parent-question exclusion, missing-question-id exclusion, visibility disjunction | `clarification_bundle_query.go:109-205` | +| Page options: default limit 50, cap 200 | `apps/backend/internal/task/models/clarification_bundle.go:19-50` | +| Bundle messages by id | `FindMessagesByPendingID`, wired at `apps/backend/internal/backendapp/helpers.go:1902-1904` | +| Question projection, response envelope, base64url cursor | `clarification/bundle_question.go:68-86`, `mcp/handlers/question_handlers.go:205-265` | +| Resolution endpoint `POST /api/v1/clarification/:id/respond` | `apps/backend/internal/clarification/handlers.go:207-212` | +| Answer component props `{pending, messages, onResolved, shortcutScopeRef, maxHeightVh}` | `apps/web/components/task/chat/clarification-panel-section.tsx:54-60` | +| Shared status vocabulary, including `clarification` and `permission` kinds | `apps/web/lib/threads/thread-session-status.ts:9-99` | +| Office count key, sole writer and sole reader | `office-slice.ts:258`, `office/selectors.ts:42` | +| Sidebar chrome, row icon/hue, em-dash check, five locales | cited in the card; `app-sidebar-*`, `lib/ui/state-icons.tsx`, `scripts/check-no-em-dash-ui.mjs`, `src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}` | +| Request identity on every HTTP route, synthetic when auth is disabled | `apps/backend/internal/auth/httpmw/middleware.go` (`Middleware`, `SyntheticIdentity`), `auth/authn.IdentityFromContext` | +| Workspace authorization with the 404-vs-403 rule | `taskSvc.AuthorizeWorkspaceScope` / `requireWorkspaceScope`, `apps/backend/internal/task/service/service_access.go` | +| Caller-visible workspace set | `taskSvc.ListWorkspaces` -> `filterWorkspacesForCaller`, same file | +| Workspace-wide pending-action broadcast, fail-closed | `ws.ActionSessionPendingActionChanged` -> `hub.BroadcastToWorkspaceOrDrop`, `apps/backend/internal/gateway/websocket/task_notifications.go` | +| Boot-state per-workspace keys and active-workspace cookie | `bootStateBuilder`, `apps/backend/internal/backendapp/boot_state.go` | +| Page truncation flag | `models.ClarificationBundlePage.HasMore` | +| Flag registry and profile shape | `apps/backend/internal/runtimeflags/registry.go:32-48`, `profiles.yaml:39-45` | + +## Feature flag + +Registry key `features.needsYouInbox`, env var +`KANDEV_FEATURES_NEEDS_YOU_INBOX`, following the camelCase-to-SCREAMING_SNAKE +convention (`features.multiTenancy` / `KANDEV_FEATURES_MULTI_TENANCY`). Profile +defaults `prod: "false"`, `dev: "true"`, `e2e: "true"`. The e2e value is +load-bearing: AC .30 makes an end-to-end spec mandatory and it cannot run +against a flag that is false everywhere. Follow `/runtime-feature-flags` for the +registry, profile and frontend completeness tests. + +The nav entry is gated on this flag alone. It must not be placed inside the +`{inOffice && ...}` block, and must not read the Office feature flag. + +## Components and responsibilities + +**Backend.** New HTTP handlers in the clarification package: one read, one +sidecar upsert, one sidecar delete, one hidden-bundle list. The read resolves +identity and workspace access per [Security](needs-you-inbox-02.md#security) and reuses the existing +bundle lister with the sidecar filter applied inside the same query. It adds no +new SQL predicate for answerability. + +Reuse stops at the lister. These handlers do NOT call the MCP response builder +`buildListPendingQuestionsResponse`, which emits the `QuestionStatus` +projection; this endpoint emits durable message rows instead, for the reason in +[Data and contracts](#data-and-contracts). The handler calls +`ListUnresolvedClarificationBundles` then `FindMessagesByPendingID` per bundle — +the same pair the MCP builder calls — and assembles its own envelope. + +**Sidecar store.** A new per-user table plus repository methods for dismiss, +snooze and restore. + +**Frontend.** A route component, a bordered container of flat divided rows, a +row component with a 32px muted icon chip and plain muted relative time, an +expander mounting the shipped `ClarificationPanelSection`, a nav entry, a new +store slice holding `needsYouCountByWorkspaceId`, and its selector. + +**One shared component IS modified, and that scope is declared here rather +than discovered at Build (AC .39).** `ClarificationPanelSection` cannot be +mounted entirely as-is, because its only outward signal is +`onResolved: () => void` and `useResolveCallback` fires that solely on +`submitState === "ok"`. A `409 not_active` becomes `"expired"` and never calls +it; a `5xx`, a timeout or a network failure becomes `"error"` and never calls +it; and the winner's `claimed` / `status` / `answers`, which +`useClarificationGroup` already parses into `ClarificationRespondResult`, never +leave the hook. So AC .17's notice has no input, AC .35's rollback has no +trigger, and the losing-caller row removal has nothing to fire on. Two changes, +both additive: + +1. **An optional outcome callback** threaded + `useClarificationGroup` -> `ClarificationInputOverlay` -> + `ClarificationPanelSection`, reporting the four outcomes AC .39 enumerates. + It carries values the hook already computes; nothing new is derived. See + [Failure and recovery](needs-you-inbox-02.md#failure-and-recovery) for the mapping. +2. **One string externalized.** The overlay renders the literal + `{group.answeredCount} of {group.total} answered` + (`data-testid="clarification-group-progress"`) with no `t()`. That string + will render inside the Inbox, so AC .29 reaches it. The i18n ratchet judges + added and changed lines and would never have caught an untouched one, which + is precisely why it is named here. This is the ONLY copy change to the shared + component. + +`onResolved` keeps its exact signature and firing rule, the new callback is +optional, and the task chat and Quick Chat hosts pass neither, so their +behaviour is unchanged — which AC .39 requires a test to assert. The rejected +alternative was for the Inbox to post `POST /api/v1/clarification/:id/respond` +itself: that reads the outcome directly, and gives one bundle two posting paths +that must stay in step forever. + +The count key is deliberately new: `inboxCountByWorkspaceId` has exactly one +writer today, and a second would make the Office and Needs-you badges overwrite +each other per workspace. + +v1 renders no tab strip and no page title: the layout starts at the toolbar +with `p-6 space-y-4`, and the app top bar owns the title. + +## Data and contracts + +`GET /api/v1/clarification-inbox` + +Query parameters: `workspace_id` (required), `limit` (optional, default 50, +capped at 200), `cursor` (optional, opaque). + +Validation (AC .38), all `400` with no rows and no sidecar effect: + +| Input | Rule | +| --- | --- | +| `workspace_id` absent or empty | reject. Never read as "the empty workspace", never defaulted to the caller's first | +| `limit` absent | default 50 | +| `limit` non-numeric or `<= 0` | reject, not clamped: a client sending `0` has a bug worth surfacing | +| `limit > 200` | clamp to 200; a cap, not an error | +| `cursor` absent | first page | +| `cursor` present but undecodable | reject | + +Authorization failure is `404` per [Security](needs-you-inbox-02.md#security) (which records why +`403` is unreachable on every endpoint here), not `400`. A +read matching nothing returns `"bundles": []`, never `null`, and renders the +empty state; only a non-2xx renders the error state (AC .21, .31). + +The workspace is a query parameter, not a path segment, on purpose: +`/api/v1/workspaces/:x/...` already carries two different parameter names at one +position (`:workspaceID` in the GitLab controller, `:wsId` in Office costs), and +gin panics at registration when sibling routes disagree on a wildcard name. A +query parameter adds no wildcard and matches the MCP tool's own `workspace_id`. + +Response body: + +```json +{ + "bundles": [ + { + "pending_id": "...", + "task_id": "...", + "session_id": "...", + "session_state": "WAITING_FOR_INPUT", + "task_title": "...", + "created_at": "2026-09-03T04:54:02Z", + "context": "...", + "messages": [ { "id": "...", "metadata": { "pending_id": "...", "question_id": "...", "question_index": 0, "question": { "id": "...", "title": "...", "prompt": "...", "options": [] } } } ] + } + ], + "count": 1, + "hidden_count": 0, + "next_snooze_expiry": null, + "next_cursor": "..." +} +``` + +`count` is `len(bundles)`. Because the sidecar filter runs INSIDE the bundle +query (see [Persistence](needs-you-inbox-02.md#persistence)), every returned row is already a +listable row, so `count == len(bundles)` holds by construction and AC .12 is +structural rather than merely tested. The client reads the list and the badge +from this one response and never computes either separately. + +**The count has exactly TWO producers, and no third (AC .40).** A number is +easy to produce in three places and hard to keep equal in three places, so the +set is closed here: + +| Producer | Bound | Truncation carried by | Rows alongside | +| --- | --- | --- | --- | +| This read | the request's `limit` (default 50, cap 200) | `next_cursor` present | yes, the same response | +| Boot hydration | the SAME default limit of 50, same workspace scope, same sidecar exclusion for the boot identity | an explicit flag in the boot payload (see [Control flow](needs-you-inbox-02.md#control-flow)) | no, and none are rendered yet | + +Everything else that changes the row set produces NO count. In particular the +sidecar writes return no body: they answer `204` and the client re-reads +(below). That is what keeps AC .12's "no rendered state exists in which the +count disagrees with the number of listed rows" true by construction instead of +by three hand-matched computations. A third producer is the defect, not the +optimisation: a workspace-wide count returned from a write would read 59 while +the page it labels holds 49. + +Both producers are bounded the same way, so AC .14's capped presentation is +reachable from either: 60 listable bundles read at a limit of 50 give `count` +50 with truncation set, before the route is opened and after, and the badge +never changes value merely because the operator visited. + +`next_cursor` is present exactly when `models.ClarificationBundlePage.HasMore` +is true, which the shipped query already computes under the same filters. +Because hidden rows are excluded before `LIMIT` rather than after, a short page +means exhaustion and never "the rest were hidden" — which is what makes AC .11's +truncation indicator and AC .14's capped count honest. + +`hidden_count` is how many answerable bundles in this workspace THIS operator's +sidecar is hiding right now. It is workspace-wide, not page-local, so it is a +SECOND query: a bounded `COUNT` over the same visibility and answerability +predicates with the sidecar join inverted. No such field exists in the shipped +tree and the MCP builder computes only a page-local `len()`, which cannot answer +a workspace-wide question. The second query is AC .33's cost, accepted +deliberately. + +`next_snooze_expiry` is the earliest `snooze_until` strictly after server time +among the bundles THIS operator's sidecar is hiding in this workspace, or `null` +when none is snoozed. It falls out of the same bounded query that produces +`hidden_count` (a `MIN` beside the `COUNT`), so it costs no third round trip. +It exists because AC .41's timer has to be schedulable from a response the +client already reads: without it the client would have to call the hidden +endpoint purely to learn when to ask again, and the badge would depend on a +route the operator has not opened. A dismissal contributes no expiry, so a +workspace with only dismissals reports `null` and schedules nothing. + +**Why this carries `messages` and not the MCP envelope's `questions`.** The MCP +tool projects each bundle to `QuestionStatus` +(`{question_id, title, prompt, status, options}`) for an agent reading data. +This endpoint's consumer is `ClarificationPanelSection` -> `ClarificationInputOverlay`, +which is driven by durable message rows: it reads `metadata.question`, +`metadata.question_id`, `metadata.pending_id` and orders by +`metadata.question_index` +(`apps/web/components/task/chat/clarification-input-overlay.tsx:57-92`). Handing +it the `QuestionStatus` projection would force the client to reconstruct message +rows — a second representation of one bundle, the drift AC .26 forbids +elsewhere. Hence reuse at the lister level, not the envelope level. + +Messages travel with the list, so expanding a row needs no second request and +cannot race one. + +AC .10's canonical order is `metadata.question_index` ascending, tie-broken by +`metadata.question_id` ascending. `question_index` alone is not a total order: +the shipped overlay +(`apps/web/components/task/chat/clarification-input-overlay.tsx`) coerces an +absent index to `0`, so a bundle with two indexless questions has a tie that +falls back to arrival order and is not reproducible. An index that is absent, +negative, or not a number sorts as `0` and then by `question_id`. The server emits +messages in this order AND rewrites each emitted `metadata.question_index` to its +0-based rank within it. That rewrite is what makes the two surfaces agree, because +the client does NOT preserve server order: `sortMessagesByQuestionIndex` re-sorts +on `question_index ?? 0`, which catches only null and undefined, so a negative +index sorts ahead of zero and a numeric string sorts as its own value, both +contrary to AC .10. Against normalized ranks that re-sort is a no-op. Normalizing +server-side rather than fixing the client is deliberate: changing the shared sort +would alter what the task session and Quick Chat render, which AC .39 forbids. The +rewrite is a response projection and writes nothing durable. + +Row presentation: primary text is the first question's `title` when non-empty, +else its `prompt`, truncated; if both are empty the row falls back to the bundle +`context`, and if that is empty too, to a localized "Question from agent" +string. A row is never blank and never unlabelled. + +The envelope's bundle-level `context` is the FIRST message's `metadata.context` in +the canonical order above when that is a non-empty string, else `""`; absent or +non-string counts as absent, and later messages are deliberately not consulted, so +the row's fallback text and the expanded panel cannot disagree — the overlay +renders exactly `readSharedContext(sortedMessages[0])`. Do not reuse +`bundleContext` (`mcp/handlers/question_handlers.go`): unexported in another +package, and it scans in STORAGE order. + +Secondary text is the task title then the task identifier. +`models.ClarificationBundleSummary` carries no title (Correction 8), so the +handler resolves it per bundle from the task service and returns `task_title` — +bounded by page size (at most 200 lookups), and it spares the client a second +data source it would otherwise invent. A task lookup that FAILS, one that finds +nothing, and an empty title all yield `task_title: ""`, a failure being logged; +the row renders the identifier alone, so neither a deleted task nor a transient +store error hides a question. The title is presentational and the row stays fully +actionable without it, which is why it degrades where `messages` fails the page. Relative time derives from `created_at`. + +`session_state` is the owning session's `state`, resolved per bundle by the +handler from the session service and bounded by page size exactly as +`task_title` is. It exists for one reason: AC .26's resolver takes +`Pick & {...}`, so `state` is a REQUIRED argument (see +[Status vocabulary](needs-you-inbox-02.md#status-vocabulary)). Without this field the +client would have to fabricate a value or fetch the session separately, and a +fabricated state is a second classifier wearing a disguise. When the session +cannot be read the field is `""`; the resolver short-circuits on +`pending_action` before reading `state`, so the label is unaffected, and the +row still renders. + +`messages` is the row's payload, not an ornament, so it does NOT degrade the way +`task_title` and `session_state` do. Two cases, and they differ: + +- **The per-bundle message read FAILS.** The whole page fails and the Inbox + renders the read-error state (AC .21). A transient store error is not a fact + about that bundle, and dropping one row would hide a blocked agent behind a + page that looks complete. An error the operator can retry is the honest + outcome. +- **The read succeeds and returns NO messages.** That bundle is omitted from the + page and the omission is logged with its `pending_id`. A row with no questions + cannot satisfy AC .15 and is not actionable, so admitting it would breach + AC .6's entry gate. This should be unreachable — the bundle query already + excludes bundles with no resolvable question identifier (AC .8) and + `resolveIdentity` treats a messageless bundle as not-found — but "unreachable" + is a claim about today's predicates, and the failure it guards against is a row + the operator can see and cannot answer. + +An omission changes only `count`, which stays `len(bundles)` and therefore stays +equal to the rows listed (AC .12). It does not change `next_cursor`: truncation +describes what the bounded query found, not what enrichment kept. + +That is also why the empty state carries a second condition: AC .20's trigger is +the absence of listed rows AND no truncation reported. The second clause is +AC .11's doing, not a rival trigger — it forbids showing a partial list as +complete, and a zero-row page that reports truncation is exactly that. A response listing zero rows +while carrying `next_cursor` means enrichment emptied a page the query had filled, +so the Inbox says the list could not be built in full and offers the AC .21 retry +rather than reporting the operator caught up. Nothing re-reads automatically there +— the omissions are logged, and a retry would refetch the same page and drop the +same rows. + +### Sidecar write and restore contracts + +AC .22-.25 and AC .32-.33 describe dismiss, snooze and restore; these are their +contracts (AC .36). All three sit under the same new `/api/v1/clarification-inbox` +prefix. A path parameter is safe here where it was not on `/api/v1/workspaces`: +that prefix already carries two disagreeing wildcard names at one position and +gin panics on that, whereas this prefix is new and every route below names the +same `:pendingID`. + +**`PUT /api/v1/clarification-inbox/sidecar/:pendingID`** — dismiss or snooze. + +```json +{ "state": "dismissed" | "snoozed", "snooze_duration": "1h" | "4h" | "24h" } +``` + +- `snooze_duration` is read only when `state` is `snoozed`; absent means `4h` + (AC .32). Any other value, including a well-formed `2h`, is `400`. The set is + closed deliberately: an open duration would let one entry hide a blocking + question for longer than the nine-day failure this feature exists to fix. +- `state` absent or outside the two values is `400`. +- Authorized on the workspace owning the addressed bundle by the same rule and + the same status mapping as the read (AC .36): an unseeable bundle is `404` and + writes nothing, and an unknown `pendingID` is `404`. **The route carries no + `workspace_id`, so the mechanism is NAMED here rather than left to Build:** + `Resolver.AuthorizeBundleAccess(ctx, pendingID)` + (`apps/backend/internal/clarification/resolver.go`), which resolves the + bundle's `task_id` from its durable messages and then applies + `AuthorizeTaskAccess` -> `authorizeTaskScope(ctx, taskID, + authz.ScopeWorkspaceRead)`. That is the READ's rule reached through the + bundle's task, not a second rule: it loads the task's workspace and tests the + same `workspaceDecision(...).CanRead()` predicate at the same scope that + `AuthorizeWorkspaceScope` tests for the read. Authorizing the bundle's ACTUAL + workspace rather than a client-named one is also how AC .31's "a + client-supplied workspace identifier shall not by itself grant access" holds on + a route that supplies none. Every rejection collapses to one `404` + (`ErrBundleNotFound`): an unknown `pendingID`, a parent-question record, an + unresolvable task and an out-of-reach workspace are indistinguishable to the + caller. Where a transient store error surfaces depends on WHICH step hit it, and + that split is reused as-is. Inside `resolveIdentity` it propagates unconverted + and the caller sees a server error. Inside authorization it does NOT: + `AuthorizeBundleAccess` rewrites ANY error from `AuthorizeTaskAccess` to + `ErrBundleNotFound`, and `authorizeTaskScope` returns raw errors from `GetTask` + and from a non-not-found `GetWorkspace`, so a transient failure there is + indistinguishable from a rejection and answers `404`. Build must NOT make this + symmetric: that resolver is shared with the shipped clarification read endpoints + and lies outside this feature's boundary. See + [Security](needs-you-inbox-02.md#security) for why `403` cannot arise here. +- **The lookup matches ALREADY-RESOLVED bundles, and that is deliberate.** + `resolveIdentity` reads durable messages, which outlive resolution, so a bundle + dismissed and later answered elsewhere still resolves and lands in the `DELETE` + table's "real and visible" rows, not its "unknown" row. Persistence keeps that + orphan sidecar row and runs no retention job, so this is what keeps it + deletable. A lister-based lookup would answer `404` instead, because the bundle + lister is scoped to UNRESOLVED bundles — making an orphan row permanently + unreachable through the only endpoint that removes it. +- Idempotent by construction: primary key `(user_id, pending_id)` makes it an + upsert, so repeating leaves one row (AC .25). Re-snoozing REPLACES + `snooze_until` with a fresh absolute instant from server time; it does not + extend the old one. +- Concurrency: the primary key serializes writers, so two callers racing on one + bundle leave one row and the later commit wins. A `PUT` racing a `DELETE` + therefore ends hidden or visible, never both and never duplicated. Last-write- + wins is safe here because the row is per-user preference, not a claim. +- **`204 No Content` on success. The write returns no count and no rows**, and + the client then re-issues the list read with the canonical parameters of + [Control flow](needs-you-inbox-02.md#control-flow), replacing rows, `count`, + `hidden_count`, `next_snooze_expiry` and `next_cursor` together from that one + response. Returning refreshed counts from the write instead is wrong twice + over: it makes the write a third producer of a number AC .40 binds to one + bound, and it cannot refill a truncated page — dismiss all 50 rows of a + 50-of-60 page and a counts-only response leaves the client holding zero rows + over a workspace with 10 listable bundles left, which AC .20's trigger renders + as caught up. The re-read returns those 10 (AC .13, AC .24). One bounded extra + round trip per hide changes latency, not behaviour: sidecar writes are already + non-optimistic and already round-tripping. + +**`DELETE /api/v1/clarification-inbox/sidecar/:pendingID`** — restore. + +- Deletes the operator's sidecar row, which is the whole of restore: the bundle + was never mutated (AC .22). `204`, and the client re-reads exactly as it does + after a `PUT`. +- **`DELETE` resolves existence and visibility FIRST, by the same check as + `PUT`**, and only then decides. The three cases are distinct and must not be + collapsed: + + | Addressed `pendingID` | Outcome | + | --- | --- | + | unknown, or in a workspace the caller cannot see | `404`, no sidecar write, no disclosure of which of the two it was | + | real and visible, currently hidden by this operator | `204`, row deleted | + | real and visible, not hidden by this operator | `204`, nothing changes (AC .36) | + + The middle and last rows are the idempotency AC .36 asks for: the goal state + is already true, so succeeding is correct and `404` would be wrong. The first + row is NOT that case, and letting "unknown" fall into the `204` branch would + turn a bogus-id probe into an existence oracle running the wrong way: a + caller would learn nothing from `204` but WOULD learn from its absence. Same + status for all three visible outcomes is what keeps it non-disclosing. + +**`GET /api/v1/clarification-inbox/hidden?workspace_id=`** — enumerate hidden. + +- Same row shape, ordering, page bounds, validation and authorization as the + main read, with the sidecar predicate inverted: exactly the answerable bundles + this operator's dismiss or snooze is hiding. Each row additionally carries + `state` (`dismissed` or `snoozed`) and `snooze_until` (`null` unless snoozed), + because restore has to show the operator WHY a bundle is hidden and when it + would have come back on its own (AC .37). +- **It reports TWO numbers, and they are not the same number.** `count` is + `len(bundles)`, page-scoped, exactly as on the main read so the two envelopes + stay one shape. `total` is the workspace-wide number of bundles this + operator's sidecar is hiding, produced by the SAME bounded query that + produces `hidden_count` on the main read, and it is what AC .37 binds to: + `total == hidden_count` for the same operator and workspace, at any page size. + An earlier draft said this endpoint's `count` equalled `hidden_count`, which + holds only until an operator hides more bundles than one page returns — 60 + hidden at a limit of 50 makes those 50 and 60. Naming the workspace-wide + number separately is what makes AC .37 true at every page size instead of + only on small fixtures. +- "Inverted" is a mode on the same option, not a second option. The field added + to `ListClarificationBundlesOptions` (see + [Persistence](needs-you-inbox-02.md#persistence)) carries a user id AND a + direction, `exclude` or `only`; the main read passes `exclude`, this endpoint + passes `only`, and everything else about the query is identical. One predicate + with two directions is what makes the two lists a partition of one answerable + set: a bundle is in the main read or in this one, never both and never + neither. +- This makes AC .33's "restore a hidden bundle" constructible; `hidden_count` + gives the disclosure but not the identities. +- Like the main read, the v1 client sends no `cursor` here. With 60 hidden at a + limit of 50 the operator sees 50 identities over a `total` of 60; restoring from + that page shrinks the hidden set, and the next read surfaces the rest. That is + the same refill argument as AC .13, and it is why `total` rather than `count` is + what AC .37 binds to. +- **Empty and error.** Zero hidden bundles is `"bundles": []` with `count` and + `total` both `0`, and the restore surface says nothing is currently hidden + rather than rendering an error. A failed enumeration renders the read-error + state of AC .21 and offers to retry. Because `hidden_count` and this + enumeration are two round trips apart, a snooze expiring between them can put a + disclosed "3 hidden" over an enumeration of zero; the newer read wins, so the + restore surface replaces `hidden_count` with this response's `total`. AC .37's + equality is an identity within one response, not across two. diff --git a/docs/specs/ui/system-design/needs-you-inbox-02.md b/docs/specs/ui/system-design/needs-you-inbox-02.md new file mode 100644 index 0000000000..833dbe0f7f --- /dev/null +++ b/docs/specs/ui/system-design/needs-you-inbox-02.md @@ -0,0 +1,509 @@ +--- +status: draft +system: ui +requirements: + - REQ-UI-NEEDS-YOU-INBOX-001 +--- +# Needs-you Inbox System Design Part 2 + +Part 1 (boundaries, input inventory, flag, components, data and contracts) is in +[`needs-you-inbox-01.md`](needs-you-inbox-01.md); Part 3 (design source, +fidelity decisions) is in +[`needs-you-inbox-03.md`](needs-you-inbox-03.md). + +## Control flow + +**Read.** Route mount or workspace change issues one request. The response +populates both the row list and the count slice entry for that workspace. + +**The badge does not wait for the route (AC .34).** Route mount as the only +trigger would mean the operator must open the Inbox to learn they need to — +the Overview's own failure, and the shape Office has today (its badge is +written solely from `apps/web/app/office/inbox/inbox-page-client.tsx`). Two +triggers fix it, neither new machinery: + +1. **Boot.** `bootStateBuilder` (`apps/backend/internal/backendapp/boot_state.go`) + already resolves the active workspace from the `kandev-active-workspace` + cookie and adds per-workspace keys to the boot state map. It adds, for that + workspace, `{ count, has_more, next_snooze_expiry }` — NOT a bare integer. + It runs the SAME bounded read as the HTTP endpoint, at the same default + limit of 50, with the same sidecar exclusion resolved for the boot + identity, because AC .40 requires every producer to share one bound. A bare + integer cannot represent AC .14's capped badge, so 60 listable bundles would + render an exact 60 before the visit and a capped 50 after it, and the badge + would change value purely because the operator opened the page. + `next_snooze_expiry` travels with it so the timer in AC .41 can be armed on + first paint. When no active workspace resolves, or the flag is off, the key + is absent and no badge renders — absent is not zero, and neither renders + (AC .13). +2. **The refresh triggers below**, which keep it right thereafter. + +**Refresh (AC .19). No event is the contract; the re-read is.** Every trigger +below does one thing: re-read the bundle path. None of them decides what a row +is, which is what keeps AC .6 intact — the client never reads `pending_action` +to decide whether a row exists. + +That framing is load-bearing rather than pedantic, because the obvious event +does NOT fire on every exit. `publishSessionPendingActionChanged` returns early +unless `pendingActionProjectionChanged` is true, and that reduces to +`previous.action != action` for the SESSION. So the enum, not the bundle, is +what changed. Three real transitions emit nothing: + +- a bundle resolved and immediately replaced by another in the same session — + the projection stays `clarification` throughout; +- a session going terminal, which is an AC .8 exit but not a pending-action + change; +- a turn being superseded, likewise. + +Binding correctness to that one event would strand rows in all three. So: + +| Trigger | Covers | Wiring | +| --- | --- | --- | +| `ws.ActionSessionPendingActionChanged` | set and clear of the pending action — the common case, near-instant | none; already `hub.BroadcastToWorkspaceOrDrop` with `workspace_id` on the payload | +| `ws.ActionSessionStateChanged` | a session becoming terminal | none; already `hub.BroadcastToWorkspace` for exactly this reason ("so the sidebar task switcher can track state changes for all tasks") | +| WebSocket (re)connect | everything missed while disconnected | none | +| Tab visibility regained | a backgrounded tab whose socket was throttled | none | +| A bounded periodic re-read, at most once every 60 seconds, only while the tab is visible and a workspace is active | the residual: same-session bundle replacement, superseded turn, and any future exit nobody thought to emit an event for | none | + +**All five triggers are owned by the count slice, not by the Inbox route +component**, for the same reason the snooze timer is (below): a trigger mounted +with the route stops firing the moment the operator navigates away, and AC .34 +and AC .41 both require the badge to stay right while the Inbox is closed. The +route subscribes to the same slice; it adds no triggers of its own. + +**One canonical parameter set, owned by the same slice (AC .12).** Every read in +this design — boot, route mount, all five triggers, and the convergence re-reads +after a settled resolution or a `204` — is issued with the SAME parameters: the +active workspace and the default limit of 50, and NO cursor. That is what "the +same parameters the Inbox is displaying" means wherever it appears below. Without +one canonical set, a background trigger would write a count computed over a +different page than the rows the route is rendering, which is precisely the +disagreement AC .12 forbids. + +**The v1 client never sends `cursor`, and v1 has no forward-paging control.** The +endpoint accepts one, so the contract is complete and the hidden endpoint shares +the shape, but the Inbox does not use it and no AC needs it: AC .11 asks for a +truncation INDICATOR rather than navigation, AC .14 asks for a capped count, and +AC .13's refill works *because* the re-read is cursorless — emptying a 50-of-60 +page returns the remaining 10 instead of whatever follows a now-stale cursor. So +`next_cursor` is consumed as a boolean, the truncation flag behind AC .11 and +AC .14, and is never echoed back. Forward paging is not part of v1. + +Boot participates in the stale-response guard rather than sitting outside it: +the boot value seeds the slice at generation zero, and the first read for that +workspace supersedes it. So a boot payload that lands after an early read +cannot overwrite fresher rows. + +The periodic read is the honest part of this table. It is the reason AC .19 can +say "for every exit in AC .8, not only the ones a push notification announces", +and it is bounded so it cannot become a poll: one read per minute per active +tab, against a query already bounded to 50 rows. `session.clarification_requested` +remains excluded as a trigger because its `TaskSessionNotificationPayload` +carries no `workspace_id` and so cannot be filtered to the active workspace. + +**Snooze expiry (AC .24, AC .41).** No event fires when a snooze expires — time +passing is not a transition anything publishes — so the client schedules one +re-read at `next_snooze_expiry`, which the boot payload and every list response +already carry (see [Data and contracts](needs-you-inbox-01.md#data-and-contracts)), refreshed +on every response and cleared when the field is `null`. + +**The timer is owned by the count slice, not by the Inbox route component.** +That placement is the whole of AC .41. A timer mounted with the route only runs +while the operator is looking at the Inbox, so a bundle snoozed for four hours +and then left alone would stay off the badge until some unrelated event +happened to fire — reproducing, for snoozed bundles, the exact +"you have to open the Inbox to learn you need to" failure AC .34 exists to +remove. The count slice is live for the whole workspace session, so the badge +recovers whether or not the route was ever mounted. + +**On a workspace switch the timer is cancelled and re-armed from the first +response for the newly active workspace**, so it never fires for a workspace the +operator has left; a new workspace whose response carries no +`next_snooze_expiry` leaves no timer armed, which is correct, because nothing is +snoozed there. The five refresh triggers need no equivalent: they are not +per-workspace subscriptions, they re-read whichever workspace is active when they +fire, and the stale-response guard drops anything that arrives for the wrong one. + +Expiry is evaluated server-side against server time with `snooze_until <= now` +(see [Persistence](#persistence)), so the timer only decides WHEN to ask; it never decides +what is hidden. A client clock running slow asks late and the row appears late. +A client clock running fast asks early, the server returns the row still +hidden, and the response carries the same `next_snooze_expiry` again — so the +worst case is a small number of wasted bounded reads, not a hidden question and +not a spin: the reschedule is floored at 5 seconds, so even a badly skewed clock +cannot busy-loop. + +**There is NO local mutation. Rows and count change only when a response lands.** +The Inbox never splices a row out of the list and never adjusts +`needsYouCountByWorkspaceId` itself; every removal and every count change arrives +in a list response. That is one rule for an answered bundle and a hidden one +alike, which is what makes AC .13's "behave identically" structural rather than +coincidental, and it is why AC .40's producer set stays closed at two: a +client-computed decrement would be a third producer, bounded by nothing and equal +to the rows on screen only by luck. AC .12 is then satisfied by construction, +since the Inbox applies no change ahead of a response and no rendered state can +disagree with itself. The rejected alternative was an immediate splice-and- +decrement on the settled outcome. It is wrong exactly where it matters: on a +truncated page, answering the last row of a 50-of-60 page empties the list +locally and renders AC .20's caught-up state over a workspace with 10 bundles +still waiting. Deferring to the response costs one bounded round trip and removes +that state entirely. AC .35 then holds a fortiori: a submission that settles as a +failure has nothing to restore, because nothing was removed. During the window +between a settled outcome and the response that removes the row, the row stays +rendered with its component still mounted in that settled state, which already +refuses a second submit, so the deferral cannot produce a double resolution. + +**Stale responses (AC .38).** Reads carry a per-workspace request generation. +The store applies a response only when its generation is the newest issued for +the currently active workspace, and drops it otherwise. Without it, a fast +workspace switch lets a slower earlier response replace the active list, and a +read issued before a resolution can resurrect the row just answered. This is the +guard this repo already applies to HTTP/WS cache races; named here so Build does +not rediscover it. + +Answer: the expanded row mounts `ClarificationPanelSection` with `pending` +true and the bundle's messages. Submission goes through the component's +existing path to `POST /api/v1/clarification/:id/respond`, which already +performs the resume. No resume logic is added here. + +**A settled resolution converges exactly the way a hide does.** Each of the three +removing outcomes re-issues the list read with the canonical parameters above — +the same contract a `204` from a sidecar write carries — replacing +rows, `count`, `hidden_count`, `next_snooze_expiry` and `next_cursor` from that +one response. This is what makes AC .13's "removing every listed row shall behave +identically whether those rows were answered or hidden" structural rather than +coincidental. Without it, answering the last row of a 50-of-60 page leaves the +Inbox holding zero rows over a workspace with 10 listable bundles left, and +AC .20's "lists no rows" trigger renders that as caught up until some later +trigger fires. Leaning on `ws.ActionSessionPendingActionChanged` instead does not +close it: that event does not fire when a resolved bundle is immediately replaced +by another in the same session, because the projection stays `clarification` +throughout. The fourth outcome re-reads nothing — it removed nothing, so there is +nothing to converge. A re-read that itself fails is a READ failure (AC .21), +never reported as a failed answer. + +The Inbox is a browser caller and is therefore not an automation principal, so +the caller-self filter in the MCP path does not apply to it: an operator sees +every answerable bundle in the workspace, including ones raised by the agent +they are watching. + +## Failure and recovery + +**Four outcomes, and the Inbox must be able to tell them apart.** Nothing is +removed until one of them arrives, so every outcome below either removes the row +or leaves it exactly where it was. The Row and Count columns state the OUTCOME, +not the mechanism: a removal is applied by the convergence re-read that outcome +triggers, never by a local splice (see [Control flow](#control-flow)). The Inbox +learns which through the optional outcome callback declared in +[Components](needs-you-inbox-01.md#components-and-responsibilities) (AC .39); the +values it carries are exactly the ones `useClarificationGroup` already parses into +`ClarificationRespondResult`, so nothing new is derived and the Inbox never +posts the resolution itself. + +| Outcome | Wire | Row | Count | What the operator sees | +| --- | --- | --- | --- | --- | +| This caller won | `200`, `claimed: true` | REMOVED | decremented | nothing extra; the row leaving IS the feedback | +| Another caller won | `200`, `claimed: false`, with the winner's `status` and `answers` | REMOVED | decremented | a transient NON-error notice naming the bundle and the winning outcome (AC .17) | +| Bundle no longer active | `409`, code `not_active` | REMOVED | decremented | a transient NON-error notice naming the bundle and saying it is no longer waiting | +| Anything else | `5xx`, network, timeout, unparseable body, unrecognized status | UNCHANGED | UNCHANGED | a retryable error, with the entered answer preserved (AC .35) | + +**AC .35 is satisfied by never having removed the row, not by putting it back.** +It requires a failed submission to leave the row and the count at their +pre-submission values; because nothing is ever removed locally and this outcome +triggers no re-read, those values are never disturbed, so the outcome it forbids +— a removed row over an unresolved bundle — is unreachable by construction rather +than by a rollback that could itself fail. Its other two clauses still need building: the entered answer +survives because the shipped component stays mounted and keeps its own form +state, and the retry is offered through that same component. + +**The callback fires exactly once per submission attempt**, on the transition +into a settled state, mirroring the guard `useResolveCallback` already uses for +`onResolved` (`last.current !== submitState`). Without that rule a settled +`"error"` would re-fire on every render and restore the row repeatedly. It also +inherits the hook's existing ownership fence: a callback is delivered only for +the submission that still owns the request, so a bundle swapped under an +in-flight submit cannot attribute one bundle's outcome to another's row. + +Two corrections to an earlier draft are folded in here. First, it claimed both +of the middle rows "surface the winning outcome": they cannot. A `409` body +carries only `error` and `code` — it is the resolver's NO-winner branch — so +there is no winner to name, and its notice says the bundle stopped waiting +rather than inventing who answered it. AC .17 governs the `claimed: false` row +specifically, which is the only one that is actually a concurrent-resolution +loss and the only one that carries a winner. Second, the whole table was +unreachable as specified, because the shipped `onResolved` fires only on +`submitState === "ok"` and takes no argument: the `409` and the failure rows +never reached the host at all, so the Inbox could not tell a loss from a failure +and had no signal it could safely remove a row on. Removing at submit instead +was the earlier draft's answer and it is not available: the component exposes no +submission-started signal, and AC .39 forbids the Inbox posting the resolution +itself, so there is no moment before the settled outcome at which the Inbox +learns a submission exists. + +A failed read, as opposed to a failed submission, renders an error state that is +visually and textually distinct from the empty state, and suppresses the badge. +Rendering "All caught up" on a failed read is the specific defect AC .21 +forbids: it is indistinguishable from success and it is the way this feature +dies in week one. + +**A read that fails AFTER a successful one CLEARS the rows it was replacing.** +The error state replaces the list, and the rows and the count are dropped in the +SAME store update, the rule that already governs removal. Keeping the last good +rows on screen instead is the tempting alternative and it is wrong twice: AC .21 +suppresses the badge on a failed read, so N rows would render under an absent +count, exactly the disagreement AC .12 forbids; and rows that survived a failed +refresh are rows the server may already have resolved, so the operator would +expand one and submit into a `409`. What a transient blip costs is bounded and +self-healing: the error state carries a retry for the read, and the count slice's +triggers re-read regardless, so the list returns within at most the 60-second +reconciling period with no operator action. A failed read never writes sidecar +state and is never reported as a failed hide (below). + +Sidecar writes are never optimistic. The row moves only after the write +succeeds, because the write is cheap and a wrongly-hidden question is expensive. +A failed sidecar write leaves the list unchanged and shows a retryable error. + +**A `204` followed by a failed re-read is a READ failure, not a write failure**, +and the two must not be conflated now that the write returns no body. The +sidecar row is already durable at that point, so the Inbox renders the read +error state of AC .21 and offers to retry the READ; it must not tell the +operator the hide failed, and it must not re-issue the write, which would be +correct but pointless since the `PUT` is an upsert and the `DELETE` is a no-op +on an absent row. Retrying the read is always sufficient, because the server +state the read reports is already the state the operator asked for. + +Snooze uses stored absolute expiry evaluated against server time, so a client +clock skew cannot hide a row. + +## Persistence + +New table `clarification_inbox_sidecar`: + +| Column | Notes | +| --- | --- | +| `user_id` | the request identity's `UserID`; with auth disabled that is the synthetic identity's default user id, mirroring Office's `dashboardUserID` | +| `pending_id` | The bundle | +| `state` | `dismissed` or `snoozed` | +| `snooze_until` | Null unless `state = snoozed` | +| `created_at`, `updated_at` | | + +Primary key `(user_id, pending_id)`, which makes repeated dismiss or snooze an +upsert and satisfies AC .25 by construction. + +**The filter runs inside the bundle query, before `LIMIT`.** The shipped +`ListClarificationBundlesOptions` has no user or sidecar field, so the naive +implementation filters the page in Go AFTER the SQL `LIMIT`. That breaks three +things at once: hidden rows consume page slots (a limit of 50 returns 30 with 20 +still waiting); `HasMore` then describes a page the caller never saw, so AC .11's +truncation indicator and AC .14's capped count both lie; and a page can come back +empty while listable bundles remain, rendering the empty state over a non-empty +workspace. + +So `ListClarificationBundlesOptions` gains one optional field, e.g. +`ExcludeSidecarUserID string`. Empty leaves the query byte-for-byte as it is +today, which is how the MCP caller keeps its behaviour. Set, the query adds a +`LEFT JOIN` to `clarification_inbox_sidecar` on `(user_id, pending_id)` and +excludes `dismissed` rows and `snoozed` rows with `snooze_until > :now`. +`HasMore` and the cursor comparison are unchanged and now describe the filtered +set, making `count == len(bundles)` structural. + +This is additive and inside this design's declared boundary: it changes neither +the current-turn predicate, nor the bundle visibility rules, nor resolution +semantics. Filtering in the client instead would let `count` include rows the +list excludes, breaking AC .12 and AC .23. + +The sidecar never writes to `task_session_messages`. AC .22 is asserted by +resolving a dismissed bundle from another surface and observing success. + +Snooze durations are 1h, 4h and 24h, default 4h (AC .32); the rationale for the +closed set is at the endpoint. The value is stored as an absolute `snooze_until` +compared against SERVER time, so a skewed client clock cannot hide a row. "Still +hidden" is `snooze_until > now`, so an expiry equal to the current instant is +already expired and the row is listed (AC .24) — ties resolve toward showing the +question, the safe direction for a surface meant to stop questions going +unseen. + +Dismissal is reversible (AC .33), and reversibility needs two things, not one. + +The DISCLOSURE is `hidden_count`, workspace-wide, so it cannot come from the +page: a second bounded `COUNT` over the same predicates with the sidecar +predicate inverted. An earlier draft said it arrived "without a second query", +which was not achievable — the rows it counts are exactly the rows the main +query excluded. + +**That one query answers three questions, so there is no third round trip.** +Alongside the `COUNT` it selects `MIN(snooze_until)` over the rows whose +`snooze_until` is strictly after server time. The main read reports the count as +`hidden_count` and the minimum as `next_snooze_expiry`; the hidden endpoint +reports the same count as its `total` (AC .37), which is what makes +`total == hidden_count` an identity rather than two SQL clauses kept in step by +hand. The hidden endpoint's own `count` stays page-scoped, like every other +`count` in this design. + +The RESTORE needs identities, which a count cannot carry. +`GET /api/v1/clarification-inbox/hidden` returns the hidden bundles so the +operator can choose one; `DELETE .../sidecar/:pendingID` then removes the +sidecar row and the bundle returns. Without the enumeration endpoint AC .33's +"restore a hidden bundle" is not constructible: the UI would know how many rows +exist and never which. + +Without both, one mis-click hides a blocking agent's question with no way back, +strictly worse than the pre-Inbox status quo where the task at least appeared in +Threads. + +Rows for resolved bundles are harmless: the bundle stops being answerable, so +it leaves the list regardless. No retention job in this iteration. + +## Status vocabulary + +AC .26 resolves to the object-returning family in +`apps/web/lib/threads/thread-session-status.ts`. The `STATUS` table in that file +is a module-private `const` and is NOT exported; the exported entry point is +`resolveThreadSessionStatus(input): ThreadStatus`, whose input is +`Pick & { pending_action?, foreground_activity? }`. + +An Inbox row calls `resolveThreadSessionStatus` with the owning session's +`state` — carried on the row as `session_state`, which is why that field is on +the envelope at all, since the input type is `Pick & {...}` +and `state` is REQUIRED, not optional — and `pending_action: "clarification"`, +receiving +`{ kind: "clarification", labelKey: "threads:statusQuestionFromAgent", +hasAttention: true }` — the resolver short-circuits on `pending_action`, so the +result does not depend on `state`. Passing `"clarification"` is not deriving the +row from session state: the row exists because the bundle is answerable, and an +answerable bundle is by construction a pending clarification. The row set still +comes only from the bundle read path (AC .6); only the LABEL comes from the +shared resolver. + +**No rename or re-export is required.** An earlier draft asked Build to export +the unrelated private `resolveThreadStatus` in `thread-view-query.ts`. That is +not needed for AC .26 and is not in scope; the two same-named functions live in +different modules and neither is imported here. Leave them alone. + +The Inbox defines no status strings of its own and introduces no new hue: +`IconMessageQuestion` with `text-yellow-500`, the pair already used for +waiting-on-input. + +AC .27's agreement test is deliberately scoped. The two membership predicates +differ by design: Threads derives from `pendingAction || taskPendingAction` +(session state), the Inbox from the bundle path. They coincide only on the +fixture class the AC names, and its four enumerated divergences are asserted AS +divergences so a change that accidentally aligns them fails loudly. + +## Security + +Workspace scoping is enforced server-side from the request identity, not from +the query parameter. + +**Do not reuse the MCP path's visibility resolver** (Correction 6): from an +HTTP handler it takes the unscoped branch, sets `opts.Unscoped = true` and +silently disables workspace filtering, the exact inverse of AC .31. + +The correct mechanism already exists and is identity-based: + +| Step | Contract | +| --- | --- | +| Identity is on every HTTP request | `auth/httpmw.Middleware` calls `authn.SetOnGin`; with auth disabled it injects `SyntheticIdentity()` (`UserID` = the default user, `Role` admin, `Synthetic` true) | +| Authorize the named workspace (the two reads) | `taskSvc.AuthorizeWorkspaceScope(ctx, workspaceID, authz.ScopeWorkspaceRead)` (`task/service/service_access.go`) | +| Authorize the addressed bundle (the two sidecar writes, which carry no `workspace_id`) | `Resolver.AuthorizeBundleAccess(ctx, pendingID)` -> `AuthorizeTaskAccess` -> `authorizeTaskScope(ctx, taskID, authz.ScopeWorkspaceRead)` (`clarification/resolver.go`, `task/service/service_access.go`) | +| Resolve the visible set for the query | `taskSvc.ListWorkspaces(ctx)`, which already applies `filterWorkspacesForCaller` | + +`AuthorizeWorkspaceScope` also settles the status code: `requireWorkspaceScope` +already encodes this repo's 404-vs-403 rule — `ErrWorkspaceNotFound` (404, +non-disclosing) when the caller cannot read the workspace, `ErrForbidden` (403) +when they can read it but lack the scope. The handler maps both straight through +and adds nothing. + +**Which rejection each endpoint can actually produce (AC .31).** All four +endpoints here authorize on `authz.ScopeWorkspaceRead`. For that scope +specifically the 403 branch is DEAD CODE, and saying so is the point: +`Decision.CanRead()` is defined as `d.Scopes.Has(ScopeWorkspaceRead)`, the same +predicate `requireWorkspaceScope` then tests for the 403. A caller who fails the +first test already got the 404; a caller who passes it cannot fail the second. +So an out-of-reach workspace is always `404`, never `403`, on every endpoint in +this feature. + +| Endpoint | Scope | Workspace reached via | Reachable rejections | +| --- | --- | --- | --- | +| `GET /clarification-inbox` | `ScopeWorkspaceRead` | the `workspace_id` query parameter | `400` validation, `404` out of reach | +| `GET /clarification-inbox/hidden` | `ScopeWorkspaceRead` | the `workspace_id` query parameter | `400` validation, `404` out of reach | +| `PUT /clarification-inbox/sidecar/:pendingID` | `ScopeWorkspaceRead` | the addressed bundle's task | `400` validation, `404` unknown or out of reach | +| `DELETE /clarification-inbox/sidecar/:pendingID` | `ScopeWorkspaceRead` | the addressed bundle's task | `404` unknown or out of reach | + +**Two routes to one rule, not two rules (AC .36).** `authorizeTaskScope` loads +the task's workspace and then runs the SAME `workspaceDecision(...)` it runs for +a named workspace, testing `CanRead()` and the same `ScopeWorkspaceRead`. So the +sidecar writes are authorized "by the same rule as the read"; only the way the +workspace is identified differs, and for a route carrying no `workspace_id` the +bundle's own task is the only honest source. The `403` branch is dead on this +path too, for the same reason and by the same predicate. `authorizeTaskScope` +short-circuits to allow when the caller is unscoped, matching the +auth-disabled behaviour described below, and its own not-found +(`ErrTaskNotFound`) is folded into `ErrBundleNotFound` by `AuthorizeBundleAccess` +so the caller cannot tell an unknown bundle from an unreachable one. + +**The sidecar writes deliberately do NOT take a stronger scope.** Spec review +suggested giving them one so AC .31's forbidden outcome would be constructible +somewhere, and that is the wrong trade: `workspaceRoleScopes` gives +`WorkspaceRoleViewer` exactly `{ScopeWorkspaceRead}`, so requiring anything +stronger would stop a viewer dismissing a row from THEIR OWN sidebar. The +sidecar is per-user preference state that mutates nothing shared and discloses +nothing; gating it behind `ScopeTaskWrite` would degrade real behaviour to make +a status code testable. AC .31's security guarantee is untouched by this — +non-disclosure, server-side enforcement, and a client-supplied `workspace_id` +granting nothing all hold. The 403 branch stays in `requireWorkspaceScope` and +starts producing outcomes the day an endpoint here needs a scope a reaching role +can lack; today none does. + +With auth disabled, `callerSubject` returns `Unscoped: true` for the synthetic +identity and every workspace is visible. That is intended single-user behaviour, +not a bypass: AC .31 is satisfied by having consulted identity resolution, and it +starts constraining the moment auth is enabled, with no change here. + +The sidecar is keyed by user, so one user's dismissal cannot hide a row from +another user once authentication is enabled. + +Agents may only enqueue themselves. This is preserved rather than added: a +bundle is created by the agent's own session through the clarification request +path, and the Inbox adds no way for any caller to insert a row for a session it +does not own. The Inbox is read-plus-resolve only. + +## Observability + +Structured logs on read failure and on sidecar write failure, each carrying +workspace id and, where relevant, `pending_id`. No new metric namespace this +iteration: the population is small enough that logs suffice, and a counter that +is almost always zero invites the same "learn to ignore it" failure the badge +rule guards against. + +## Prior art + +**Leg 1, internal wiki — DID NOT RUN.** Receipt: `@henry` resolved +`OBSIDIAN_VAULT_PATH=/Users/henry/Documents/henry/wiki`, +`QMD_WIKI_COLLECTION=wiki`. The path exists but every read returns `Operation not +permitted` even with the sandbox disabled (macOS TCC, not the agent sandbox), and +there is no `qmd` MCP server or CLI, so the grep fallback needed the same blocked +filesystem. No wiki content read and none claimed; a re-run needs Full Disk Access. + +**Leg 2, cross-vendor `saas-kb` — DID NOT RUN.** Receipt: no `saas-kb` MCP server +attached and no local checkout or CLI, so `search_fsm_docs` with +`category: "ai_sdlc"` could not be called. No vendor claims cited. + +**Leg 3, in-repo at `20efe4855` — RAN, and is this design's basis.** Office's inbox +(`docs/specs/office/requirements/inbox.md`) is the closest precedent and supplies +four adopted positions: computed view not a table (AC-OFFICE-INBOX-001.2); a +dismissal sidecar keyed by `(user_id, item_kind, item_id)`; identical inputs give +identical output across restart; the badge uses the same aggregation as the list. +Also read: `docs/decisions/2026-08-14-current-turn-clarification-ownership.md`, +`docs/decisions/2026-09-05-bounded-clarification-response-path.md`. + +**Differently from Office:** one source rather than eight item types from five, +because a count assembled from several sources cannot be guaranteed equal to the +list it labels; reachable outside Office mode; and ordered oldest-first, because +the failure being fixed is an old question nobody saw. + +## Related decisions + +- [Current-turn clarification ownership](../../../decisions/2026-08-14-current-turn-clarification-ownership.md) +- [Bounded clarification response path](../../../decisions/2026-09-05-bounded-clarification-response-path.md) +- [Runtime feature flags](../../../decisions/0007-runtime-feature-flags.md) diff --git a/docs/specs/ui/system-design/needs-you-inbox-03.md b/docs/specs/ui/system-design/needs-you-inbox-03.md new file mode 100644 index 0000000000..fe31f6a652 --- /dev/null +++ b/docs/specs/ui/system-design/needs-you-inbox-03.md @@ -0,0 +1,95 @@ +--- +status: draft +system: ui +requirements: + - REQ-UI-NEEDS-YOU-INBOX-001 +--- +# Needs-you Inbox System Design Part 3 + +Part 1 (boundaries, input inventory, flag, components, data and contracts) is in +[`needs-you-inbox-01.md`](needs-you-inbox-01.md); Part 2 (control flow, failure +and recovery, security) is in [`needs-you-inbox-02.md`](needs-you-inbox-02.md). +This part carries the visual and copy contract. + +## Design source + +The visual and copy contract is the KAN-41 revision-4 mockup deck, drawn +against the shipped shell and vendored at +[`../assets/needs-you-inbox-mockup-r4.html`](../assets/needs-you-inbox-mockup-r4.html). + +It is vendored rather than linked because the first implementation round +received it as prose only. Its eleven frames were inlined into the originating +card as host-relative `/api/attachments//content` URLs. Resolved against +the Kandev origin, which is the only origin an agent reading that card knows, +they 404: Kandev has its own `/api/attachments/` route over a different +identifier space, so the path looks valid and fails without erroring. The build +that followed satisfied every acceptance criterion in the requirements while +diverging from the deck, because no criterion in the requirements described the +deck. + +That is the failure this part exists to prevent, and it generalizes: a design +that is not in the spec canon cannot fail review, because review checks code +against the canon. A copy or control decision that matters is stated here, not +only drawn. + +## Fidelity decisions + +**D1. Each row offers a control that opens its task.** The row body toggles the +answer panel, so without a distinct control the task a question belongs to is +unreachable from the Inbox. The deck draws it as a `Button size="sm"` on the +right of the row, beside the actions menu; that button is withheld below the +`sm` breakpoint, where its fixed width would squeeze the question title and put +the row at risk of the horizontal overflow AC .30's mobile specification +forbids. The actions menu carries the same destination at every width, so the +task is reachable on a phone without the button. + +**D2. The empty state names the active workspace and does not congratulate.** +No completion icon, no exclamation mark, and no claim of being caught up. An +empty queue is a normal state rather than an achievement, and an unscoped +congratulation reads as a claim about the whole instance while other workspaces +hold unanswered questions. Naming the workspace also keeps the sentence true +under AC .20, which already requires the empty state to name what it is not +counting. When the active workspace cannot be resolved from client state, the +sentence falls back to naming no workspace rather than naming the wrong one. + +**D3. A row whose bundle holds more than one question states how many.** A +bundle is answered as a unit, so its size changes what answering it costs, and +a row that hides the size understates the work. The count is of questions +carried, not of messages: a bundle's message list can hold context messages +that are not themselves questions, so the count filters on the presence of a +clarification question. A single-question bundle renders no count line, because +the line would then restate the row above it. + +**D4. The sidebar entry is named Inbox and uses `IconInbox`.** The deck draws +the destination as **Inbox**; "Needs you" is the label of the first of its three +tabs. When v1 collapsed to one bucket the tab name was promoted to the +destination name, which put a bucket label in the sidebar and gave the entry an +icon the deck never assigned it. The destination is the Inbox whether it renders +one bucket or three, so adding the history and failed buckets later changes the +tabs and not the sidebar. + +The one exception is Office mode. AC .1 through .3 keep this entry present +regardless of mode, and Office renders its own `/office/inbox` entry already +labelled Inbox, so in that mode two identically named rows would be +indistinguishable. There, and only there, this entry falls back to "Needs you". +The deck did not have to solve this: it assumed the kanban workspace, where +Office's row is gated out and no collision exists. + +## Deck decisions this capability does not adopt + +Each is a recorded decision rather than an omission; all are listed in the +requirements under "Out of scope". + +- **The tab strip**, and with it the deck's `variant="line"` analysis. v1 + renders one bucket, so there is nothing to switch between. +- **The history bucket and the failed-task bucket**, which the deck draws as + the second and third tabs. +- **Permission rows** and their amber `IconShieldQuestion` treatment. +- **Re-ask**, cut from v1 on 2026-09-11. +- **The deck's cross-workspace empty state**, which offers to switch to a + workspace that has a waiting question. It depends on a per-workspace count + map the client does not hold for workspaces it is not in. + +Because the tab strip is gone, the deck's empty-state copy pointing the +operator at "the other two tabs" has nowhere to point; D2 states the copy for +the one-bucket world instead.