feat(web): add a Failed tab to the Needs-you Inbox (#3683)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-16 06:14:56 +08:00
committed by GitHub
parent 02120b907c
commit 37cea5a8c2
78 changed files with 4895 additions and 55 deletions
@@ -46,6 +46,7 @@ import (
editorhandlers "github.com/kandev/kandev/internal/editors/handlers"
"github.com/kandev/kandev/internal/entityrefs"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/failedinbox"
gateways "github.com/kandev/kandev/internal/gateway/websocket"
"github.com/kandev/kandev/internal/github"
"github.com/kandev/kandev/internal/gitlab"
@@ -1427,6 +1428,9 @@ func registerSecondaryRoutes(
)
p.log.Debug("Registered Clarification handlers (HTTP)")
failedinbox.RegisterRoutes(p.router, p.taskSvc, p.taskRepo, p.log, p.features.NeedsYouInbox)
p.log.Debug("Registered Failed Inbox handlers (HTTP)")
// Wire the plugin Host interaction write path (ADR 0052) onto the same
// orchestrator permission resolution and the same clarification resolver
// instance the REST route and the MCP handlers use, so a plugin's response
@@ -21,6 +21,19 @@ func AutoIncrementIDColumn(driver string) string {
return "id INTEGER PRIMARY KEY AUTOINCREMENT"
}
// ByteOrderedText wraps a column or expression reference so ORDER BY compares
// it byte-for-byte on every supported dialect, regardless of a database-level
// locale collation. SQLite's TEXT columns already default to the BINARY
// collation, so this is a no-op there; PostgreSQL's default template
// collation can be locale-aware, so this appends an explicit COLLATE "C" to
// force the same byte order there too.
func ByteOrderedText(driver, expr string) string {
if IsPostgres(driver) {
return expr + ` COLLATE "C"`
}
return expr
}
// BoolToInt converts a boolean to an integer for SQL storage.
func BoolToInt(value bool) int {
if value {
@@ -27,6 +27,15 @@ func TestTimestampType(t *testing.T) {
}
}
func TestByteOrderedText(t *testing.T) {
if got := ByteOrderedText(SQLite3, "t.id"); got != "t.id" {
t.Errorf("sqlite: expected bare expression, got %q", got)
}
if got := ByteOrderedText(PGX, "t.id"); got != `t.id COLLATE "C"` {
t.Errorf("pgx: expected explicit C collation, got %q", got)
}
}
func TestBoolToInt(t *testing.T) {
if BoolToInt(true) != 1 {
t.Error("expected 1 for true")
@@ -0,0 +1,169 @@
package failedinbox
import (
"context"
"errors"
"net/http"
"strconv"
"strings"
"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"
"go.uber.org/zap"
)
const (
defaultFailedInboxLimit = 50
maxFailedInboxLimit = 200
errFailedInboxInternal = "failed to read failed inbox"
)
// workspaceAuthorizer is the workspace-scope check the Failed tab's read
// endpoint needs (design "Security"): the same rule AuthorizeWorkspaceScope
// already applies everywhere else.
type workspaceAuthorizer interface {
AuthorizeWorkspaceScope(ctx context.Context, workspaceID string, scope authz.Scope) error
}
// failedTaskStore is the bounded read surface the Failed tab needs from the
// task repository.
type failedTaskStore interface {
ListFailedInboxTasks(ctx context.Context, opts taskmodels.ListFailedInboxOptions) (*taskmodels.FailedInboxPage, error)
}
// Handlers serves the Inbox Failed tab's HTTP surface.
type Handlers struct {
authz workspaceAuthorizer
store failedTaskStore
logger *logger.Logger
}
// NewHandlers constructs the Failed-tab handlers.
func NewHandlers(authz workspaceAuthorizer, store failedTaskStore, log *logger.Logger) *Handlers {
return &Handlers{
authz: authz,
store: store,
logger: log.WithFields(zap.String("component", "failed-inbox-handlers")),
}
}
// RegisterRoutes registers the Failed tab's HTTP route. enabled gates it
// behind the same Inbox feature flag that already governs the Inbox
// destination (AC-UI-INBOX-FAILED-001.3).
func RegisterRoutes(router *gin.Engine, authz workspaceAuthorizer, store failedTaskStore, log *logger.Logger, enabled bool) {
if !enabled {
return
}
h := NewHandlers(authz, store, log)
router.GET("/api/v1/failed-inbox", h.httpListFailedInbox)
}
// failedInboxRow is one wire row. task_id, title, workspace_id, origin and
// reason are always present as an empty string rather than absent;
// failure_instant is omitted (never null, never a zero instant) when
// unresolvable -- the wire contract F22 fixes.
type failedInboxRow struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
WorkspaceID string `json:"workspace_id"`
Origin string `json:"origin"`
FailureInstant *string `json:"failure_instant,omitempty"`
Reason string `json:"reason"`
}
// failedInboxListResponse is GET /api/v1/failed-inbox's response envelope.
type failedInboxListResponse struct {
Rows []failedInboxRow `json:"rows"`
Count int `json:"count"`
Truncated bool `json:"truncated"`
}
func respondFailedInboxError(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"error": message})
}
// httpListFailedInbox backs GET /api/v1/failed-inbox.
func (h *Handlers) httpListFailedInbox(c *gin.Context) {
ctx := c.Request.Context()
workspaceID := strings.TrimSpace(c.Query("workspace_id"))
if workspaceID == "" {
respondFailedInboxError(c, http.StatusBadRequest, "workspace_id is required")
return
}
limit, err := parseFailedInboxLimit(c.Query("limit"))
if err != nil {
respondFailedInboxError(c, http.StatusBadRequest, err.Error())
return
}
if err := h.authz.AuthorizeWorkspaceScope(ctx, workspaceID, authz.ScopeWorkspaceRead); err != nil {
h.respondWorkspaceAuthzError(c, err)
return
}
page, err := h.store.ListFailedInboxTasks(ctx, taskmodels.ListFailedInboxOptions{WorkspaceID: workspaceID, Limit: limit})
if err != nil {
h.logger.Error("failed to list failed inbox tasks", zap.Error(err))
respondFailedInboxError(c, http.StatusInternalServerError, errFailedInboxInternal)
return
}
rows := make([]failedInboxRow, 0, len(page.Rows))
for _, row := range page.Rows {
rows = append(rows, toFailedInboxRow(row))
}
c.JSON(http.StatusOK, failedInboxListResponse{Rows: rows, Count: len(rows), Truncated: page.HasMore})
}
// toFailedInboxRow projects a store row onto the wire shape, applying
// AC-UI-INBOX-FAILED-001.19a's sanitize-then-truncate reason contract.
func toFailedInboxRow(row taskmodels.FailedInboxTaskRow) failedInboxRow {
out := failedInboxRow{
TaskID: row.TaskID,
Title: row.Title,
WorkspaceID: row.WorkspaceID,
Origin: row.Origin,
Reason: SanitizeAndTruncateReason(row.Reason),
}
if row.FailureInstant != nil {
instant := row.FailureInstant.UTC().Format(time.RFC3339)
out.FailureInstant = &instant
}
return out
}
// parseFailedInboxLimit mirrors the Needs-you bucket's limit contract
// (AC-UI-INBOX-FAILED-001.12): absent defaults to 50, non-numeric or <= 0 is
// rejected, and > 200 clamps to 200.
func parseFailedInboxLimit(raw string) (int, error) {
if raw == "" {
return defaultFailedInboxLimit, nil
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return 0, errors.New("limit must be a positive integer")
}
if n > maxFailedInboxLimit {
return maxFailedInboxLimit, nil
}
return n, nil
}
// respondWorkspaceAuthzError maps AuthorizeWorkspaceScope's errors per
// AC-UI-INBOX-FAILED-001.24: an unreachable workspace is a non-disclosing 404.
func (h *Handlers) respondWorkspaceAuthzError(c *gin.Context, err error) {
switch {
case errors.Is(err, repoerrors.ErrWorkspaceNotFound):
respondFailedInboxError(c, http.StatusNotFound, "workspace not found")
case errors.Is(err, service.ErrForbidden):
respondFailedInboxError(c, http.StatusForbidden, "insufficient permissions")
default:
h.logger.Error("failed to authorize failed inbox workspace access", zap.Error(err))
respondFailedInboxError(c, http.StatusInternalServerError, errFailedInboxInternal)
}
}
@@ -0,0 +1,223 @@
package failedinbox
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"unicode/utf8"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/authz"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
"github.com/kandev/kandev/internal/task/service"
taskmodels "github.com/kandev/kandev/internal/task/models"
)
type fakeWorkspaceAuthorizer struct {
err error
}
func (f *fakeWorkspaceAuthorizer) AuthorizeWorkspaceScope(context.Context, string, authz.Scope) error {
return f.err
}
type fakeFailedTaskStore struct {
page *taskmodels.FailedInboxPage
err error
// lastOpts records the options of the most recent call, for assertions
// on what the handler passed through.
lastOpts taskmodels.ListFailedInboxOptions
}
func (f *fakeFailedTaskStore) ListFailedInboxTasks(_ context.Context, opts taskmodels.ListFailedInboxOptions) (*taskmodels.FailedInboxPage, error) {
f.lastOpts = opts
if f.err != nil {
return nil, f.err
}
if f.page != nil {
return f.page, nil
}
return &taskmodels.FailedInboxPage{}, nil
}
func newFailedInboxTestHandler(authorizer workspaceAuthorizer, store failedTaskStore) *Handlers {
gin.SetMode(gin.TestMode)
return NewHandlers(authorizer, store, logger.Default())
}
func runFailedInboxGet(h *Handlers, target string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, target, nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
h.httpListFailedInbox(c)
return rec
}
func decodeFailedInboxBody(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 TestHttpListFailedInbox_MissingWorkspaceIDIs400(t *testing.T) {
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{}, &fakeFailedTaskStore{})
rec := runFailedInboxGet(h, "/api/v1/failed-inbox")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestHttpListFailedInbox_NonNumericLimitIs400(t *testing.T) {
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{}, &fakeFailedTaskStore{})
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws1&limit=abc")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestHttpListFailedInbox_NonPositiveLimitIs400(t *testing.T) {
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{}, &fakeFailedTaskStore{})
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws1&limit=0")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}
func TestHttpListFailedInbox_AbsentLimitDefaultsTo50(t *testing.T) {
store := &fakeFailedTaskStore{}
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{}, store)
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws1")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if store.lastOpts.Limit != 50 {
t.Errorf("limit = %d, want 50", store.lastOpts.Limit)
}
}
func TestHttpListFailedInbox_LimitAbove200Clamps(t *testing.T) {
store := &fakeFailedTaskStore{}
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{}, store)
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws1&limit=999")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if store.lastOpts.Limit != 200 {
t.Errorf("limit = %d, want clamped 200", store.lastOpts.Limit)
}
}
func TestHttpListFailedInbox_UnreachableWorkspaceIs404NonDisclosing(t *testing.T) {
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{err: repoerrors.ErrWorkspaceNotFound}, &fakeFailedTaskStore{})
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws-outside")
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestHttpListFailedInbox_ForbiddenMapsTo403(t *testing.T) {
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{err: service.ErrForbidden}, &fakeFailedTaskStore{})
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws1")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
}
func TestHttpListFailedInbox_StoreErrorIs500(t *testing.T) {
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{}, &fakeFailedTaskStore{err: errors.New("boom")})
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws1")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
}
func TestHttpListFailedInbox_EmptyPageReturnsEmptyListNotAbsent(t *testing.T) {
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{}, &fakeFailedTaskStore{page: &taskmodels.FailedInboxPage{}})
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws1")
var body struct {
Rows []json.RawMessage `json:"rows"`
Count int `json:"count"`
Truncated bool `json:"truncated"`
}
decodeFailedInboxBody(t, rec, &body)
if body.Rows == nil {
t.Error("expected an empty array, got absent/null rows")
}
if body.Count != 0 || body.Truncated {
t.Errorf("expected count=0 truncated=false, got count=%d truncated=%v", body.Count, body.Truncated)
}
}
func TestHttpListFailedInbox_CountIsRenderedRowSetSize(t *testing.T) {
instant := time.Date(2026, 9, 14, 1, 0, 0, 0, time.UTC)
page := &taskmodels.FailedInboxPage{
Rows: []taskmodels.FailedInboxTaskRow{
{TaskID: "t1", Title: "Task one", WorkspaceID: "ws1", Origin: "manual", FailureInstant: &instant, Reason: "boom"},
{TaskID: "t2", Title: "Task two", WorkspaceID: "ws1", Origin: "", FailureInstant: nil, Reason: ""},
},
HasMore: true,
}
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{}, &fakeFailedTaskStore{page: page})
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws1")
var body struct {
Rows []struct {
TaskID string `json:"task_id"`
Title string `json:"title"`
WorkspaceID string `json:"workspace_id"`
Origin string `json:"origin"`
FailureInstant *string `json:"failure_instant"`
Reason string `json:"reason"`
} `json:"rows"`
Count int `json:"count"`
Truncated bool `json:"truncated"`
}
decodeFailedInboxBody(t, rec, &body)
if body.Count != 2 {
t.Errorf("count = %d, want 2 (the rendered row set's own size)", body.Count)
}
if !body.Truncated {
t.Error("expected truncated=true")
}
if body.Rows[0].TaskID != "t1" || body.Rows[0].FailureInstant == nil || *body.Rows[0].FailureInstant != "2026-09-14T01:00:00Z" {
t.Errorf("row 0 = %+v", body.Rows[0])
}
if body.Rows[1].FailureInstant != nil {
t.Errorf("row 1 failure_instant should be omitted (nil), got %v", *body.Rows[1].FailureInstant)
}
if body.Rows[1].Origin != "" || body.Rows[1].Reason != "" {
t.Errorf("row 1 = %+v, want empty-string origin and reason rather than absent", body.Rows[1])
}
}
func TestHttpListFailedInbox_ReasonSanitizedAndTruncated(t *testing.T) {
longReason := ""
for i := 0; i < 600; i++ {
longReason += "a"
}
page := &taskmodels.FailedInboxPage{
Rows: []taskmodels.FailedInboxTaskRow{
{TaskID: "t1", Title: "Task one", WorkspaceID: "ws1", Reason: longReason},
},
}
h := newFailedInboxTestHandler(&fakeWorkspaceAuthorizer{}, &fakeFailedTaskStore{page: page})
rec := runFailedInboxGet(h, "/api/v1/failed-inbox?workspace_id=ws1")
var body struct {
Rows []struct {
Reason string `json:"reason"`
} `json:"rows"`
}
decodeFailedInboxBody(t, rec, &body)
if got := utf8.RuneCountInString(body.Rows[0].Reason); got != 512 {
t.Errorf("expected the reason truncated to 512 code points, got length %d", got)
}
}
@@ -0,0 +1,63 @@
// Package failedinbox implements the Inbox Failed tab: a workspace-scoped,
// bounded, read-only projection of failed tasks
// (docs/specs/ui/system-design/inbox-failed-bucket-01.md). It is a separate
// endpoint from the clarification bundle read -- a different table, a
// different question, never merged.
package failedinbox
import (
"strings"
"unicode/utf8"
)
// maxReasonCodePoints is AC-UI-INBOX-FAILED-001.19a's bound: enough to stop a
// 4096-byte stored message multiplied by a 200-row page from becoming an
// 800KB payload, while still showing the actual triage content.
const maxReasonCodePoints = 512
// SanitizeAndTruncateReason produces the failure reason exactly as
// AC-UI-INBOX-FAILED-001.19a requires, in the order the AC states -- the
// steps are not commutative. FIRST, every byte that is not part of a
// well-formed UTF-8 sequence is replaced with the Unicode replacement
// character, one per invalid byte, so the result is valid UTF-8 for every
// input including one that is not. SECOND, the result is truncated to at
// most 512 Unicode code points, never bytes, never splitting one.
func SanitizeAndTruncateReason(raw string) string {
return truncateToCodePoints(sanitizeToValidUTF8(raw), maxReasonCodePoints)
}
// sanitizeToValidUTF8 replaces each invalid byte with the replacement
// character individually (unlike strings.ToValidUTF8, which collapses a run
// of invalid bytes into a single replacement), matching the AC's literal
// "each byte" wording.
func sanitizeToValidUTF8(raw string) string {
if raw == "" {
return ""
}
var b strings.Builder
b.Grow(len(raw))
for i := 0; i < len(raw); {
r, size := utf8.DecodeRuneInString(raw[i:])
// utf8.DecodeRuneInString already advances by exactly one byte on a
// malformed sequence (size <= 1 whenever r == RuneError for
// non-empty input), so writing r as-is replaces each invalid byte
// individually rather than collapsing a run into one replacement.
b.WriteRune(r)
i += size
}
return b.String()
}
// truncateToCodePoints cuts s at the byte offset where the (max+1)'th code
// point would start, so the result carries exactly min(max, count) code
// points and never splits one.
func truncateToCodePoints(s string, max int) string {
count := 0
for i := range s {
if count == max {
return s[:i]
}
count++
}
return s
}
@@ -0,0 +1,78 @@
package failedinbox
import (
"strings"
"testing"
"unicode/utf8"
)
func TestSanitizeAndTruncateReason_ValidShortReasonUnchanged(t *testing.T) {
if got := SanitizeAndTruncateReason("connection refused"); got != "connection refused" {
t.Errorf("got %q", got)
}
}
func TestSanitizeAndTruncateReason_Empty(t *testing.T) {
if got := SanitizeAndTruncateReason(""); got != "" {
t.Errorf("got %q", got)
}
}
// AC-UI-INBOX-FAILED-001.19a: invalid bytes become the replacement
// character, one per invalid byte, never a broken partial character.
func TestSanitizeAndTruncateReason_InvalidUTF8Replaced(t *testing.T) {
raw := "before\xff\xfeafter"
got := SanitizeAndTruncateReason(raw)
if !utf8.ValidString(got) {
t.Fatalf("result is not valid UTF-8: %q", got)
}
want := "before��after"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// AC-UI-INBOX-FAILED-001.19a: truncation is to at most 512 Unicode CODE
// POINTS, never bytes, and never splits a code point -- a multi-byte script
// must still yield exactly 512 runes and no partial trailing character.
func TestSanitizeAndTruncateReason_TruncatesToCodePointsNotBytes(t *testing.T) {
// Each "日" is 3 bytes in UTF-8; 600 of them is 1800 bytes but only 600
// code points.
raw := strings.Repeat("日", 600)
got := SanitizeAndTruncateReason(raw)
if !utf8.ValidString(got) {
t.Fatalf("result is not valid UTF-8")
}
gotCount := utf8.RuneCountInString(got)
if gotCount != 512 {
t.Fatalf("expected exactly 512 code points, got %d", gotCount)
}
want := strings.Repeat("日", 512)
if got != want {
t.Errorf("got %q, want first 512 repeats of 日", got)
}
}
func TestSanitizeAndTruncateReason_ExactBoundaryUnchanged(t *testing.T) {
raw := strings.Repeat("a", 512)
got := SanitizeAndTruncateReason(raw)
if got != raw {
t.Errorf("expected a 512-code-point reason to be returned unchanged")
}
}
func TestSanitizeAndTruncateReason_SanitizesBeforeTruncating(t *testing.T) {
// A long run of invalid bytes followed by valid content: sanitizing
// FIRST means the invalid bytes count toward the 512-code-point budget
// as individual replacement characters, not as raw bytes that would
// otherwise never reach the truncation step in a byte-sliced
// implementation.
raw := strings.Repeat("\xff", 600)
got := SanitizeAndTruncateReason(raw)
if !utf8.ValidString(got) {
t.Fatalf("result is not valid UTF-8")
}
if utf8.RuneCountInString(got) != 512 {
t.Fatalf("expected exactly 512 code points, got %d", utf8.RuneCountInString(got))
}
}
@@ -308,6 +308,7 @@ type seedTaskSessionRequest struct {
AgentProfileID string `json:"agent_profile_id,omitempty"`
StartedAt *string `json:"started_at,omitempty"`
CompletedAt *string `json:"completed_at,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
CommandCount int `json:"command_count,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
@@ -380,6 +381,7 @@ func updateSeededSession(
}
existing.State = session.State
existing.CompletedAt = session.CompletedAt
existing.ErrorMessage = session.ErrorMessage
existing.UpdatedAt = time.Now().UTC()
if existing.Metadata == nil {
existing.Metadata = map[string]interface{}{}
@@ -540,6 +542,7 @@ func buildSeededSession(req *seedTaskSessionRequest) (*models.TaskSession, error
RepositoryID: req.RepositoryID,
AgentProfileID: req.AgentProfileID,
State: models.TaskSessionState(req.State),
ErrorMessage: req.ErrorMessage,
Metadata: metadata,
StartedAt: startedAt,
CompletedAt: completedAt,
@@ -352,6 +352,51 @@ func TestSeedTaskSessionUsesRequestedSessionID(t *testing.T) {
}
}
func TestSeedTaskSessionUpdateAppliesErrorMessage(t *testing.T) {
repo, sqlxDB := newTestRepo(t)
taskID := uuid.New().String()
sessionID := uuid.New().String()
seedTask(t, sqlxDB, taskID)
r := newRouter(t, repo, nil)
body := mustJSON(t, map[string]interface{}{
"task_id": taskID,
"session_id": sessionID,
"state": "RUNNING",
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/_test/task-sessions", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
completedAt := time.Now().UTC().Format(time.RFC3339)
updateBody := mustJSON(t, map[string]interface{}{
"task_id": taskID,
"session_id": sessionID,
"state": "FAILED",
"completed_at": completedAt,
"error_message": "boom",
})
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/_test/task-sessions", bytes.NewReader(updateBody))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
session, err := repo.GetTaskSession(context.Background(), sessionID)
if err != nil {
t.Fatalf("get updated session: %v", err)
}
if session.ErrorMessage != "boom" {
t.Fatalf("error_message = %q, want %q", session.ErrorMessage, "boom")
}
}
func TestSeedTaskSessionUsesRequestedRepositoryID(t *testing.T) {
repo, sqlxDB := newTestRepo(t)
taskID := uuid.New().String()
@@ -0,0 +1,33 @@
package models
import "time"
// FailedInboxTaskRow is one row of the Inbox Failed-tab projection: a task
// whose own state is the terminal failed state (AC-UI-INBOX-FAILED-001.6),
// joined to the session AC-UI-INBOX-FAILED-001.30 selects for its failure
// instant and reason. FailureInstant is nil when unresolvable (no session, or
// the chosen session records no completion instant); Reason is "" when no
// reason can be resolved, mirroring the wire contract's own omit/empty rule.
type FailedInboxTaskRow struct {
TaskID string
Title string
WorkspaceID string
Origin string
FailureInstant *time.Time
Reason string
}
// ListFailedInboxOptions bounds a single failed-inbox read
// (AC-UI-INBOX-FAILED-001.5, .12, .13). Limit must be >= 1.
type ListFailedInboxOptions struct {
WorkspaceID string
Limit int
}
// FailedInboxPage is one bounded page of failed tasks, ordered per
// AC-UI-INBOX-FAILED-001.10/.10a. HasMore mirrors the bundle read's Limit+1
// probe-row convention (AC-UI-INBOX-FAILED-001.12's truncation signal).
type FailedInboxPage struct {
Rows []FailedInboxTaskRow
HasMore bool
}
@@ -0,0 +1,102 @@
package sqlite
import (
"context"
"database/sql"
"fmt"
"github.com/kandev/kandev/internal/db/dialect"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// ListFailedInboxTasks returns the bounded, workspace-scoped page of failed
// tasks for the Inbox Failed tab (docs/specs/ui/system-design/inbox-failed-bucket-01.md,
// "Ordering, paging, and the failure instant" and "Session resolution"). A
// row exists when tasks.state is the terminal failed state and
// tasks.archived_at is null (AC .6, .7); the correlated subquery selects the
// same session AC .30/.30a pick per task, and every ordering key is explicit
// rather than left to either engine's default (AC .10, .10a).
func (r *Repository) ListFailedInboxTasks(ctx context.Context, opts models.ListFailedInboxOptions) (*models.FailedInboxPage, error) {
if opts.Limit < 1 {
return nil, fmt.Errorf("ListFailedInboxTasks: limit must be >= 1, got %d", opts.Limit)
}
drv := r.ro.DriverName()
query := failedInboxQuery(drv)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), string(v1.TaskStateFailed), opts.WorkspaceID, opts.Limit+1)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
taskRows, err := scanFailedInboxRows(rows)
if err != nil {
return nil, err
}
page := &models.FailedInboxPage{Rows: taskRows}
if len(taskRows) > opts.Limit {
page.Rows = taskRows[:opts.Limit]
page.HasMore = true
}
return page, nil
}
// failedInboxSessionOrder is the session-selection order AC .30/.30a specify:
// the primary session first, then most-recently-started, tied broken by the
// lowest session id compared as a byte-ordered string.
func failedInboxSessionOrder(drv string) string {
return fmt.Sprintf("s.is_primary DESC, s.started_at DESC, %s ASC", dialect.ByteOrderedText(drv, "s.id"))
}
// failedInboxRowOrder is the row order AC .10/.10a specify: unresolvable
// failure instants first, then failure instant descending, then task id
// ascending as a byte-ordered string -- explicit rather than left to either
// engine's default null placement (design#Ordering-paging-and-the-failure-instant).
func failedInboxRowOrder(drv string) string {
return fmt.Sprintf(
"(CASE WHEN cs.completed_at IS NULL THEN 0 ELSE 1 END) ASC, cs.completed_at DESC, %s ASC",
dialect.ByteOrderedText(drv, "t.id"),
)
}
func failedInboxQuery(drv string) string {
return fmt.Sprintf(`
SELECT t.id, t.title, t.workspace_id, t.origin, cs.completed_at, cs.error_message
FROM tasks t
LEFT JOIN task_sessions cs ON cs.id = (
SELECT s.id FROM task_sessions s
WHERE s.task_id = t.id
ORDER BY %s
LIMIT 1
)
WHERE t.state = ? AND t.archived_at IS NULL AND t.workspace_id = ?
ORDER BY %s
LIMIT ?
`, failedInboxSessionOrder(drv), failedInboxRowOrder(drv))
}
func scanFailedInboxRows(rows *sql.Rows) ([]models.FailedInboxTaskRow, error) {
var out []models.FailedInboxTaskRow
for rows.Next() {
var row models.FailedInboxTaskRow
var origin sql.NullString
var completedAt sql.NullTime
var errorMessage sql.NullString
if err := rows.Scan(&row.TaskID, &row.Title, &row.WorkspaceID, &origin, &completedAt, &errorMessage); err != nil {
return nil, err
}
row.Origin = origin.String
if completedAt.Valid {
completedAtCopy := completedAt.Time
row.FailureInstant = &completedAtCopy
}
row.Reason = errorMessage.String
out = append(out, row)
}
if err := rows.Err(); err != nil {
return nil, err
}
return out, nil
}
@@ -0,0 +1,126 @@
package sqlite
import (
"context"
"testing"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/testutil"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// TestPostgresListFailedInboxTasksScansDirectCompletedAtColumn proves the
// nullable completed_at column (a direct column reference through the
// correlated-subquery join, not a MIN() aggregate) round-trips through
// PostgreSQL's driver both when present and when the joined session is
// absent -- the SQLite-only run never exercises PostgreSQL's own NULL
// timestamp handling on this path.
func TestPostgresListFailedInboxTasksScansDirectCompletedAtColumn(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()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-pg", Name: "ws-pg"}); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
seedFailedInboxTask(t, repo, "task-pg-resolved", "ws-pg", v1.TaskStateFailed, "")
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-pg-resolved", TaskID: "task-pg-resolved", IsPrimary: true,
StartedAt: mustTime(0), CompletedAt: timePtr(mustTime(10)), ErrorMessage: "boom",
})
seedFailedInboxTask(t, repo, "task-pg-unresolved", "ws-pg", v1.TaskStateFailed, "")
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws-pg", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 2 {
t.Fatalf("expected 2 rows, got %+v", page.Rows)
}
// Unresolvable sorts first.
if page.Rows[0].TaskID != "task-pg-unresolved" || page.Rows[0].FailureInstant != nil {
t.Fatalf("expected the unresolvable row first with a nil instant, got %+v", page.Rows[0])
}
if page.Rows[1].TaskID != "task-pg-resolved" || page.Rows[1].FailureInstant == nil {
t.Fatalf("expected the resolved row second with a non-nil instant, got %+v", page.Rows[1])
}
if !page.Rows[1].FailureInstant.Equal(mustTime(10)) {
t.Fatalf("FailureInstant = %v, want %v", page.Rows[1].FailureInstant, mustTime(10))
}
}
// TestPostgresListFailedInboxTasksTieBreaksByteOrdered proves
// dialect.ByteOrderedText's explicit COLLATE "C" actually forces byte order
// on PostgreSQL for the task-id tiebreak (AC-UI-INBOX-FAILED-001.10a). The
// ids deliberately differ only in case: "B" (0x42) sorts before "a" (0x61)
// in byte/C-locale order, but a language-aware default collation typically
// compares letters primarily case-insensitively and would put "a" first --
// two same-case ids (e.g. "a"/"b") would pass this assertion identically
// whether or not COLLATE "C" is applied, which is exactly the weak fixture
// this replaced. See TestPostgresListFailedInboxTasksSessionTieBreaksByteOrdered
// for the sibling session-id coverage (AC .30a) this file previously lacked
// entirely.
func TestPostgresListFailedInboxTasksTieBreaksByteOrdered(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()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-pg-tie", Name: "ws-pg-tie"}); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
seedFailedInboxTask(t, repo, "task-pg-a", "ws-pg-tie", v1.TaskStateFailed, "")
seedFailedInboxTask(t, repo, "task-pg-B", "ws-pg-tie", v1.TaskStateFailed, "")
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws-pg-tie", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 2 || page.Rows[0].TaskID != "task-pg-B" || page.Rows[1].TaskID != "task-pg-a" {
t.Fatalf("expected byte-ordered [task-pg-B, task-pg-a], got %+v", page.Rows)
}
}
// TestPostgresListFailedInboxTasksSessionTieBreaksByteOrdered proves the same
// COLLATE "C" behavior for the session-id tiebreak (AC-UI-INBOX-FAILED-001.30a)
// -- the sibling test above only ever exercised the task-id tiebreak, so a
// missing collation on the session-id comparison had zero PostgreSQL coverage.
// Mirrors the SQLite-only
// TestListFailedInboxTasks_SessionResolutionTieBreaksByLowestSessionID fixture
// shape (two non-primary sessions on the same task, tied on started_at), with
// case-differing ids for the same reason as above.
func TestPostgresListFailedInboxTasksSessionTieBreaksByteOrdered(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()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-pg-sess-tie", Name: "ws-pg-sess-tie"}); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
seedFailedInboxTask(t, repo, "task-pg-sess-tie", "ws-pg-sess-tie", v1.TaskStateFailed, "")
tie := mustTime(10)
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-pg-a", TaskID: "task-pg-sess-tie", IsPrimary: false,
StartedAt: tie, CompletedAt: timePtr(mustTime(15)), ErrorMessage: "session a",
})
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-pg-B", TaskID: "task-pg-sess-tie", IsPrimary: false,
StartedAt: tie, CompletedAt: timePtr(mustTime(20)), ErrorMessage: "session B",
})
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws-pg-sess-tie", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].Reason != "session B" {
t.Fatalf("expected the tie broken toward the byte-ordered lower session id (sess-pg-B), got %+v", page.Rows)
}
}
@@ -0,0 +1,320 @@
package sqlite
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// seedFailedInboxTask creates a task in the given state/workspace/origin,
// satisfying the failed-task predicate's own fields
// (AC-UI-INBOX-FAILED-001.6, .7, .9a).
func seedFailedInboxTask(t *testing.T, repo *Repository, id, workspaceID string, state v1.TaskState, origin string) {
t.Helper()
if err := repo.CreateTask(context.Background(), &models.Task{
ID: id, WorkspaceID: workspaceID, Title: "task " + id, State: state, Origin: origin,
}); err != nil {
t.Fatalf("create task %s: %v", id, err)
}
}
type failedInboxSessionSeed struct {
ID string
TaskID string
IsPrimary bool
StartedAt time.Time
CompletedAt *time.Time
ErrorMessage string
}
func seedFailedInboxSession(t *testing.T, repo *Repository, s failedInboxSessionSeed) {
t.Helper()
if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{
ID: s.ID,
TaskID: s.TaskID,
IsPrimary: s.IsPrimary,
StartedAt: s.StartedAt,
CompletedAt: s.CompletedAt,
ErrorMessage: s.ErrorMessage,
State: models.TaskSessionStateFailed,
}); err != nil {
t.Fatalf("create session %s: %v", s.ID, err)
}
}
func mustTime(offsetSeconds int) time.Time {
return time.Date(2026, 9, 14, 0, 0, 0, 0, time.UTC).Add(time.Duration(offsetSeconds) * time.Second)
}
func timePtr(tm time.Time) *time.Time { return &tm }
func TestListFailedInboxTasks_OnlyTaskStateFailedListed(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedFailedInboxTask(t, repo, "task-failed", "ws1", v1.TaskStateFailed, "")
seedFailedInboxTask(t, repo, "task-running", "ws1", v1.TaskStateInProgress, "")
// A task whose primary session is failed but whose own state is not
// terminal-failed must list zero rows for it (AC .6).
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-running", TaskID: "task-running", IsPrimary: true,
StartedAt: mustTime(0), CompletedAt: timePtr(mustTime(10)),
})
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].TaskID != "task-failed" {
t.Fatalf("expected exactly [task-failed], got %+v", page.Rows)
}
}
func TestListFailedInboxTasks_ArchivedTaskNeverListed(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedFailedInboxTask(t, repo, "task-archived", "ws1", v1.TaskStateFailed, "")
if err := repo.ArchiveTask(ctx, "task-archived"); err != nil {
t.Fatalf("ArchiveTask: %v", err)
}
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 0 {
t.Fatalf("expected no rows for an archived task, got %+v", page.Rows)
}
}
func TestListFailedInboxTasks_WorkspaceScoped(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedFailedInboxTask(t, repo, "task-ws1", "ws1", v1.TaskStateFailed, "")
seedFailedInboxTask(t, repo, "task-ws2", "ws2", v1.TaskStateFailed, "")
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].TaskID != "task-ws1" {
t.Fatalf("expected exactly [task-ws1], got %+v", page.Rows)
}
}
func TestListFailedInboxTasks_SessionResolutionPrefersPrimaryThenMostRecent(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedFailedInboxTask(t, repo, "task-1", "ws1", v1.TaskStateFailed, "")
// Two non-primary sessions, one primary: primary must win regardless of
// recency (AC .30).
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-old", TaskID: "task-1", IsPrimary: false,
StartedAt: mustTime(0), CompletedAt: timePtr(mustTime(5)), ErrorMessage: "old non-primary",
})
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-primary", TaskID: "task-1", IsPrimary: true,
StartedAt: mustTime(10), CompletedAt: timePtr(mustTime(15)), ErrorMessage: "primary reason",
})
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-newer-non-primary", TaskID: "task-1", IsPrimary: false,
StartedAt: mustTime(20), CompletedAt: timePtr(mustTime(25)), ErrorMessage: "newer non-primary",
})
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 1 {
t.Fatalf("expected 1 row, got %+v", page.Rows)
}
if page.Rows[0].Reason != "primary reason" {
t.Fatalf("expected primary session's reason, got %q", page.Rows[0].Reason)
}
}
func TestListFailedInboxTasks_SessionResolutionMostRecentWhenNonePrimary(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedFailedInboxTask(t, repo, "task-1", "ws1", v1.TaskStateFailed, "")
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-old", TaskID: "task-1", IsPrimary: false,
StartedAt: mustTime(0), CompletedAt: timePtr(mustTime(5)), ErrorMessage: "old",
})
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-new", TaskID: "task-1", IsPrimary: false,
StartedAt: mustTime(10), CompletedAt: timePtr(mustTime(15)), ErrorMessage: "new",
})
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].Reason != "new" {
t.Fatalf("expected the most recently started session's reason, got %+v", page.Rows)
}
}
func TestListFailedInboxTasks_SessionResolutionTieBreaksByLowestSessionID(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedFailedInboxTask(t, repo, "task-1", "ws1", v1.TaskStateFailed, "")
tie := mustTime(10)
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-b", TaskID: "task-1", IsPrimary: false,
StartedAt: tie, CompletedAt: timePtr(mustTime(15)), ErrorMessage: "session b",
})
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-a", TaskID: "task-1", IsPrimary: false,
StartedAt: tie, CompletedAt: timePtr(mustTime(20)), ErrorMessage: "session a",
})
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].Reason != "session a" {
t.Fatalf("expected the tie broken toward the lower session id (sess-a), got %+v", page.Rows)
}
}
func TestListFailedInboxTasks_NoSessionLeavesBothFieldsUnresolvable(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedFailedInboxTask(t, repo, "task-no-session", "ws1", v1.TaskStateFailed, "")
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 1 {
t.Fatalf("a failed task with no session must still be listed, got %+v", page.Rows)
}
row := page.Rows[0]
if row.FailureInstant != nil {
t.Errorf("expected unresolvable failure instant, got %v", row.FailureInstant)
}
if row.Reason != "" {
t.Errorf("expected unresolvable reason, got %q", row.Reason)
}
}
func TestListFailedInboxTasks_SessionWithNoCompletionInstantIsUnresolvable(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedFailedInboxTask(t, repo, "task-1", "ws1", v1.TaskStateFailed, "")
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-1", TaskID: "task-1", IsPrimary: true,
StartedAt: mustTime(0), CompletedAt: nil, ErrorMessage: "boom",
})
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].FailureInstant != nil {
t.Fatalf("expected unresolvable failure instant, got %+v", page.Rows)
}
}
func TestListFailedInboxTasks_OrderingUnresolvableFirstThenInstantDescThenTaskIDAsc(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
// task-late failed most recently among resolved rows -> first of the
// resolved group.
seedFailedInboxTask(t, repo, "task-late", "ws1", v1.TaskStateFailed, "")
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-late", TaskID: "task-late", IsPrimary: true,
StartedAt: mustTime(0), CompletedAt: timePtr(mustTime(100)),
})
seedFailedInboxTask(t, repo, "task-early", "ws1", v1.TaskStateFailed, "")
seedFailedInboxSession(t, repo, failedInboxSessionSeed{
ID: "sess-early", TaskID: "task-early", IsPrimary: true,
StartedAt: mustTime(0), CompletedAt: timePtr(mustTime(10)),
})
// task-unresolvable-b and task-unresolvable-a tie (both unresolvable) and
// must sort by task id ascending, ahead of every resolved row.
seedFailedInboxTask(t, repo, "task-unresolvable-b", "ws1", v1.TaskStateFailed, "")
seedFailedInboxTask(t, repo, "task-unresolvable-a", "ws1", v1.TaskStateFailed, "")
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
got := make([]string, len(page.Rows))
for i, row := range page.Rows {
got[i] = row.TaskID
}
want := []string{"task-unresolvable-a", "task-unresolvable-b", "task-late", "task-early"}
if len(got) != len(want) {
t.Fatalf("expected %v, got %v", want, got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("expected order %v, got %v", want, got)
}
}
}
func TestListFailedInboxTasks_Pagination(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
for i := 0; i < 3; i++ {
seedFailedInboxTask(t, repo, "task-page-"+string(rune('a'+i)), "ws1", v1.TaskStateFailed, "")
}
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 2})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 2 {
t.Fatalf("expected exactly 2 rows (bound), got %d", len(page.Rows))
}
if !page.HasMore {
t.Errorf("expected HasMore=true with 3 rows over a limit of 2")
}
full, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 3})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if full.HasMore {
t.Errorf("expected HasMore=false when the bound exactly covers every row")
}
}
func TestListFailedInboxTasks_RejectsSubOneLimit(t *testing.T) {
repo := newRepoForSessionTests(t)
if _, err := repo.ListFailedInboxTasks(context.Background(), models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 0}); err == nil {
t.Fatal("expected an error for Limit < 1")
}
}
func TestListFailedInboxTasks_OriginCarriedThrough(t *testing.T) {
repo := newRepoForSessionTests(t)
ctx := context.Background()
seedFailedInboxTask(t, repo, "task-1", "ws1", v1.TaskStateFailed, models.TaskOriginAutomationRun)
page, err := repo.ListFailedInboxTasks(ctx, models.ListFailedInboxOptions{WorkspaceID: "ws1", Limit: 50})
if err != nil {
t.Fatalf("ListFailedInboxTasks: %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].Origin != models.TaskOriginAutomationRun {
t.Fatalf("expected origin %q, got %+v", models.TaskOriginAutomationRun, page.Rows)
}
}
@@ -2,15 +2,17 @@ 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";
import type { FailedInboxRow as FailedInboxRowData } from "@/lib/types/failed-inbox";
const mocks = vi.hoisted(() => ({
bumpRefreshTick: vi.fn(),
useFailedInboxController: vi.fn(),
}));
const EMPTY_TESTID = "needs-you-inbox-empty";
const ERROR_TESTID = "needs-you-inbox-error";
let state: {
let needsYouState: {
status: "idle" | "loading" | "ready" | "error";
bundles: ClarificationInboxBundle[];
hiddenCount: number;
@@ -20,12 +22,20 @@ let state: {
};
let workspacesActiveId: string | null;
let failedState: {
rows: FailedInboxRowData[];
count: number;
truncated: boolean;
status: "idle" | "loading" | "ready" | "error";
};
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: workspacesActiveId },
needsYouInbox: { byWorkspaceId: { w1: needsYouState } },
failedInbox: { byWorkspaceId: { w1: failedState } },
workspaces: { activeId: workspacesActiveId, items: [{ id: "w1", name: "Kegmil V2" }] },
bumpNeedsYouInboxRefreshTick: mocks.bumpRefreshTick,
}),
}));
@@ -53,6 +63,14 @@ vi.mock("@/components/needs-you-inbox/needs-you-inbox-hidden-panel", () => ({
),
}));
// The controller owns its own refresh triggers (mount, tab change, workspace
// change, foreground, periodic 60s) and is covered by its own test suite;
// this page-level suite is about which view renders, so the controller is a
// no-op here.
vi.mock("@/hooks/domains/failed-inbox/use-failed-inbox-controller", () => ({
useFailedInboxController: (...args: unknown[]) => mocks.useFailedInboxController(...args),
}));
import { NeedsYouInboxPageClient } from "./needs-you-inbox-page-client";
function bundle(id: string): ClarificationInboxBundle {
@@ -83,13 +101,34 @@ function bundle(id: string): ClarificationInboxBundle {
};
}
function failedRow(id: string): FailedInboxRowData {
return {
task_id: id,
title: `Failed task ${id}`,
workspace_id: "w1",
origin: "manual",
failure_instant: "2026-09-14T00:00:00Z",
reason: "boom",
};
}
function setLocation(path: string) {
window.history.replaceState({}, "", path);
}
beforeEach(() => {
mocks.bumpRefreshTick.mockReset();
state = { status: "idle", bundles: [], hiddenCount: 0, hasMore: false };
mocks.useFailedInboxController.mockReset();
needsYouState = { status: "idle", bundles: [], hiddenCount: 0, hasMore: false };
failedState = { rows: [], count: 0, truncated: false, status: "idle" };
workspacesActiveId = "w1";
setLocation("/needs-you-inbox");
});
afterEach(() => cleanup());
afterEach(() => {
cleanup();
setLocation("/needs-you-inbox");
});
describe("NeedsYouInboxPageClient", () => {
it("titles the route Inbox, which the app top bar owns (design-03#D4)", () => {
@@ -99,7 +138,7 @@ describe("NeedsYouInboxPageClient", () => {
});
it("renders a row per listed bundle", () => {
state = {
needsYouState = {
status: "ready",
bundles: [bundle("p1"), bundle("p2")],
hiddenCount: 0,
@@ -111,14 +150,14 @@ describe("NeedsYouInboxPageClient", () => {
});
it("renders the empty state when the read succeeded with no rows and no truncation", () => {
state = { status: "ready", bundles: [], hiddenCount: 0, hasMore: false };
needsYouState = { status: "ready", bundles: [], hiddenCount: 0, hasMore: false };
render(<NeedsYouInboxPageClient />);
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 };
needsYouState = { status: "error", bundles: [], hiddenCount: 0, hasMore: false };
render(<NeedsYouInboxPageClient />);
expect(screen.getByTestId(ERROR_TESTID)).not.toBeNull();
@@ -126,7 +165,7 @@ describe("NeedsYouInboxPageClient", () => {
});
it("renders the error state for a zero-row truncated page rather than caught up (F42)", () => {
state = { status: "ready", bundles: [], hiddenCount: 0, hasMore: true };
needsYouState = { status: "ready", bundles: [], hiddenCount: 0, hasMore: true };
render(<NeedsYouInboxPageClient />);
expect(screen.getByTestId(ERROR_TESTID)).not.toBeNull();
@@ -134,21 +173,21 @@ describe("NeedsYouInboxPageClient", () => {
});
it("shows the truncation notice when the page is bounded (AC .11)", () => {
state = { status: "ready", bundles: [bundle("p1")], hiddenCount: 0, hasMore: true };
needsYouState = { status: "ready", bundles: [bundle("p1")], hiddenCount: 0, hasMore: true };
render(<NeedsYouInboxPageClient />);
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 };
needsYouState = { status: "ready", bundles: [bundle("p1")], hiddenCount: 0, hasMore: false };
render(<NeedsYouInboxPageClient />);
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 };
needsYouState = { status: "ready", bundles: [bundle("p1")], hiddenCount: 2, hasMore: false };
render(<NeedsYouInboxPageClient />);
expect(screen.getByTestId("stub-hidden-panel").textContent).toBe("2");
@@ -157,12 +196,13 @@ describe("NeedsYouInboxPageClient", () => {
describe("NeedsYouInboxPageClient loading vs. settled/refresh states", () => {
it("shows a loading indicator on the very first read, not the empty state", () => {
state = {
needsYouState = {
status: "loading",
bundles: [],
hiddenCount: 0,
hasMore: false,
appliedGeneration: 0,
lastAppliedOk: false,
};
render(<NeedsYouInboxPageClient />);
@@ -171,7 +211,7 @@ describe("NeedsYouInboxPageClient loading vs. settled/refresh states", () => {
});
it("keeps the settled empty state during a background refresh, instead of re-flashing loading", () => {
state = {
needsYouState = {
status: "loading",
bundles: [],
hiddenCount: 0,
@@ -186,7 +226,7 @@ describe("NeedsYouInboxPageClient loading vs. settled/refresh states", () => {
});
it("keeps the settled list during a background refresh, instead of re-flashing loading", () => {
state = {
needsYouState = {
status: "loading",
bundles: [bundle("p1")],
hiddenCount: 0,
@@ -201,7 +241,7 @@ describe("NeedsYouInboxPageClient loading vs. settled/refresh states", () => {
});
it("does not flash the error state for a truncated boot payload before the first read resolves", () => {
state = {
needsYouState = {
status: "loading",
bundles: [],
hiddenCount: 0,
@@ -222,7 +262,7 @@ describe("NeedsYouInboxPageClient loading vs. settled/refresh states", () => {
// apart. Getting this wrong renders a false "Nothing needs your attention"
// over a read that is still failing.
it("shows the loading indicator, not a false empty state, during a refresh that follows a failed read", () => {
state = {
needsYouState = {
status: "loading",
bundles: [],
hiddenCount: 0,
@@ -242,7 +282,7 @@ describe("NeedsYouInboxPageClient loading vs. settled/refresh states", () => {
// already set. That must read the same as any other pre-first-read state:
// the honest spinner, not a false empty.
it("shows the loading indicator, not a false empty state, for an idle truncated boot seed", () => {
state = {
needsYouState = {
status: "idle",
bundles: [],
hiddenCount: 0,
@@ -263,7 +303,7 @@ describe("NeedsYouInboxPageClient loading vs. settled/refresh states", () => {
// resolve. This must render the empty state, not a permanent spinner.
it("renders the empty state, not a permanent spinner, when no active workspace resolves", () => {
workspacesActiveId = null;
state = {
needsYouState = {
status: "idle",
bundles: [],
hiddenCount: 0,
@@ -277,3 +317,55 @@ describe("NeedsYouInboxPageClient loading vs. settled/refresh states", () => {
expect(screen.queryByRole("status")).toBeNull();
});
});
describe("NeedsYouInboxPageClient — Failed tab (REQ-UI-INBOX-FAILED-001)", () => {
it("renders the tab strip with Needs you selected by default", () => {
render(<NeedsYouInboxPageClient />);
expect(screen.getByRole("tab", { name: /Needs you/ }).getAttribute("aria-selected")).toBe(
"true",
);
});
it("renders the Failed tab directly from ?tab=failed, without first rendering Needs you (AC .2)", () => {
setLocation("/needs-you-inbox?tab=failed");
needsYouState = { status: "ready", bundles: [bundle("p1")], hiddenCount: 0, hasMore: false };
failedState = { rows: [failedRow("t1")], count: 1, truncated: false, status: "ready" };
render(<NeedsYouInboxPageClient />);
expect(screen.getByTestId("failed-inbox-row")).not.toBeNull();
expect(screen.queryByTestId("stub-row")).toBeNull();
});
it("renders Needs you for an unrecognised tab value rather than an error (AC .2)", () => {
setLocation("/needs-you-inbox?tab=bogus");
needsYouState = { status: "ready", bundles: [bundle("p1")], hiddenCount: 0, hasMore: false };
render(<NeedsYouInboxPageClient />);
expect(screen.getByTestId("stub-row")).not.toBeNull();
});
it("shows the Failed tab's own badge count once known, even while Needs you is selected (AC .16)", () => {
failedState = { rows: [failedRow("t1")], count: 1, truncated: false, status: "ready" };
render(<NeedsYouInboxPageClient />);
expect(screen.getByTestId("inbox-tab-failed-badge").textContent).toBe("1");
});
it("passes the failed count into the Needs-you empty state's AC .23 clause", () => {
needsYouState = { status: "ready", bundles: [], hiddenCount: 0, hasMore: false };
failedState = { rows: [failedRow("t1")], count: 1, truncated: false, status: "ready" };
render(<NeedsYouInboxPageClient />);
expect(screen.getByText(/also failed/)).not.toBeNull();
});
it("mounts the failed-inbox controller with the currently selected tab", () => {
setLocation("/needs-you-inbox?tab=failed");
render(<NeedsYouInboxPageClient />);
expect(mocks.useFailedInboxController).toHaveBeenCalledWith("failed");
});
});
@@ -2,22 +2,34 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { TabsContent } from "@kandev/ui/tabs";
import { PageShell } from "@/components/page-shell";
import { useAppStore } from "@/components/state-provider";
import { usePathname, useRouter, useSearchParams } from "@/lib/routing/client-router";
import {
selectNeedsYouInboxBundles,
selectNeedsYouInboxCount,
selectNeedsYouInboxHasMore,
selectNeedsYouInboxHiddenCount,
selectNeedsYouInboxLastAppliedOk,
selectNeedsYouInboxRevision,
selectNeedsYouInboxStatus,
} from "@/lib/state/slices/needs-you-inbox/selectors";
import {
selectFailedInboxCount,
selectFailedInboxCountIsKnown,
selectFailedInboxTruncated,
} from "@/lib/state/slices/failed-inbox/selectors";
import type { NeedsYouInboxReadStatus } from "@/lib/state/slices/needs-you-inbox/types";
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";
import { InboxTabStrip } from "@/components/needs-you-inbox/inbox-tab-strip";
import { FailedInboxTabPanel } from "@/components/needs-you-inbox/failed-inbox-tab-panel";
import { useFailedInboxController } from "@/hooks/domains/failed-inbox/use-failed-inbox-controller";
import { buildInboxTabHref, resolveInboxTab, type InboxTab } from "@/lib/failed-inbox/inbox-tab";
type ViewMode = "error" | "loading" | "empty" | "list";
@@ -77,21 +89,17 @@ function NeedsYouInboxList({
);
}
// 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() {
function NeedsYouInboxTabContent({ retry }: { retry: () => void }) {
const { t } = useTranslation();
const status = useAppStore(selectNeedsYouInboxStatus);
const bundles = useAppStore(selectNeedsYouInboxBundles);
const hiddenCount = useAppStore(selectNeedsYouInboxHiddenCount);
const listRevision = useAppStore(selectNeedsYouInboxRevision);
const hasMore = useAppStore(selectNeedsYouInboxHasMore);
const failedCount = useAppStore(selectFailedInboxCount);
const failedCountKnown = useAppStore(selectFailedInboxCountIsKnown);
const lastAppliedOk = useAppStore(selectNeedsYouInboxLastAppliedOk);
const hasActiveWorkspace = useAppStore((s) => s.workspaces.activeId !== null);
const bumpRefreshTick = useAppStore((s) => s.bumpNeedsYouInboxRefreshTick);
const retry = useCallback(() => bumpRefreshTick(), [bumpRefreshTick]);
const viewMode = resolveViewMode(
status,
@@ -102,7 +110,7 @@ export function NeedsYouInboxPageClient() {
);
return (
<PageShell title={t("sidebar:inbox")} contentClassName="space-y-4 p-6">
<>
{viewMode === "error" && <NeedsYouInboxErrorState onRetry={retry} />}
{viewMode === "loading" && (
<p className="text-sm text-muted-foreground" role="status" aria-live="polite">
@@ -110,7 +118,12 @@ export function NeedsYouInboxPageClient() {
</p>
)}
{viewMode === "empty" && (
<NeedsYouInboxEmptyState hiddenCount={hiddenCount} listRevision={listRevision} />
<NeedsYouInboxEmptyState
hiddenCount={hiddenCount}
listRevision={listRevision}
failedCount={failedCount}
failedCountKnown={failedCountKnown}
/>
)}
{viewMode === "list" && (
<NeedsYouInboxList
@@ -120,6 +133,57 @@ export function NeedsYouInboxPageClient() {
listRevision={listRevision}
/>
)}
</>
);
}
// v1 renders no tab strip and no in-page title (design-01#Components). This
// capability adds the tab strip; the page title still 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 bumpRefreshTick = useAppStore((s) => s.bumpNeedsYouInboxRefreshTick);
const needsYouCount = useAppStore(selectNeedsYouInboxCount);
const needsYouHasMore = useAppStore(selectNeedsYouInboxHasMore);
const failedCount = useAppStore(selectFailedInboxCount);
const failedCountKnown = useAppStore(selectFailedInboxCountIsKnown);
const failedTruncated = useAppStore(selectFailedInboxTruncated);
const pathname = usePathname();
const searchParams = useSearchParams();
const router = useRouter();
const selectedTab = resolveInboxTab(searchParams);
// Mounted only here, never app-wide (design-01#Control-flow): the Failed
// bucket's own refresh triggers, independent of the Needs-you controller
// mounted at the app shell.
useFailedInboxController(selectedTab);
const retry = useCallback(() => bumpRefreshTick(), [bumpRefreshTick]);
const selectTab = useCallback(
(tab: InboxTab) => router.replace(buildInboxTabHref(pathname, tab, searchParams)),
[router, pathname, searchParams],
);
return (
<PageShell title={t("sidebar:inbox")} contentClassName="space-y-4 p-6">
<InboxTabStrip
selectedTab={selectedTab}
onSelectTab={selectTab}
needsYouCount={needsYouCount}
needsYouHasMore={needsYouHasMore}
failedCount={failedCountKnown ? failedCount : undefined}
failedTruncated={failedTruncated}
>
<TabsContent value="needs-you">
<NeedsYouInboxTabContent retry={retry} />
</TabsContent>
<TabsContent value="failed">
<FailedInboxTabPanel />
</TabsContent>
</InboxTabStrip>
</PageShell>
);
}
@@ -69,6 +69,7 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
badge={needsYouInboxCount}
badgeSuffix={needsYouInboxHasMore ? "+" : undefined}
collapsed={collapsed}
testId="sidebar-needs-you-inbox"
/>
)}
{workspaceId && collapsed && (
@@ -0,0 +1,36 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
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 } }),
}));
import { FailedInboxEmptyState } from "./failed-inbox-empty-state";
afterEach(() => {
cleanup();
workspaceItems = [{ id: "w1", name: "Kegmil V2" }];
});
describe("FailedInboxEmptyState", () => {
it("names the active workspace rather than the whole instance (AC .21)", () => {
render(<FailedInboxEmptyState />);
expect(screen.getByText("Nothing has failed in Kegmil V2.")).not.toBeNull();
});
it("names no workspace when the active workspace cannot be resolved (AC .21)", () => {
workspaceItems = [];
render(<FailedInboxEmptyState />);
expect(screen.getByText("Nothing has failed in this workspace.")).not.toBeNull();
});
it("does not congratulate the operator (AC .21)", () => {
const { container } = render(<FailedInboxEmptyState />);
expect(container.textContent).not.toContain("!");
expect(container.textContent).not.toContain("caught up");
});
});
@@ -0,0 +1,27 @@
"use client";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
// An empty Failed bucket 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 (AC-UI-INBOX-FAILED-001.21).
export function FailedInboxEmptyState() {
const { t } = useTranslation();
const workspaceName = useAppStore(
(s) => s.workspaces?.items?.find((w) => w.id === s.workspaces.activeId)?.name,
);
return (
<div
className="rounded-lg border border-border p-8 text-center"
data-testid="failed-inbox-empty"
>
<p className="text-sm font-medium">
{workspaceName
? t("failedInbox:emptyTitle", { workspace: workspaceName })
: t("failedInbox:emptyTitleUnnamedWorkspace")}
</p>
<p className="mt-1 text-xs text-muted-foreground">{t("failedInbox:emptyDescription")}</p>
</div>
);
}
@@ -0,0 +1,22 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { FailedInboxErrorState } from "./failed-inbox-error-state";
afterEach(() => cleanup());
describe("FailedInboxErrorState", () => {
it("renders an alert distinct from the empty state and does not claim nothing failed (AC .22)", () => {
render(<FailedInboxErrorState />);
const alert = screen.getByTestId("failed-inbox-error");
expect(alert.getAttribute("role")).toBe("alert");
expect(alert.textContent).not.toContain("Nothing has failed");
});
// design-01#Failure-and-recovery: recovery needs no operator action, so the
// error state carries no retry affordance -- the periodic and foreground
// triggers already re-read within the same bound the rows are.
it("renders no retry control", () => {
render(<FailedInboxErrorState />);
expect(screen.queryByRole("button")).toBeNull();
});
});
@@ -0,0 +1,22 @@
"use client";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
// No retry control: recovery needs no operator action here (design-01#Failure-and-recovery)
// -- the periodic and foreground-return triggers already re-read within the
// same bound the rows are, so a retry button would offer nothing.
export function FailedInboxErrorState() {
const { t } = useTranslation();
return (
<div
className="rounded-lg border border-border p-8 text-center"
data-testid="failed-inbox-error"
role="alert"
>
<IconAlertTriangle className="mx-auto h-6 w-6 text-destructive" />
<p className="mt-2 text-sm font-medium">{t("failedInbox:errorTitle")}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("failedInbox:errorDescription")}</p>
</div>
);
}
@@ -0,0 +1,104 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { FailedInboxRow as FailedInboxRowData } from "@/lib/types/failed-inbox";
import { FailedInboxRow } from "./failed-inbox-row";
const ORIGIN_MARKER_TESTID = "failed-inbox-origin-marker";
function row(overrides: Partial<FailedInboxRowData> = {}): FailedInboxRowData {
return {
task_id: "t1",
title: "Sync repository",
workspace_id: "w1",
origin: "manual",
failure_instant: "2026-09-14T00:00:00Z",
reason: "connection refused",
...overrides,
};
}
afterEach(() => cleanup());
describe("FailedInboxRow", () => {
it("renders the title, reason, and an open-task control (AC .18)", () => {
render(<FailedInboxRow row={row()} />);
expect(screen.getByText("Sync repository")).not.toBeNull();
expect(screen.getByText("connection refused")).not.toBeNull();
const link = screen.getByTestId("failed-inbox-open-task") as HTMLAnchorElement;
expect(link.getAttribute("href")).toBe("/t/t1");
});
it("renders the task-state failed marker, not the session-state one, from the shared state-icon module (AC .18a)", () => {
// TASK_STATE_ICONS.FAILED (IconX) and SESSION_STATE_ICONS.FAILED
// (IconAlertTriangle) share the same wrapper testid and red styling, so
// asserting only that a status icon exists cannot catch a regression to
// the forbidden session-state pairing -- the rendered glyph itself has
// to be checked.
const { container } = render(<FailedInboxRow row={row()} />);
const icon = container.querySelector('[data-testid="failed-inbox-status-icon"]');
expect(icon).not.toBeNull();
expect(icon!.querySelector(".tabler-icon-x")).not.toBeNull();
expect(icon!.querySelector(".tabler-icon-alert-triangle")).toBeNull();
});
it("presents the shared thread-status vocabulary's failed status (AC .20/.20a)", () => {
render(<FailedInboxRow row={row()} />);
expect(screen.getByRole("img", { name: "Failed" })).not.toBeNull();
});
it("falls back to stated copy when the reason is empty (AC .19)", () => {
render(<FailedInboxRow row={row({ reason: "" })} />);
expect(screen.getByTestId("failed-inbox-reason-fallback")).not.toBeNull();
});
it("falls back to stated copy when the reason is only whitespace (AC .19)", () => {
render(<FailedInboxRow row={row({ reason: " " })} />);
expect(screen.getByTestId("failed-inbox-reason-fallback")).not.toBeNull();
});
it("renders no origin marker for the manual origin (AC .9a)", () => {
render(<FailedInboxRow row={row({ origin: "manual" })} />);
expect(screen.queryByTestId(ORIGIN_MARKER_TESTID)).toBeNull();
});
it("renders no origin marker for an absent origin (AC .9a)", () => {
render(<FailedInboxRow row={row({ origin: "" })} />);
expect(screen.queryByTestId(ORIGIN_MARKER_TESTID)).toBeNull();
});
it("renders a distinct marker for a non-person origin (AC .9a)", () => {
render(<FailedInboxRow row={row({ origin: "automation_run" })} />);
expect(screen.getByTestId(ORIGIN_MARKER_TESTID)).not.toBeNull();
});
it("renders the generic marker for an unenumerated non-empty origin (AC .9a)", () => {
render(<FailedInboxRow row={row({ origin: "something_new" })} />);
expect(screen.getByTestId(ORIGIN_MARKER_TESTID)).not.toBeNull();
});
it("states the failure time is unknown rather than substituting one when unresolvable (AC .30)", () => {
render(<FailedInboxRow row={row({ failure_instant: undefined })} />);
expect(screen.getByTestId("failed-inbox-unknown-time")).not.toBeNull();
});
it("states the failure time is unknown for a syntactically malformed wire value rather than rendering Date's lenient parse of it", () => {
// `new Date("0")` parses to 1970, not NaN, so a naive `formatRelativeTime`
// call would render a plausible-but-wrong age instead of the fallback.
render(<FailedInboxRow row={row({ failure_instant: "0" })} />);
expect(screen.getByTestId("failed-inbox-unknown-time")).not.toBeNull();
});
it("states the failure time is unknown for a calendar-invalid wire value Date.parse would otherwise normalize", () => {
// `new Date("2026-02-30T10:00:00Z")` normalizes to March 2 instead of
// rejecting the nonexistent date.
render(<FailedInboxRow row={row({ failure_instant: "2026-02-30T10:00:00Z" })} />);
expect(screen.getByTestId("failed-inbox-unknown-time")).not.toBeNull();
});
it("carries a session id in the task href when the row has none, since the row model carries none", () => {
render(<FailedInboxRow row={row()} />);
const link = screen.getByTestId("failed-inbox-open-task") as HTMLAnchorElement;
expect(link.getAttribute("href")).not.toContain("sessionId");
});
});
@@ -0,0 +1,96 @@
"use client";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import Link from "@/components/routing/app-link";
import { formatRelativeTime } from "@/lib/i18n/formats";
import { getTaskStateIcon } from "@/lib/ui/state-icons";
import { FAILED_TASK_STATUS } from "@/lib/threads/thread-session-status";
import { parseStrictRfc3339Timestamp } from "@/lib/utils/strict-timestamp";
import {
failedInboxOriginMarkerKey,
hasResolvableFailedInboxReason,
} from "@/lib/failed-inbox/row-presentation";
import type { FailedInboxRow as FailedInboxRowData } from "@/lib/types/failed-inbox";
// The row addresses a task, never a session (design-01#Session-resolution):
// the open-task control needs only the task id.
function taskHrefForRow(row: FailedInboxRowData): string {
return `/t/${row.task_id}`;
}
export function FailedInboxRow({ row }: { row: FailedInboxRowData }) {
const { t } = useTranslation();
const originMarkerKey = failedInboxOriginMarkerKey(row.origin);
const reasonText = hasResolvableFailedInboxReason(row.reason)
? row.reason
: t("failedInbox:reasonUnknown");
// Date.parse (which formatRelativeTime's `new Date(...)` uses internally)
// normalizes malformed or non-RFC3339 wire values -- e.g. "0" becomes 1970
// and "2026-02-30" becomes March 2 -- into a plausible but wrong instant
// instead of failing, so shape validation must happen first or the unknown-
// time fallback below is silently bypassed.
const relativeTime =
row.failure_instant && parseStrictRfc3339Timestamp(row.failure_instant) !== null
? formatRelativeTime(row.failure_instant)
: null;
return (
<div
className="flex items-start gap-3 px-4 py-2.5 md:items-center"
data-testid="failed-inbox-row"
>
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted"
data-testid="failed-inbox-status-icon"
role="img"
aria-label={t(FAILED_TASK_STATUS.labelKey)}
>
{getTaskStateIcon("FAILED")}
</span>
<div className="flex min-w-0 flex-1 flex-col md:contents">
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{row.title}</span>
<span className="block truncate text-xs text-muted-foreground">
{!hasResolvableFailedInboxReason(row.reason) ? (
<span data-testid="failed-inbox-reason-fallback">{reasonText}</span>
) : (
reasonText
)}
</span>
{originMarkerKey && (
<span
className="block truncate text-xs text-muted-foreground"
data-testid="failed-inbox-origin-marker"
>
{t(originMarkerKey)}
</span>
)}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{relativeTime !== null && relativeTime !== "" ? (
relativeTime
) : (
<span data-testid="failed-inbox-unknown-time">
{t("failedInbox:unknownFailureTime")}
</span>
)}
</span>
</div>
{/* Always visible at every viewport width -- AC-UI-INBOX-FAILED-001.18
requires the open-task control be reachable where a fixed-width
button would otherwise be withheld, and this row has no actions
menu to fall back to like the Needs-you row does. */}
<Button
asChild
variant="outline"
size="sm"
className="shrink-0 cursor-pointer max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
>
<Link href={taskHrefForRow(row)} data-testid="failed-inbox-open-task">
{t("failedInbox:openTask")}
</Link>
</Button>
</div>
);
}
@@ -0,0 +1,108 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { FailedInboxRow as FailedInboxRowData } from "@/lib/types/failed-inbox";
import { FailedInboxTabPanel } from "./failed-inbox-tab-panel";
vi.mock("@/components/state-provider", async () => {
const actual = await vi.importActual<typeof import("@/components/state-provider")>(
"@/components/state-provider",
);
return { ...actual, useAppStore: (selector: (s: unknown) => unknown) => selector(mockState) };
});
let mockState: {
workspaces: { activeId: string; items: Array<{ id: string; name: string }> };
failedInbox: {
byWorkspaceId: Record<
string,
{ rows: FailedInboxRowData[]; count: number; truncated: boolean; status: string }
>;
};
};
function row(id: string): FailedInboxRowData {
return {
task_id: id,
title: `Task ${id}`,
workspace_id: "w1",
origin: "manual",
failure_instant: "2026-09-14T00:00:00Z",
reason: "boom",
};
}
beforeEach(() => {
mockState = {
workspaces: { activeId: "w1", items: [{ id: "w1", name: "Kegmil V2" }] },
failedInbox: { byWorkspaceId: {} },
};
});
afterEach(() => cleanup());
describe("FailedInboxTabPanel", () => {
it("shows a loading indicator on the very first read", () => {
mockState.failedInbox.byWorkspaceId.w1 = {
rows: [],
count: 0,
truncated: false,
status: "loading",
};
render(<FailedInboxTabPanel />);
expect(screen.getByRole("status")).not.toBeNull();
});
it("shows a loading indicator, not the empty state, before the first read ever completes", () => {
// No entry under w1 yet -- the slice's idle default, not a successful
// empty read. A false "nothing has failed" would misrepresent an unread
// workspace as a confirmed-empty one.
render(<FailedInboxTabPanel />);
expect(screen.getByRole("status")).not.toBeNull();
expect(screen.queryByTestId("failed-inbox-empty")).toBeNull();
});
it("renders the error state, not the empty state, when the read failed (AC .22)", () => {
mockState.failedInbox.byWorkspaceId.w1 = {
rows: [],
count: 0,
truncated: false,
status: "error",
};
render(<FailedInboxTabPanel />);
expect(screen.getByTestId("failed-inbox-error")).not.toBeNull();
expect(screen.queryByTestId("failed-inbox-empty")).toBeNull();
});
it("renders the empty state when the read succeeded with no rows", () => {
mockState.failedInbox.byWorkspaceId.w1 = {
rows: [],
count: 0,
truncated: false,
status: "ready",
};
render(<FailedInboxTabPanel />);
expect(screen.getByTestId("failed-inbox-empty")).not.toBeNull();
});
it("renders a row per listed failed task", () => {
mockState.failedInbox.byWorkspaceId.w1 = {
rows: [row("t1"), row("t2")],
count: 2,
truncated: false,
status: "ready",
};
render(<FailedInboxTabPanel />);
expect(screen.getAllByTestId("failed-inbox-row")).toHaveLength(2);
});
it("shows a truncation notice when the page is bounded", () => {
mockState.failedInbox.byWorkspaceId.w1 = {
rows: [row("t1")],
count: 1,
truncated: true,
status: "ready",
};
render(<FailedInboxTabPanel />);
expect(screen.getByTestId("failed-inbox-truncated")).not.toBeNull();
});
});
@@ -0,0 +1,56 @@
"use client";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import {
selectFailedInboxRows,
selectFailedInboxStatus,
selectFailedInboxTruncated,
} from "@/lib/state/slices/failed-inbox/selectors";
import { FailedInboxRow } from "./failed-inbox-row";
import { FailedInboxEmptyState } from "./failed-inbox-empty-state";
import { FailedInboxErrorState } from "./failed-inbox-error-state";
type ViewMode = "error" | "loading" | "empty" | "list";
function resolveViewMode(status: string, rowCount: number): ViewMode {
if (status === "error") return "error";
if ((status === "loading" || status === "idle") && rowCount === 0) return "loading";
if (rowCount === 0) return "empty";
return "list";
}
export function FailedInboxTabPanel() {
const { t } = useTranslation();
const status = useAppStore(selectFailedInboxStatus);
const rows = useAppStore(selectFailedInboxRows);
const truncated = useAppStore(selectFailedInboxTruncated);
const viewMode = resolveViewMode(status, rows.length);
return (
<div className="space-y-4">
{viewMode === "error" && <FailedInboxErrorState />}
{viewMode === "loading" && (
<p className="text-sm text-muted-foreground" role="status" aria-live="polite">
{t("common:loading")}
</p>
)}
{viewMode === "empty" && <FailedInboxEmptyState />}
{viewMode === "list" && (
<>
<div className="overflow-hidden rounded-lg border border-border divide-y divide-border">
{rows.map((row) => (
<FailedInboxRow key={row.task_id} row={row} />
))}
</div>
{truncated && (
<p className="text-xs text-muted-foreground" data-testid="failed-inbox-truncated">
{t("failedInbox:truncatedNotice")}
</p>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,95 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TabsContent } from "@kandev/ui/tabs";
import type { ReactNode } from "react";
import { InboxTabStrip } from "./inbox-tab-strip";
import type { InboxTab } from "@/lib/failed-inbox/inbox-tab";
const FAILED_BADGE_TESTID = "inbox-tab-failed-badge";
function renderStrip(
overrides: Partial<{
selectedTab: InboxTab;
onSelectTab: (tab: InboxTab) => void;
needsYouCount: number;
needsYouHasMore: boolean;
failedCount: number | undefined;
failedTruncated: boolean;
children?: ReactNode;
}> = {},
) {
return render(
<InboxTabStrip
selectedTab="needs-you"
onSelectTab={vi.fn()}
needsYouCount={0}
needsYouHasMore={false}
failedCount={undefined}
failedTruncated={false}
{...overrides}
/>,
);
}
afterEach(() => cleanup());
describe("InboxTabStrip", () => {
it("renders exactly two tabs, Needs you then Failed (AC .1)", () => {
renderStrip();
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(2);
expect(tabs[0].textContent).toContain("Needs you");
expect(tabs[1].textContent).toContain("Failed");
});
it("calls onSelectTab with the clicked tab's value", () => {
const onSelectTab = vi.fn();
renderStrip({ onSelectTab });
// Radix TabsTrigger switches tabs on mousedown, not click (@radix-ui/react-tabs).
fireEvent.mouseDown(screen.getByRole("tab", { name: /Failed/ }));
expect(onSelectTab).toHaveBeenCalledWith("failed");
});
it("renders no failed badge before a response is known (AC .16)", () => {
renderStrip();
expect(screen.queryByTestId(FAILED_BADGE_TESTID)).toBeNull();
});
it("renders no failed badge when the known count is zero (AC .16)", () => {
renderStrip({ failedCount: 0 });
expect(screen.queryByTestId(FAILED_BADGE_TESTID)).toBeNull();
});
it("renders the failed badge once a non-zero count is known", () => {
renderStrip({ failedCount: 3 });
expect(screen.getByTestId(FAILED_BADGE_TESTID).textContent).toBe("3");
});
it("renders a capped indicator when the failed page is truncated (AC .17)", () => {
renderStrip({ failedCount: 200, failedTruncated: true });
expect(screen.getByTestId(FAILED_BADGE_TESTID).textContent).toBe("200+");
});
it("stays visible while the other tab is selected (AC .16)", () => {
renderStrip({ selectedTab: "failed", failedCount: 2 });
expect(screen.getByTestId(FAILED_BADGE_TESTID)).not.toBeNull();
});
it("carries a coarse-pointer/mobile 44px touch-target floor on every tab trigger", () => {
renderStrip();
for (const tab of screen.getAllByRole("tab")) {
expect(tab.className).toContain("min-h-11");
}
});
it("connects a tab trigger to its content panel", () => {
renderStrip({
children: <TabsContent value="needs-you">Needs you content</TabsContent>,
});
const tab = screen.getByRole("tab", { name: /Needs you/ });
const panel = screen.getByRole("tabpanel");
expect(tab.getAttribute("aria-controls")).toBe(panel.getAttribute("id"));
expect(panel.getAttribute("aria-labelledby")).toBe(tab.getAttribute("id"));
});
});
@@ -0,0 +1,64 @@
"use client";
import { useTranslation } from "react-i18next";
import { Tabs, TabsList, TabsTrigger } from "@kandev/ui/tabs";
import type { ReactNode } from "react";
import { Badge } from "@kandev/ui/badge";
import type { InboxTab } from "@/lib/failed-inbox/inbox-tab";
// Absent for a not-yet-known or zero count; a truncated count carries a "+"
// suffix (AC-UI-INBOX-FAILED-001.16, .17) -- the same capped presentation the
// sidebar badge already uses.
function badgeText(count: number | undefined, truncated: boolean): string | null {
if (typeof count !== "number" || count <= 0) return null;
return truncated ? `${count}+` : `${count}`;
}
export function InboxTabStrip({
selectedTab,
onSelectTab,
needsYouCount,
needsYouHasMore,
failedCount,
failedTruncated,
children,
}: {
selectedTab: InboxTab;
onSelectTab: (tab: InboxTab) => void;
needsYouCount: number;
needsYouHasMore: boolean;
failedCount: number | undefined;
failedTruncated: boolean;
children?: ReactNode;
}) {
const { t } = useTranslation();
const needsYouBadge = badgeText(needsYouCount, needsYouHasMore);
const failedBadge = badgeText(failedCount, failedTruncated);
return (
<Tabs value={selectedTab} onValueChange={(value) => onSelectTab(value as InboxTab)}>
<TabsList variant="line" className="max-md:min-h-11 [@media(pointer:coarse)]:min-h-11">
<TabsTrigger
value="needs-you"
className="max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
>
{t("needsYouInbox:tabLabel")}
{needsYouBadge && (
<Badge variant="secondary" data-testid="inbox-tab-needs-you-badge">
{needsYouBadge}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="failed" className="max-md:min-h-11 [@media(pointer:coarse)]:min-h-11">
{t("failedInbox:tabLabel")}
{failedBadge && (
<Badge variant="secondary" data-testid="inbox-tab-failed-badge">
{failedBadge}
</Badge>
)}
</TabsTrigger>
</TabsList>
{children}
</Tabs>
);
}
@@ -68,3 +68,37 @@ describe("NeedsYouInboxEmptyState", () => {
expect(screen.getByTestId("needs-you-inbox-hidden-panel")).not.toBeNull();
});
});
// AC-UI-INBOX-FAILED-001.4: this is the single permitted delta to the
// Needs-you empty state; every case above (workspace naming, no
// congratulation, hidden-bundle disclosure) renders identically when the new
// props are omitted, which is this suite's own before/after proof.
describe("NeedsYouInboxEmptyState — AC-UI-INBOX-FAILED-001.23 failed-tasks clause", () => {
it("omits the clause entirely when the failed count is not yet known", () => {
render(<NeedsYouInboxEmptyState hiddenCount={0} />);
expect(screen.queryByText(/Failed tab/)).toBeNull();
});
it("omits the clause when the last failed read failed", () => {
render(<NeedsYouInboxEmptyState hiddenCount={0} failedCountKnown={false} failedCount={3} />);
expect(screen.queryByText(/Failed tab/)).toBeNull();
});
it("says a failed task exists rather than implying the workspace is quiet, on first render", () => {
render(<NeedsYouInboxEmptyState hiddenCount={0} failedCountKnown={true} failedCount={2} />);
expect(
screen.getByText(
"This list does not count failed tasks. 2 tasks have also failed. See the Failed tab.",
),
).not.toBeNull();
});
it("does not imply any failed task exists when the workspace holds none", () => {
render(<NeedsYouInboxEmptyState hiddenCount={0} failedCountKnown={true} failedCount={0} />);
expect(
screen.getByText(
"This list does not count failed tasks. None have failed in this workspace right now; check the Failed tab any time.",
),
).not.toBeNull();
});
});
@@ -11,9 +11,15 @@ import { NeedsYouInboxHiddenPanel } from "./needs-you-inbox-hidden-panel";
export function NeedsYouInboxEmptyState({
hiddenCount,
listRevision = 0,
failedCount,
failedCountKnown = false,
}: {
hiddenCount: number;
listRevision?: number;
/** The Failed tab's own count, read only for the AC-UI-INBOX-FAILED-001.23
* clause below -- never written from here (design-01#Count-separation). */
failedCount?: number;
failedCountKnown?: boolean;
}) {
const { t } = useTranslation();
const workspaceName = useAppStore(
@@ -31,6 +37,16 @@ export function NeedsYouInboxEmptyState({
: t("needsYouInbox:emptyTitleUnnamedWorkspace")}
</p>
<p className="mt-1 text-xs text-muted-foreground">{t("needsYouInbox:emptyDescription")}</p>
{/* AC-UI-INBOX-FAILED-001.23: omitted while the failed count is not
known (no read applied yet, or the last one failed), so this never
asserts presence or absence it cannot back up. */}
{failedCountKnown && (
<p className="mt-1 text-xs text-muted-foreground">
{failedCount && failedCount > 0
? t("needsYouInbox:failedClauseSome", { count: failedCount })
: t("needsYouInbox:failedClauseNone")}
</p>
)}
</div>
{hiddenCount > 0 && (
<NeedsYouInboxHiddenPanel hiddenCount={hiddenCount} listRevision={listRevision} />
+2
View File
@@ -1447,6 +1447,7 @@ export class ApiClient {
repositoryId?: string;
startedAt?: string;
completedAt?: string;
errorMessage?: string;
commandCount?: number;
metadata?: Record<string, unknown>;
},
@@ -1460,6 +1461,7 @@ export class ApiClient {
if (opts.repositoryId !== undefined) body.repository_id = opts.repositoryId;
if (opts.startedAt !== undefined) body.started_at = opts.startedAt;
if (opts.completedAt !== undefined) body.completed_at = opts.completedAt;
if (opts.errorMessage !== undefined) body.error_message = opts.errorMessage;
if (opts.commandCount !== undefined) body.command_count = opts.commandCount;
if (opts.metadata !== undefined) body.metadata = opts.metadata;
return this.request("POST", "/api/v1/_test/task-sessions", body);
@@ -0,0 +1,239 @@
// AC-UI-INBOX-FAILED-001.29: observing the Failed tab's count before that tab
// is ever selected, then selecting it, observing a failed task listed with
// its reason and relative failure time, opening the task from the row, and
// observing that the sidebar count did not change when the failed task
// appeared.
import { test, expect } from "../../fixtures/test-base";
import { injectLatency, waitForHttp } from "../../helpers/causal-waits";
test.describe("Inbox Failed tab", () => {
test("lists a failed task without inflating the sidebar count (AC .29)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
const title = "Inbox Failed Tab Fixture";
const reason = "Simulated failure for the Inbox Failed tab";
const { task_id: taskId } = await apiClient.seedTask(seedData.workspaceId, title, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.seedTaskSession(taskId, {
state: "FAILED",
completedAt: new Date().toISOString(),
errorMessage: reason,
});
await apiClient.updateTaskState(taskId, "FAILED");
await testPage.goto("/needs-you-inbox");
// The sidebar's "someone is blocked on you" count must never move when a
// failed task exists (AC .14) -- captured before the Failed tab is ever
// selected, so an accidental read into the shared count would show here.
const sidebarInbox = testPage.getByTestId("sidebar-needs-you-inbox");
await expect(sidebarInbox).toBeVisible();
const sidebarTextBeforeFailedTabSelected = await sidebarInbox.textContent();
// The tab strip's own Failed count becomes visible on Inbox mount --
// before the Failed tab is ever the selected one (AC .29's ordering).
const failedBadge = testPage.getByTestId("inbox-tab-failed-badge");
await expect(failedBadge).toHaveText("1", { timeout: 30_000 });
await testPage.getByRole("tab", { name: /Failed/ }).click();
const row = testPage.getByTestId("failed-inbox-row").filter({ hasText: title });
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toContainText(reason);
// A real failure instant renders a relative time, not the "unknown" fallback.
await expect(row.getByTestId("failed-inbox-unknown-time")).toHaveCount(0);
await row.getByTestId("failed-inbox-open-task").click();
await expect(testPage).toHaveURL(new RegExp(`/t/${taskId}$`));
const sidebarTextAfterOpeningTask = await sidebarInbox.textContent();
expect(sidebarTextAfterOpeningTask).toBe(sidebarTextBeforeFailedTabSelected);
});
// AC-UI-INBOX-FAILED-001.16: "Changing the active workspace shall clear it
// until a response for the new one is applied." Regression coverage for the
// Review-round-2 blocker: returning to a workspace visited earlier in the
// same session must not keep showing that workspace's stale ready badge
// while the fresh read for it is still in flight.
test("clears the stale badge while re-reading a re-visited workspace (AC .16)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
const title = "Workspace Switch Back Fixture";
const { task_id: taskId } = await apiClient.seedTask(seedData.workspaceId, title, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.seedTaskSession(taskId, {
state: "FAILED",
completedAt: new Date().toISOString(),
errorMessage: "Simulated failure for the workspace-switch regression",
});
await apiClient.updateTaskState(taskId, "FAILED");
const workspaceB = await apiClient.createWorkspace("Failed Tab Switch Target");
const failedBadge = testPage.getByTestId("inbox-tab-failed-badge");
// First visit to workspace A's Failed tab: reads to "ready" with 1 row,
// the cached state this test later returns to.
await testPage.goto("/needs-you-inbox");
await expect(failedBadge).toHaveText("1", { timeout: 30_000 });
// Switch to workspace B (client-side nav; unmounts the Inbox page and its
// controller) and visit its own empty Failed tab, so the slice's
// last-active-workspace tracking actually flips away from A.
await testPage.getByTestId("sidebar-workspace-trigger").click();
await testPage.getByTestId(`sidebar-workspace-item-${workspaceB.id}`).click();
await expect(testPage).toHaveURL((url) => url.pathname === "/");
await testPage.getByTestId("sidebar-needs-you-inbox").click();
await expect(testPage).toHaveURL(/\/needs-you-inbox$/);
// Anchor the absence check to a point after the tab strip has actually
// rendered -- checking `toHaveCount(0)` immediately after the URL change
// could pass trivially during the pre-mount window and never re-poll
// once satisfied, so it would not reliably catch a badge that renders
// (correctly or, if regressed, stale) a few milliseconds later.
await expect(testPage.getByRole("tab", { name: /Failed/ })).toBeVisible();
await expect(failedBadge).toHaveCount(0);
// Switch back to workspace A (still client-side; the store is never torn
// down) and delay only the next failed-inbox read, so the in-flight
// "loading" window is observable instead of resolving instantly.
await testPage.getByTestId("sidebar-workspace-trigger").click();
await testPage.getByTestId(`sidebar-workspace-item-${seedData.workspaceId}`).click();
await expect(testPage).toHaveURL((url) => url.pathname === "/");
let delayNextFailedInboxRead = false;
await testPage.route("**/api/v1/failed-inbox*", async (route) => {
if (delayNextFailedInboxRead) {
delayNextFailedInboxRead = false;
await injectLatency(
1500,
"make the re-visited workspace's loading window observable before it resolves",
);
}
await route.continue();
});
delayNextFailedInboxRead = true;
const rereadForA = waitForHttp(testPage, "GET", /\/failed-inbox$/, {
predicate: (response) =>
new URL(response.url()).searchParams.get("workspace_id") === seedData.workspaceId,
});
await testPage.getByTestId("sidebar-needs-you-inbox").click();
await expect(testPage).toHaveURL(/\/needs-you-inbox$/);
// Anchored the same way as the switch-to-B assertion above.
await expect(testPage.getByRole("tab", { name: /Failed/ })).toBeVisible();
// The bug this covers: workspace A's own cache was still "ready" from the
// first visit, so the badge kept showing its stale "1" immediately
// instead of clearing while this re-read is in flight.
await expect(failedBadge).toHaveCount(0);
await rereadForA;
await expect(failedBadge).toHaveText("1", { timeout: 15_000 });
});
// Regression coverage for the Review-round-3 blocker: the sibling test
// above visits workspace B's own Failed tab before switching back, which is
// exactly what let the stale cache go undetected -- `beginFailedInboxRead`
// is only ever called while the Inbox is mounted, so a switch that happens
// with the Inbox closed throughout must still be caught the next time this
// workspace is read.
test("clears a re-visited workspace's stale badge even when the switch away happened with the Inbox closed (AC .16)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
const title = "Workspace Switch Back While Closed Fixture";
const { task_id: taskId } = await apiClient.seedTask(seedData.workspaceId, title, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.seedTaskSession(taskId, {
state: "FAILED",
completedAt: new Date().toISOString(),
errorMessage: "Simulated failure for the closed-Inbox workspace-switch regression",
});
await apiClient.updateTaskState(taskId, "FAILED");
const workspaceB = await apiClient.createWorkspace("Failed Tab Switch Target (Closed)");
const failedBadge = testPage.getByTestId("inbox-tab-failed-badge");
// First visit to workspace A's Failed tab: reads to "ready" with 1 row,
// the cached state this test later returns to.
await testPage.goto("/needs-you-inbox");
await expect(failedBadge).toHaveText("1", { timeout: 30_000 });
// Switch to workspace B but never open its Inbox -- stay on the landing
// page. Nothing here calls the failed-inbox read for B at all, unlike
// the sibling test above. The switcher is a controlled Radix dropdown
// (app-sidebar-header.tsx wires its open state through the store) whose
// content has a CSS exit animation (dropdown-menu.tsx's
// `data-closed:animate-out ... duration-100`); Radix keeps the departing
// item mounted until that animation ends, after `aria-expanded` has
// already flipped. Both switches land on pathname "/" (only the
// `workspaceId` query param changes), so the routed page never remounts
// to force a settle the way every other picker test's route change does
// -- wait for the departing item to actually leave the DOM before
// reopening the same trigger, or the second open can race Radix's
// in-flight close and land unstable.
const workspaceTrigger = testPage.getByTestId("sidebar-workspace-trigger");
const itemB = testPage.getByTestId(`sidebar-workspace-item-${workspaceB.id}`);
await workspaceTrigger.click();
await itemB.click();
await expect(testPage).toHaveURL((url) => url.pathname === "/");
await expect(itemB).toHaveCount(0);
// Switch back to workspace A, still without ever having opened the Inbox
// on B.
const itemA = testPage.getByTestId(`sidebar-workspace-item-${seedData.workspaceId}`);
await workspaceTrigger.click();
await itemA.click();
await expect(testPage).toHaveURL((url) => url.pathname === "/");
await expect(itemA).toHaveCount(0);
await expect(workspaceTrigger).toHaveAttribute("aria-expanded", "false");
let delayNextFailedInboxRead = false;
await testPage.route("**/api/v1/failed-inbox*", async (route) => {
if (delayNextFailedInboxRead) {
delayNextFailedInboxRead = false;
await injectLatency(
1500,
"make the re-visited workspace's loading window observable before it resolves",
);
}
await route.continue();
});
delayNextFailedInboxRead = true;
const rereadForA = waitForHttp(testPage, "GET", /\/failed-inbox$/, {
predicate: (response) =>
new URL(response.url()).searchParams.get("workspace_id") === seedData.workspaceId,
});
await testPage.getByTestId("sidebar-needs-you-inbox").click();
await expect(testPage).toHaveURL(/\/needs-you-inbox$/);
await expect(testPage.getByRole("tab", { name: /Failed/ })).toBeVisible();
// The bug this covers: because the Inbox was never open while B was
// active, the slice's workspace-change tracking never saw the switch,
// so A's cache was still "ready" from the first visit and the badge
// kept showing its stale "1" instead of clearing while this re-read is
// in flight.
await expect(failedBadge).toHaveCount(0);
await rereadForA;
await expect(failedBadge).toHaveText("1", { timeout: 15_000 });
});
});
@@ -0,0 +1,83 @@
// AC-UI-INBOX-FAILED-001.29: the Failed tab golden path must also work on a
// phone-sized viewport, where the row's only control falls back to the 44px
// touch target (control-sizing.tsx's coarse-pointer breakpoint) instead of
// the desktop-density button. This mirrors inbox-failed-tab.spec.ts's
// desktop coverage rather than duplicating its assertions.
import { test, expect } from "../../fixtures/test-base";
const MIN_TOUCH_TARGET_PX = 44;
test.describe("Inbox Failed tab on mobile", () => {
test("lists a failed task with a touch-sized open-task control (AC .29)", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
const title = "Mobile Inbox Failed Tab Fixture";
const reason = "Simulated failure for the Inbox Failed tab";
const { task_id: taskId } = await apiClient.seedTask(seedData.workspaceId, title, {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.seedTaskSession(taskId, {
// Keep the session non-terminal so its completed_at remains NULL. The
// task is still failed below, which exercises the row's unknown-time
// fallback without violating the harness contract for terminal
// sessions.
state: "IDLE",
errorMessage: reason,
});
await apiClient.updateTaskState(taskId, "FAILED");
await testPage.goto("/needs-you-inbox");
// The Failed count is visible before the Failed tab is ever selected
// (AC .29's ordering), same as the desktop coverage.
const failedBadge = testPage.getByTestId("inbox-tab-failed-badge");
await expect(failedBadge).toHaveText("1", { timeout: 30_000 });
// The tab strip itself (not just the row's open-task control) is a
// coarse-pointer surface an operator must tap to reach this tab.
const failedTab = testPage.getByRole("tab", { name: /Failed/ });
const failedTabBox = await failedTab.boundingBox();
expect(failedTabBox, "Failed tab trigger should have a box").not.toBeNull();
expect(failedTabBox!.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX);
await failedTab.click();
const row = testPage.getByTestId("failed-inbox-row").filter({ hasText: title });
await expect(row).toBeVisible({ timeout: 15_000 });
await expect(row).toContainText(reason);
const openTask = row.getByTestId("failed-inbox-open-task");
const openTaskBox = await openTask.boundingBox();
expect(openTaskBox, "open-task control should have a box").not.toBeNull();
expect(openTaskBox!.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET_PX);
const unknownTime = row.getByTestId("failed-inbox-unknown-time");
await expect(unknownTime).toBeVisible();
const rowBox = await row.boundingBox();
const unknownTimeBox = await unknownTime.boundingBox();
expect(rowBox, "failed row should have a box").not.toBeNull();
expect(unknownTimeBox, "unknown failure time should have a box").not.toBeNull();
expect(openTaskBox!.x + openTaskBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
expect(unknownTimeBox!.x + unknownTimeBox!.width).toBeLessThanOrEqual(
rowBox!.x + rowBox!.width,
);
// The Inbox/Failed-tab page itself must not scroll horizontally on a
// phone viewport -- checked here, before navigating away, so this
// measures the page the test is actually about rather than the
// task-detail page the next step opens.
const overflow = await testPage.evaluate(() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
}));
expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth);
await openTask.click();
await expect(testPage).toHaveURL(new RegExp(`/t/${taskId}$`));
});
});
@@ -0,0 +1,143 @@
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 listFailedInboxMock = vi.fn();
vi.mock("@/lib/api/domains/failed-inbox-api", () => ({
listFailedInbox: (...args: unknown[]) => listFailedInboxMock(...args),
}));
import { useFailedInboxController } from "./use-failed-inbox-controller";
const WORKSPACE_ID = "w1";
function page(overrides: Partial<Awaited<ReturnType<typeof listFailedInboxMock>>> = {}) {
return { rows: [], count: 0, truncated: false, ...overrides };
}
function initialState(enabled: boolean, workspaceId = WORKSPACE_ID): HydrationState {
return {
features: { ...defaultFeatureFlags, needsYouInbox: enabled },
workspaces: { activeId: workspaceId, byId: {}, allIds: [] },
} as unknown as HydrationState;
}
function renderController(selectedTab: "needs-you" | "failed" = "needs-you", enabled = true) {
let rerenderTab: (tab: "needs-you" | "failed") => void = () => {};
const view = renderHook(
({ tab }: { tab: "needs-you" | "failed" }) => {
useFailedInboxController(tab);
return useAppStoreApi();
},
{
initialProps: { tab: selectedTab },
wrapper: ({ children }) => (
<StateProvider initialState={initialState(enabled)}>{children}</StateProvider>
),
},
);
rerenderTab = (tab) => view.rerender({ tab });
return { ...view, rerenderTab };
}
beforeEach(() => {
listFailedInboxMock.mockReset();
listFailedInboxMock.mockResolvedValue(page());
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("useFailedInboxController", () => {
it("reads on mount for the active workspace regardless of selected tab", async () => {
renderController("needs-you");
await waitFor(() => expect(listFailedInboxMock).toHaveBeenCalledWith(WORKSPACE_ID));
});
it("never reads when the Inbox feature flag is disabled", async () => {
renderController("needs-you", false);
await act(async () => {
await Promise.resolve();
});
expect(listFailedInboxMock).not.toHaveBeenCalled();
});
it("applies a successful read into the failed-inbox slice", async () => {
listFailedInboxMock.mockResolvedValue(
page({
count: 1,
truncated: false,
rows: [{ task_id: "t1", title: "T", workspace_id: WORKSPACE_ID, origin: "", reason: "" }],
}),
);
const { result } = renderController("failed");
await waitFor(() => {
const state = result.current.getState().failedInbox.byWorkspaceId[WORKSPACE_ID];
expect(state?.status).toBe("ready");
expect(state?.count).toBe(1);
expect(state?.rows).toHaveLength(1);
});
});
it("applies an errored read into the failed-inbox slice", async () => {
listFailedInboxMock.mockRejectedValue(new Error("boom"));
const { result } = renderController("needs-you");
await waitFor(() => {
const state = result.current.getState().failedInbox.byWorkspaceId[WORKSPACE_ID];
expect(state?.status).toBe("error");
});
});
it("re-reads when the selected tab changes, even though it already read at mount", async () => {
const { rerenderTab } = renderController("needs-you");
await waitFor(() => expect(listFailedInboxMock).toHaveBeenCalledTimes(1));
rerenderTab("failed");
await waitFor(() => expect(listFailedInboxMock).toHaveBeenCalledTimes(2));
});
it("re-reads once at the periodic 60s interval while the tab stays visible, regardless of selected tab", async () => {
vi.useFakeTimers();
renderController("needs-you");
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(listFailedInboxMock).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(listFailedInboxMock).toHaveBeenCalledTimes(2);
});
it("does not re-read at the periodic interval while the tab is hidden", async () => {
vi.useFakeTimers();
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "hidden",
});
renderController("needs-you");
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
listFailedInboxMock.mockClear();
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000);
});
expect(listFailedInboxMock).not.toHaveBeenCalled();
Object.defineProperty(document, "visibilityState", {
configurable: true,
get: () => "visible",
});
});
});
@@ -0,0 +1,73 @@
import { useCallback, useEffect } 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 { listFailedInbox } from "@/lib/api/domains/failed-inbox-api";
import type { InboxTab } from "@/lib/failed-inbox/inbox-tab";
// A maximum staleness, not a rate limit (design-01#Control-flow): the
// residual catch-all for an exit that emits no event.
const PERIODIC_REFRESH_MS = 60_000;
/**
* Owns every Failed-bucket refresh trigger (design-01#Control-flow): Inbox
* mount, tab selection changing, active workspace changing, the browser tab
* returning to visible, and a 60s periodic re-read while visible. Every
* trigger fires regardless of which tab is currently selected. Deliberately
* NOT app-wide and NOT WS-subscribed: mounted only with the Inbox page,
* because nothing outside the Inbox renders this bucket's count, and it must
* not share the Needs-you slice's WS-triggered refresh machinery
* (design-01#Control-flow, "why it does not stop when Needs you is
* selected").
*/
export function useFailedInboxController(selectedTab: InboxTab) {
const enabled = useFeature("needsYouInbox");
const workspaceId = useAppStore((s) => s.workspaces.activeId);
const storeApi = useAppStoreApi();
const refresh = useCallback(
async (targetWorkspaceId: string) => {
const { beginFailedInboxRead, setFailedInboxPage, setFailedInboxError } = storeApi.getState();
const generation = beginFailedInboxRead(targetWorkspaceId);
try {
const page = await listFailedInbox(targetWorkspaceId);
setFailedInboxPage(targetWorkspaceId, generation, {
rows: page.rows,
count: page.count,
truncated: page.truncated,
});
} catch {
setFailedInboxError(targetWorkspaceId, generation);
}
},
[storeApi],
);
// Triggers: Inbox mount, tab selection changing, active workspace changing.
// One effect covers all three -- it fires on mount and again whenever
// either dependency changes, regardless of which tab ends up selected.
useEffect(() => {
if (!enabled || !workspaceId) return;
void refresh(workspaceId);
// eslint-disable-next-line react-hooks/exhaustive-deps -- selectedTab is
// a trigger, not a read; re-running this effect is the only thing it does.
}, [enabled, workspaceId, selectedTab, 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 browser 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]);
}
@@ -0,0 +1,47 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const fetchJsonMock = vi.hoisted(() => vi.fn());
vi.mock("../client", () => ({
fetchJson: fetchJsonMock,
}));
import { listFailedInbox } from "./failed-inbox-api";
describe("listFailedInbox", () => {
beforeEach(() => fetchJsonMock.mockReset());
it("requests the failed-inbox endpoint with only the workspace id", async () => {
const response = { rows: [], count: 0, truncated: false };
fetchJsonMock.mockResolvedValue(response);
await expect(listFailedInbox("workspace-1")).resolves.toEqual(response);
expect(fetchJsonMock).toHaveBeenCalledWith(
"/api/v1/failed-inbox?workspace_id=workspace-1",
undefined,
);
});
it("encodes the workspace id", async () => {
fetchJsonMock.mockResolvedValue({ rows: [], count: 0, truncated: false });
await listFailedInbox("workspace/needs encoding");
expect(fetchJsonMock).toHaveBeenCalledWith(
"/api/v1/failed-inbox?workspace_id=workspace%2Fneeds%20encoding",
undefined,
);
});
it("forwards request options", async () => {
const options = { cache: "no-store" as const };
fetchJsonMock.mockResolvedValue({ rows: [], count: 0, truncated: false });
await listFailedInbox("workspace-1", options);
expect(fetchJsonMock).toHaveBeenCalledWith(
"/api/v1/failed-inbox?workspace_id=workspace-1",
options,
);
});
});
@@ -0,0 +1,16 @@
import { fetchJson, type ApiRequestOptions } from "../client";
import type { FailedInboxPage } from "@/lib/types/failed-inbox";
const BASE = "/api/v1/failed-inbox";
// One canonical parameter set (design-01#Control-flow): every caller passes
// only workspaceId. No cursor -- this read serves exactly one bounded page.
export function listFailedInbox(
workspaceId: string,
options?: ApiRequestOptions,
): Promise<FailedInboxPage> {
return fetchJson<FailedInboxPage>(
`${BASE}?workspace_id=${encodeURIComponent(workspaceId)}`,
options,
);
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { INBOX_TAB_QUERY_PARAM, buildInboxTabHref, resolveInboxTab } from "./inbox-tab";
// AC-UI-INBOX-FAILED-001.2: the `tab` query parameter's only recognised
// values are `needs-you` and `failed`. An absent, empty, repeated, or
// unrecognised value renders Needs you rather than an error.
describe("resolveInboxTab", () => {
it("selects failed for exactly one tab=failed value", () => {
expect(resolveInboxTab(new URLSearchParams("tab=failed"))).toBe("failed");
});
it("selects needs-you when the param is absent", () => {
expect(resolveInboxTab(new URLSearchParams(""))).toBe("needs-you");
});
it("selects needs-you when the param is empty", () => {
expect(resolveInboxTab(new URLSearchParams("tab="))).toBe("needs-you");
});
it("selects needs-you when the param is repeated, even if both are failed", () => {
expect(resolveInboxTab(new URLSearchParams("tab=failed&tab=failed"))).toBe("needs-you");
});
it("selects needs-you for an unrecognised value", () => {
expect(resolveInboxTab(new URLSearchParams("tab=something-else"))).toBe("needs-you");
});
it("selects needs-you for the explicit needs-you value", () => {
expect(resolveInboxTab(new URLSearchParams("tab=needs-you"))).toBe("needs-you");
});
});
describe("buildInboxTabHref", () => {
it("carries the selected tab as the only query parameter", () => {
expect(buildInboxTabHref("/needs-you-inbox", "failed")).toBe(
`/needs-you-inbox?${INBOX_TAB_QUERY_PARAM}=failed`,
);
});
it("preserves other existing query parameters", () => {
expect(
buildInboxTabHref("/needs-you-inbox?other=1", "failed", new URLSearchParams("other=1")),
).toBe(`/needs-you-inbox?other=1&${INBOX_TAB_QUERY_PARAM}=failed`);
});
});
+26
View File
@@ -0,0 +1,26 @@
export type InboxTab = "needs-you" | "failed";
export const INBOX_TAB_QUERY_PARAM = "tab";
// AC-UI-INBOX-FAILED-001.2: the `tab` query parameter's only recognised
// values are `needs-you` and `failed`. An absent, empty, repeated, or
// unrecognised value renders Needs you rather than an error -- "repeated"
// means exactly one occurrence is required, even if every repeated value is
// individually valid.
export function resolveInboxTab(searchParams: URLSearchParams): InboxTab {
const values = searchParams.getAll(INBOX_TAB_QUERY_PARAM);
if (values.length !== 1) return "needs-you";
return values[0] === "failed" ? "failed" : "needs-you";
}
// Selecting a tab REPLACES the current history entry (AC-UI-INBOX-FAILED-001.2),
// carrying the selected tab alongside any other existing query parameters.
export function buildInboxTabHref(
pathname: string,
tab: InboxTab,
currentSearchParams: URLSearchParams = new URLSearchParams(),
): string {
const params = new URLSearchParams(currentSearchParams);
params.set(INBOX_TAB_QUERY_PARAM, tab);
return `${pathname.split("?")[0]}?${params.toString()}`;
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { failedInboxOriginMarkerKey, hasResolvableFailedInboxReason } from "./row-presentation";
// AC-UI-INBOX-FAILED-001.9a: `manual` is the only origin treated as a
// person and renders no marker; each of the five non-person origins gets its
// own distinct marker; anything else (including absent/empty) either takes
// the `manual` path (no marker) or, if non-empty and unrecognised, the
// generic marker.
describe("failedInboxOriginMarkerKey", () => {
it("renders no marker for the manual (person) origin", () => {
expect(failedInboxOriginMarkerKey("manual")).toBeNull();
});
it("renders no marker for an absent or empty origin", () => {
expect(failedInboxOriginMarkerKey("")).toBeNull();
expect(failedInboxOriginMarkerKey(undefined)).toBeNull();
});
it("gives each of the five non-person origins its own distinct marker key", () => {
const keys = [
"agent_created",
"routine",
"onboarding",
"automation_run",
"automation_task",
].map((origin) => failedInboxOriginMarkerKey(origin));
expect(new Set(keys).size).toBe(5);
for (const key of keys) expect(key).toBeTruthy();
});
it("renders the generic marker for a non-empty, unenumerated origin", () => {
expect(failedInboxOriginMarkerKey("some_future_origin")).toBe("failedInbox:originGeneric");
});
});
describe("hasResolvableFailedInboxReason", () => {
it("is false for an empty string", () => {
expect(hasResolvableFailedInboxReason("")).toBe(false);
});
it("is false for a whitespace-only string", () => {
expect(hasResolvableFailedInboxReason(" \n\t ")).toBe(false);
});
it("is true for a non-blank reason", () => {
expect(hasResolvableFailedInboxReason("connection refused")).toBe(true);
});
});
@@ -0,0 +1,24 @@
// AC-UI-INBOX-FAILED-001.9a: `manual` is the only origin treated as a
// person and renders no marker; the five enumerated non-person values (mirrors
// apps/backend/internal/task/models/models.go's TaskOrigin* constants) each
// render their own distinct marker key, and anything else non-empty renders
// the generic marker rather than nothing, the raw value, or a broken row.
const ORIGIN_MARKER_KEYS: Record<string, string> = {
agent_created: "failedInbox:originAgentCreated",
routine: "failedInbox:originRoutine",
onboarding: "failedInbox:originOnboarding",
automation_run: "failedInbox:originAutomationRun",
automation_task: "failedInbox:originAutomationTask",
};
export function failedInboxOriginMarkerKey(origin: string | undefined): string | null {
if (!origin || origin === "manual") return null;
return ORIGIN_MARKER_KEYS[origin] ?? "failedInbox:originGeneric";
}
// AC-UI-INBOX-FAILED-001.19: applies to the value the server returns, after
// its own truncation, so a reason whose first 512 code points are all
// whitespace still takes the fallback.
export function hasResolvableFailedInboxReason(reason: string): boolean {
return reason.trim().length > 0;
}
@@ -13,6 +13,7 @@ import type {
PluginsSliceActions,
ReviewSliceActions,
NeedsYouInboxSliceActions,
FailedInboxSliceActions,
} from "./slices";
// Split out of app-state-types.ts to stay under the file line limit. These
@@ -31,4 +32,5 @@ export type AppStateExtraActions = Pick<UIA, "setThreadActiveView" | "createThre
AutomationsSliceActions &
PluginsSliceActions &
ReviewSliceActions &
NeedsYouInboxSliceActions;
NeedsYouInboxSliceActions &
FailedInboxSliceActions;
+5
View File
@@ -45,6 +45,7 @@ import {
defaultPluginsState,
defaultReviewState,
defaultNeedsYouInboxState,
defaultFailedInboxState,
} from "./slices";
import type {
WorkspaceState,
@@ -220,6 +221,10 @@ export type AppState = KanbanSlice & {
// intersection on AppState)
needsYouInbox: (typeof defaultNeedsYouInboxState)["needsYouInbox"];
// Failed Inbox slice (actions merged via FailedInboxSliceActions
// intersection on AppState)
failedInbox: (typeof defaultFailedInboxState)["failedInbox"];
// UI slice
previewPanel: (typeof defaultUIState)["previewPanel"];
rightPanel: (typeof defaultUIState)["rightPanel"];
+13
View File
@@ -27,3 +27,16 @@ describe("quick chat hydration state", () => {
expect(state.quickChat.selectionReadyByWorkspace).toEqual({ "workspace-1": true });
});
});
describe("failed Inbox hydration state", () => {
it("keeps the request revision map when older persisted state omits it", () => {
const state = mergeInitialState({
failedInbox: {
byWorkspaceId: {},
generationByWorkspaceId: {},
},
} as unknown as HydrationState);
expect(state.failedInbox.readRevisionByWorkspaceId).toEqual({});
});
});
+10
View File
@@ -17,6 +17,7 @@ import {
defaultSystemState,
defaultReviewState,
defaultNeedsYouInboxState,
defaultFailedInboxState,
} from "./slices";
import { mergeHydratedQuickChatSessions } from "@/lib/state/slices/ui/quick-chat-sync";
import type { AgentRuntimeAvailability } from "@/lib/types/agent-runtime";
@@ -69,6 +70,7 @@ export const defaultState = {
walkthroughs: defaultSessionState.walkthroughs,
taskReview: defaultReviewState.taskReview,
needsYouInbox: defaultNeedsYouInboxState.needsYouInbox,
failedInbox: defaultFailedInboxState.failedInbox,
queue: defaultSessionState.queue,
terminal: defaultSessionRuntimeState.terminal,
shell: defaultSessionRuntimeState.shell,
@@ -526,6 +528,14 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState {
},
office: { ...defaultState.office, ...initialState.office },
needsYouInbox: { ...defaultState.needsYouInbox, ...initialState.needsYouInbox },
failedInbox: {
...defaultState.failedInbox,
...initialState.failedInbox,
readRevisionByWorkspaceId: {
...defaultState.failedInbox.readRevisionByWorkspaceId,
...initialState.failedInbox?.readRevisionByWorkspaceId,
},
},
features: { ...defaultState.features, ...initialState.features },
auth: { ...defaultState.auth, ...initialState.auth },
...mergeSessionHostnamesState(initialState),
@@ -0,0 +1,266 @@
import { describe, expect, it } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import type { FailedInboxRow } from "@/lib/types/failed-inbox";
import { createFailedInboxSlice } from "./failed-inbox-slice";
import type { FailedInboxSlice } from "./types";
import { createWorkspaceSlice } from "../workspace/workspace-slice";
import type { WorkspaceSlice } from "../workspace/types";
type TestStore = FailedInboxSlice & WorkspaceSlice;
function newStore() {
// `createFailedInboxSlice` and `createWorkspaceSlice` both type `set`/`get`
// against the full `AppState` (the zustand slices pattern, so composing
// them in store.ts needs no cast); this isolated test store only composes
// these two slices, so the shapes need a cast here, in test-only code the
// store.ts architecture rule does not cover. `createWorkspaceSlice` is
// real, not a stub: `beginFailedInboxRead` now reads
// `workspaces.activeIdRevision`, the app-wide signal a genuine workspace
// switch bumps regardless of whether the Inbox is mounted to see it, so a
// test proving that has to drive a real `setActiveWorkspace`.
return create<TestStore>()(
immer((set, get, api) => ({
...createWorkspaceSlice(
set as unknown as Parameters<typeof createWorkspaceSlice>[0],
get as unknown as Parameters<typeof createWorkspaceSlice>[1],
api as unknown as Parameters<typeof createWorkspaceSlice>[2],
),
...createFailedInboxSlice(
set as unknown as Parameters<typeof createFailedInboxSlice>[0],
get as unknown as Parameters<typeof createFailedInboxSlice>[1],
),
})),
);
}
function row(overrides: Partial<FailedInboxRow> = {}): FailedInboxRow {
return {
task_id: "t1",
title: "Task",
workspace_id: "w1",
origin: "manual",
failure_instant: "2026-09-14T01:00:00Z",
reason: "boom",
...overrides,
};
}
describe("failed-inbox slice", () => {
it("has no rows or count for a workspace before any read (AC .16: absent is not zero)", () => {
const store = newStore();
expect(store.getState().failedInbox.byWorkspaceId.w1).toBeUndefined();
});
it("applies a page read tagged with the current generation", () => {
const store = newStore();
const generation = store.getState().beginFailedInboxRead("w1");
store.getState().setFailedInboxPage("w1", generation, {
rows: [row()],
count: 1,
truncated: true,
});
const state = store.getState().failedInbox.byWorkspaceId.w1;
expect(state.status).toBe("ready");
expect(state.count).toBe(1);
expect(state.rows).toHaveLength(1);
expect(state.truncated).toBe(true);
});
// AC-UI-INBOX-FAILED-001.26: a fast workspace switch or overlapping refresh
// trigger must not let a slower, older response overwrite a newer one.
it("drops a page response whose generation was superseded by a newer read", () => {
const store = newStore();
const staleGeneration = store.getState().beginFailedInboxRead("w1");
const freshGeneration = store.getState().beginFailedInboxRead("w1");
expect(freshGeneration).not.toBe(staleGeneration);
store.getState().setFailedInboxPage("w1", freshGeneration, {
rows: [row({ task_id: "fresh" })],
count: 1,
truncated: false,
});
// The stale response lands after the fresh one and must be dropped.
store.getState().setFailedInboxPage("w1", staleGeneration, {
rows: [row({ task_id: "stale" })],
count: 1,
truncated: false,
});
const state = store.getState().failedInbox.byWorkspaceId.w1;
expect(state.rows[0].task_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().beginFailedInboxRead("w1");
store.getState().setFailedInboxPage("w1", okGeneration, {
rows: [row()],
count: 1,
truncated: false,
});
const failGeneration = store.getState().beginFailedInboxRead("w1");
store.getState().setFailedInboxError("w1", failGeneration);
const state = store.getState().failedInbox.byWorkspaceId.w1;
expect(state.status).toBe("error");
expect(state.rows).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().beginFailedInboxRead("w1");
const freshGeneration = store.getState().beginFailedInboxRead("w1");
store.getState().setFailedInboxPage("w1", freshGeneration, {
rows: [row()],
count: 1,
truncated: false,
});
store.getState().setFailedInboxError("w1", staleGeneration);
const state = store.getState().failedInbox.byWorkspaceId.w1;
expect(state.status).toBe("ready");
expect(state.count).toBe(1);
});
it("keys generations independently per workspace", () => {
const store = newStore();
const genW1 = store.getState().beginFailedInboxRead("w1");
const genW2 = store.getState().beginFailedInboxRead("w2");
store.getState().setFailedInboxPage("w2", genW2, {
rows: [row({ workspace_id: "w2" })],
count: 1,
truncated: false,
});
store.getState().setFailedInboxPage("w1", genW1, {
rows: [],
count: 0,
truncated: false,
});
expect(store.getState().failedInbox.byWorkspaceId.w1.status).toBe("ready");
expect(store.getState().failedInbox.byWorkspaceId.w2.rows).toHaveLength(1);
});
});
describe("failed-inbox slice: response identity", () => {
it("drops a response after the active workspace changes", () => {
const store = newStore();
store.getState().setActiveWorkspace("w1");
const generation = store.getState().beginFailedInboxRead("w1");
store.getState().setActiveWorkspace("w2");
store.getState().setFailedInboxPage("w1", generation, {
rows: [row()],
count: 1,
truncated: false,
});
expect(store.getState().failedInbox.byWorkspaceId.w1.status).toBe("loading");
expect(store.getState().failedInbox.byWorkspaceId.w1.rows).toHaveLength(0);
});
it("drops a response after switching away and back to the same workspace", () => {
const store = newStore();
store.getState().setActiveWorkspace("w1");
const generation = store.getState().beginFailedInboxRead("w1");
store.getState().setActiveWorkspace("w2");
store.getState().setActiveWorkspace("w1");
store.getState().setFailedInboxError("w1", generation);
expect(store.getState().failedInbox.byWorkspaceId.w1.status).toBe("loading");
expect(store.getState().failedInbox.byWorkspaceId.w1.rows).toHaveLength(0);
});
});
describe("failed-inbox slice: workspace-change status transitions (AC .16)", () => {
// The count "shall remain rendered" across a background refresh -- only a
// never-read or previously-errored workspace should show as not-yet-known
// while a request is in flight.
it("keeps a workspace's status ready while a background refresh of already-loaded data is in flight", () => {
const store = newStore();
const firstGeneration = store.getState().beginFailedInboxRead("w1");
store.getState().setFailedInboxPage("w1", firstGeneration, {
rows: [row()],
count: 1,
truncated: false,
});
store.getState().beginFailedInboxRead("w1");
const state = store.getState().failedInbox.byWorkspaceId.w1;
expect(state.status).toBe("ready");
expect(state.rows).toHaveLength(1);
expect(state.count).toBe(1);
});
// "Changing the active workspace shall clear it until a response for the
// new one is applied" -- returning to a workspace visited earlier in the
// same session must not show its stale cache. Drives `setActiveWorkspace`
// (the real signal a switch produces) rather than inferring a switch from
// the `beginFailedInboxRead` call sequence, since the guard now keys off
// `workspaces.activeIdRevision`, not a copy local to this slice.
it("clears a workspace's stale ready state when the operator switches back to it", () => {
const store = newStore();
store.getState().setActiveWorkspace("w1");
const w1Generation = store.getState().beginFailedInboxRead("w1");
store.getState().setFailedInboxPage("w1", w1Generation, {
rows: [row()],
count: 1,
truncated: false,
});
expect(store.getState().failedInbox.byWorkspaceId.w1.status).toBe("ready");
// Switch away to w2 (Inbox stays mounted and reads it too), then back to
// w1, before w1's new read resolves.
store.getState().setActiveWorkspace("w2");
store.getState().beginFailedInboxRead("w2");
store.getState().setActiveWorkspace("w1");
store.getState().beginFailedInboxRead("w1");
const state = store.getState().failedInbox.byWorkspaceId.w1;
expect(state.status).not.toBe("ready");
expect(state.rows).toHaveLength(0);
expect(state.count).toBe(0);
});
// Regression coverage for the gap the switch-back fix above did not close:
// `beginFailedInboxRead` is only ever called while the Inbox is mounted, so
// a workspace switch that happens while it is closed must still be caught
// the next time this workspace is read -- it must not depend on the Inbox
// having been open (and reading) for every intermediate workspace.
it("clears a workspace's stale ready state when the switch away and back happens with no read in between", () => {
const store = newStore();
store.getState().setActiveWorkspace("w1");
const w1Generation = store.getState().beginFailedInboxRead("w1");
store.getState().setFailedInboxPage("w1", w1Generation, {
rows: [row()],
count: 1,
truncated: false,
});
expect(store.getState().failedInbox.byWorkspaceId.w1.status).toBe("ready");
// The active workspace changes twice (e.g. via the sidebar switcher on
// another page) with the Inbox closed throughout -- nothing here calls
// `beginFailedInboxRead` for w2 at all.
store.getState().setActiveWorkspace("w2");
store.getState().setActiveWorkspace("w1");
// The Inbox reopens on w1 and its mount effect reads it.
const state0 = store.getState().failedInbox.byWorkspaceId.w1;
expect(state0.status).toBe("ready"); // still the stale cache, pre-read
store.getState().beginFailedInboxRead("w1");
const state = store.getState().failedInbox.byWorkspaceId.w1;
expect(state.status).not.toBe("ready");
expect(state.rows).toHaveLength(0);
expect(state.count).toBe(0);
});
});
@@ -0,0 +1,118 @@
import type { StateCreator } from "zustand";
import type { AppState } from "../../app-state-types";
import type { FailedInboxSlice, FailedInboxSliceState, FailedInboxWorkspaceState } from "./types";
export const defaultFailedInboxState: FailedInboxSliceState = {
failedInbox: {
byWorkspaceId: {},
generationByWorkspaceId: {},
readRevisionByWorkspaceId: {},
},
};
const emptyWorkspaceState = (readAtWorkspaceRevision = 0): FailedInboxWorkspaceState => ({
rows: [],
count: 0,
truncated: false,
status: "idle",
appliedGeneration: 0,
readAtWorkspaceRevision,
});
// Typed against the full `AppState` (the zustand slices-pattern shape), not
// this slice's own narrower type -- that keeps `set`/`get` structurally
// identical to the root store's, so composing this slice in store.ts needs
// no `as any` escape (ARCH-FRONTEND-ROOT-STATE-CAST).
type ImmerSet = Parameters<
StateCreator<AppState, [["zustand/immer", never]], [], FailedInboxSlice>
>[0];
type ImmerGet = Parameters<
StateCreator<AppState, [["zustand/immer", never]], [], FailedInboxSlice>
>[1];
/**
* Takes `get` (unlike the needs-you-inbox slice) so a genuine workspace
* switch can be detected even when it happened while nothing in this slice
* was called to observe it -- see `beginFailedInboxRead`.
*/
export const createFailedInboxSlice = (set: ImmerSet, get: ImmerGet): FailedInboxSlice => ({
...defaultFailedInboxState,
beginFailedInboxRead: (workspaceId) => {
// Read from `workspaces.activeIdRevision`, the app-wide source of truth
// bumped on every actual workspace switch, rather than a copy local to
// this slice: this slice's own actions only ever run while the Inbox
// page is mounted, so a locally-shadowed "last workspace seen" value
// cannot detect a switch that happened while the Inbox was closed.
const currentRevision = get().workspaces.activeIdRevision ?? 0;
let generation = 0;
set((draft) => {
const next = (draft.failedInbox.generationByWorkspaceId[workspaceId] ?? 0) + 1;
draft.failedInbox.generationByWorkspaceId[workspaceId] = next;
draft.failedInbox.readRevisionByWorkspaceId[workspaceId] = currentRevision;
const existing = draft.failedInbox.byWorkspaceId[workspaceId] ?? emptyWorkspaceState();
const isStaleWorkspaceRevision = existing.readAtWorkspaceRevision !== currentRevision;
// A same-workspace refresh trigger (periodic tick, tab change,
// foreground return) leaves already-`ready` data alone, so it cannot
// hide the badge behind rows it is still showing. Any read whose
// workspace revision has moved on always resets to `loading` and
// clears any cache from an earlier visit, even if that visit ended
// `ready` -- whether the switch happened while this workspace's tab
// was open or while the Inbox was closed entirely.
if (isStaleWorkspaceRevision || existing.status !== "ready") {
draft.failedInbox.byWorkspaceId[workspaceId] = {
...emptyWorkspaceState(currentRevision),
status: "loading",
};
}
generation = next;
});
return generation;
},
setFailedInboxPage: (workspaceId, generation, page) =>
set((draft) => {
if (draft.failedInbox.generationByWorkspaceId[workspaceId] !== generation) return;
const requestRevision = draft.failedInbox.readRevisionByWorkspaceId[workspaceId] ?? 0;
const activeWorkspaceId = get().workspaces.activeId;
const activeRevision = get().workspaces.activeIdRevision ?? 0;
if (
(activeWorkspaceId !== null && activeWorkspaceId !== workspaceId) ||
activeRevision !== requestRevision
) {
return;
}
draft.failedInbox.byWorkspaceId[workspaceId] = {
rows: page.rows,
count: page.count,
truncated: page.truncated,
status: "ready",
appliedGeneration: generation,
readAtWorkspaceRevision: requestRevision,
};
}),
setFailedInboxError: (workspaceId, generation) =>
set((draft) => {
if (draft.failedInbox.generationByWorkspaceId[workspaceId] !== generation) return;
const requestRevision = draft.failedInbox.readRevisionByWorkspaceId[workspaceId] ?? 0;
const activeWorkspaceId = get().workspaces.activeId;
const activeRevision = get().workspaces.activeIdRevision ?? 0;
if (
(activeWorkspaceId !== null && activeWorkspaceId !== workspaceId) ||
activeRevision !== requestRevision
) {
return;
}
// A failed read clears the rows it was replacing in the SAME update
// (design-01#Failure-and-recovery) -- stale rows over an absent count
// is a disagreement the count and the list must never show.
draft.failedInbox.byWorkspaceId[workspaceId] = {
...emptyWorkspaceState(
draft.failedInbox.byWorkspaceId[workspaceId]?.readAtWorkspaceRevision ?? 0,
),
status: "error",
appliedGeneration: generation,
};
}),
});
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { createAppStore } from "@/lib/state/store";
import {
selectFailedInboxCount,
selectFailedInboxCountIsKnown,
selectFailedInboxRows,
selectFailedInboxStatus,
selectFailedInboxTruncated,
} from "./selectors";
const WORKSPACE_ID = "workspace-1";
function storeWithActiveWorkspace() {
const store = createAppStore();
store.setState((state) => ({
...state,
workspaces: { ...state.workspaces, activeId: WORKSPACE_ID },
}));
return store;
}
describe("failed-inbox selectors", () => {
it("reports no count and unknown status before any read (AC .16, .23)", () => {
const store = storeWithActiveWorkspace();
const state = store.getState();
expect(selectFailedInboxCount(state)).toBe(0);
expect(selectFailedInboxCountIsKnown(state)).toBe(false);
expect(selectFailedInboxStatus(state)).toBe("idle");
expect(selectFailedInboxRows(state)).toEqual([]);
});
it("reports no active workspace as the empty workspace state", () => {
const store = createAppStore();
const state = store.getState();
expect(selectFailedInboxCount(state)).toBe(0);
expect(selectFailedInboxCountIsKnown(state)).toBe(false);
});
it("reflects an applied page: known count, rows, and truncation", () => {
const store = storeWithActiveWorkspace();
const generation = store.getState().beginFailedInboxRead(WORKSPACE_ID);
store.getState().setFailedInboxPage(WORKSPACE_ID, generation, {
rows: [
{
task_id: "t1",
title: "Task",
workspace_id: WORKSPACE_ID,
origin: "manual",
failure_instant: "2026-09-14T01:00:00Z",
reason: "boom",
},
],
count: 1,
truncated: true,
});
const state = store.getState();
expect(selectFailedInboxCount(state)).toBe(1);
expect(selectFailedInboxCountIsKnown(state)).toBe(true);
expect(selectFailedInboxTruncated(state)).toBe(true);
expect(selectFailedInboxRows(state)).toHaveLength(1);
});
// AC .23: the count is not "known" while the last read for the active
// workspace failed, even though a generation was applied.
it("reports the count as unknown after a failed read", () => {
const store = storeWithActiveWorkspace();
const generation = store.getState().beginFailedInboxRead(WORKSPACE_ID);
store.getState().setFailedInboxError(WORKSPACE_ID, generation);
const state = store.getState();
expect(selectFailedInboxStatus(state)).toBe("error");
expect(selectFailedInboxCountIsKnown(state)).toBe(false);
expect(selectFailedInboxCount(state)).toBe(0);
});
});
@@ -0,0 +1,48 @@
import type { AppState } from "@/lib/state/store";
import type { FailedInboxRow } from "@/lib/types/failed-inbox";
import type { FailedInboxWorkspaceState } from "./types";
const EMPTY_ROWS: FailedInboxRow[] = [];
const EMPTY_WORKSPACE_STATE: FailedInboxWorkspaceState = {
rows: EMPTY_ROWS,
count: 0,
truncated: false,
status: "idle",
appliedGeneration: 0,
readAtWorkspaceRevision: 0,
};
function selectActiveWorkspaceState(state: AppState): FailedInboxWorkspaceState {
const workspaceId = state.workspaces.activeId;
if (!workspaceId) return EMPTY_WORKSPACE_STATE;
return state.failedInbox.byWorkspaceId[workspaceId] ?? EMPTY_WORKSPACE_STATE;
}
export function selectFailedInboxRows(state: AppState): FailedInboxRow[] {
return selectActiveWorkspaceState(state).rows;
}
export function selectFailedInboxStatus(state: AppState) {
return selectActiveWorkspaceState(state).status;
}
export function selectFailedInboxTruncated(state: AppState): boolean {
return selectActiveWorkspaceState(state).truncated;
}
/** The Failed tab's own badge count (AC-UI-INBOX-FAILED-001.16): absent
* workspace or absent state renders no badge, not a zero -- distinguishing
* "not read yet" from "confirmed empty" at the presentation layer (callers
* gate visibility separately). */
export function selectFailedInboxCount(state: AppState): number {
return selectActiveWorkspaceState(state).count;
}
/** True only once a successful read has been applied for the active
* workspace -- neither "never read" nor "the last read failed" counts as
* known, since AC-UI-INBOX-FAILED-001.23's clause must be omitted in both
* cases rather than asserting presence or absence. */
export function selectFailedInboxCountIsKnown(state: AppState): boolean {
return selectActiveWorkspaceState(state).status === "ready";
}
@@ -0,0 +1,50 @@
import type { FailedInboxRow } from "@/lib/types/failed-inbox";
export type FailedInboxReadStatus = "idle" | "loading" | "ready" | "error";
export type FailedInboxWorkspaceState = {
rows: FailedInboxRow[];
count: number;
truncated: boolean;
status: FailedInboxReadStatus;
// The generation of the last response actually applied to this workspace's
// rows, for the stale-response guard (AC-UI-INBOX-FAILED-001.26).
appliedGeneration: number;
// `workspaces.activeIdRevision` as of the read that produced this entry's
// current status. Compared against the live revision (not a locally-shadowed
// copy) so a workspace switch that happens while nothing here is mounted to
// observe it is still detected the next time this workspace is read.
readAtWorkspaceRevision: number;
};
export type FailedInboxSliceState = {
failedInbox: {
byWorkspaceId: Record<string, FailedInboxWorkspaceState>;
// 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.
// Keyed on workspace only, deliberately never on the selected tab
// (design-01#Control-flow).
generationByWorkspaceId: Record<string, number>;
// Active-workspace revision captured when each request was issued. A
// response for an inactive or re-selected workspace must not mutate its
// cache after the selection boundary has moved.
readRevisionByWorkspaceId: Record<string, number>;
};
};
export type FailedInboxSliceActions = {
/** Bumps and returns the new request generation for a workspace read. */
beginFailedInboxRead: (workspaceId: string) => number;
/** Applies a successful page read if its generation is still current. */
setFailedInboxPage: (
workspaceId: string,
generation: number,
page: { rows: FailedInboxRow[]; count: number; truncated: boolean },
) => void;
/** Applies a failed read if its generation is still current -- clears rows
* and count in the same update (design-01#Failure-and-recovery). */
setFailedInboxError: (workspaceId: string, generation: number) => void;
};
export type FailedInboxSlice = FailedInboxSliceState & FailedInboxSliceActions;
+8
View File
@@ -24,6 +24,7 @@ export {
createNeedsYouInboxSlice,
defaultNeedsYouInboxState,
} from "./needs-you-inbox/needs-you-inbox-slice";
export { createFailedInboxSlice, defaultFailedInboxState } from "./failed-inbox/failed-inbox-slice";
// Export types
export type { KanbanSlice, KanbanSliceState, KanbanSliceActions } from "./kanban/types";
@@ -104,6 +105,13 @@ export type {
NeedsYouInboxReadStatus,
NeedsYouInboxBootSeed,
} from "./needs-you-inbox/types";
export type {
FailedInboxSlice,
FailedInboxSliceActions,
FailedInboxSliceState,
FailedInboxWorkspaceState,
FailedInboxReadStatus,
} from "./failed-inbox/types";
// Re-export commonly used types from each domain
export type {
@@ -28,6 +28,14 @@ export type WorkspaceState = {
updated_at: string;
}>;
activeId: string | null;
// Bumped every time `activeId` is set to a different workspace (never on a
// re-select of the same one). Optional so every existing partial
// `workspaces` object (SSR page state, WS hydration payloads) still
// type-checks unchanged; absent means "never bumped", same as 0. Lets a
// consumer that only observes state intermittently -- e.g. a slice scoped
// to one route -- detect that the active workspace changed while it was
// not watching, without itself subscribing app-wide.
activeIdRevision?: number;
};
export type RepositoriesState = {
@@ -3,7 +3,7 @@ import type { WorkspaceSlice, WorkspaceSliceState } from "./types";
import type { RepositoryBranchPolicy, RepositorySet } from "@/lib/types/http";
export const defaultWorkspaceState: WorkspaceSliceState = {
workspaces: { items: [], activeId: null },
workspaces: { items: [], activeId: null, activeIdRevision: 0 },
repositories: { itemsByWorkspaceId: {}, loadingByWorkspaceId: {}, loadedByWorkspaceId: {} },
repositorySets: {
itemsByWorkspaceId: {},
@@ -48,6 +48,7 @@ export const createWorkspaceSlice: StateCreator<
(get() as WorkspaceSliceWithKanbanReset).resetKanbanWorkspaceContext?.();
set((draft) => {
draft.workspaces.activeId = workspaceId;
draft.workspaces.activeIdRevision = (draft.workspaces.activeIdRevision ?? 0) + 1;
});
},
setWorkspaces: (workspaces) =>
+2
View File
@@ -26,6 +26,7 @@ import {
createPluginsSlice,
createReviewSlice,
createNeedsYouInboxSlice,
createFailedInboxSlice,
} from "./slices";
// Re-export all types from slices for backwards compatibility.
@@ -83,6 +84,7 @@ export function createAppStore(initialState?: HydrationState) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
...createReviewSlice(set as any),
...createNeedsYouInboxSlice(set),
...createFailedInboxSlice(set, get),
// Re-assert merged initial state so caller-supplied values win over slice defaults.
...buildStateOverrides(merged),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest";
import type { TaskSession } from "@/lib/types/http";
import { sessionId, taskId } from "@/lib/types/ids";
import { resolveThreadColumnStatus, resolveThreadSessionStatus } from "./thread-session-status";
import {
FAILED_TASK_STATUS,
resolveThreadColumnStatus,
resolveThreadSessionStatus,
} from "./thread-session-status";
function session(overrides: Partial<TaskSession> = {}): TaskSession {
return {
@@ -83,3 +87,16 @@ describe("resolveThreadColumnStatus", () => {
).toMatchObject({ kind: "needs-you", hasAttention: true });
});
});
// AC-UI-INBOX-FAILED-001.20: the Failed tab has no session to resolve status
// from, so it reaches the shared vocabulary's "failed" entry directly rather
// than fabricating a stand-in session for resolveThreadSessionStatus.
describe("FAILED_TASK_STATUS", () => {
it("is exactly the same entry resolveThreadSessionStatus returns for a failed session", () => {
expect(FAILED_TASK_STATUS).toEqual(resolveThreadSessionStatus(session({ state: "FAILED" })));
});
it("carries no attention flag", () => {
expect(FAILED_TASK_STATUS.hasAttention).toBe(false);
});
});
@@ -52,6 +52,15 @@ const STATUS = {
created: { kind: "created", labelKey: "threads:statusNotStarted", hasAttention: false },
} satisfies Record<ThreadSessionStatusKind, ThreadStatus>;
// The Inbox Failed tab has no session to resolve status from -- every row is
// defined by the task's own terminal failed state (AC-UI-INBOX-FAILED-001.6,
// .20). Exporting the existing `failed` entry lets that tab reach it
// directly instead of fabricating a stand-in session for
// resolveThreadSessionStatus, which would reintroduce the
// pending_action-first precedence AC-UI-INBOX-FAILED-001.20a excludes. This
// is a wider export of an existing entry, not a new or duplicated one.
export const FAILED_TASK_STATUS: ThreadStatus = STATUS.failed;
const REVIEW_READY: ThreadStatus = {
kind: "review-ready",
labelKey: "threads:statusReadyForReview",
+21
View File
@@ -0,0 +1,21 @@
// Inbox Failed tab wire types
// (docs/specs/ui/system-design/inbox-failed-bucket-01.md#Data-and-contracts).
// Mirrors apps/backend/internal/failedinbox/handlers.go's
// failedInboxRow / failedInboxListResponse.
export type FailedInboxRow = {
task_id: string;
title: string;
workspace_id: string;
origin: string;
// Omitted (never null, never a zero instant) when unresolvable -- how the
// client tells an unresolved failure instant from a real one.
failure_instant?: string;
reason: string;
};
export type FailedInboxPage = {
rows: FailedInboxRow[];
count: number;
truncated: boolean;
};
+68 -1
View File
@@ -14,6 +14,7 @@ function dispatch(handlers: WsHandlers, type: string, payload: unknown) {
}
type WorkspaceItem = AppState["workspaces"]["items"][number];
const SECOND_WORKSPACE_ID = "workspace-2";
function storeWith(items: WorkspaceItem[]): StoreApi<AppState> {
return createStore<AppState>(
@@ -73,6 +74,16 @@ describe("workspace.updated placement", () => {
expect(store.getState().workspaces.items[0].unit_id).toBe("unit-new");
});
it("bumps the active workspace revision when the first workspace becomes active", () => {
const store = storeWith([]);
const handlers = registerWorkspacesHandlers(store);
dispatch(handlers, "workspace.created", { id: "ws-2", name: "Runtime" });
expect(store.getState().workspaces.activeId).toBe("ws-2");
expect(store.getState().workspaces.activeIdRevision).toBe(1);
});
});
describe("workspace.deleted queue cleanup", () => {
@@ -82,7 +93,7 @@ describe("workspace.deleted queue cleanup", () => {
const clearQueueStatus = vi.fn();
let state = {
workspaces: {
items: [{ id: WORKSPACE_ID }, { id: "workspace-2" }],
items: [{ id: WORKSPACE_ID }, { id: SECOND_WORKSPACE_ID }],
activeId: WORKSPACE_ID,
},
workflows: { items: [], activeId: null },
@@ -119,4 +130,60 @@ describe("workspace.deleted queue cleanup", () => {
expect(clearQueueStatus).toHaveBeenCalledOnce();
expect(clearQueueStatus).toHaveBeenCalledWith("session-1");
});
it("bumps the active workspace revision when deletion selects a fallback", () => {
const clearQueueStatus = vi.fn();
const first = { ...workspace(), id: WORKSPACE_ID };
const second = { ...workspace(), id: SECOND_WORKSPACE_ID };
const store = createStore<AppState>(
() =>
({
workspaces: {
items: [first, second],
activeId: WORKSPACE_ID,
activeIdRevision: 4,
},
workflows: { items: [], activeId: null },
kanban: { workflowId: null, steps: [], tasks: [] },
kanbanMulti: { snapshots: {} },
taskSessions: { items: {} },
clearQueueStatus,
}) as unknown as AppState,
);
registerWorkspacesHandlers(store)["workspace.deleted"]!({
payload: { id: WORKSPACE_ID },
} as never);
expect(store.getState().workspaces.activeId).toBe(SECOND_WORKSPACE_ID);
expect(store.getState().workspaces.activeIdRevision).toBe(5);
});
it("preserves the active workspace revision when deletion leaves the selection unchanged", () => {
const clearQueueStatus = vi.fn();
const first = { ...workspace(), id: WORKSPACE_ID };
const second = { ...workspace(), id: SECOND_WORKSPACE_ID };
const store = createStore<AppState>(
() =>
({
workspaces: {
items: [first, second],
activeId: SECOND_WORKSPACE_ID,
activeIdRevision: 4,
},
workflows: { items: [], activeId: null },
kanban: { workflowId: null, steps: [], tasks: [] },
kanbanMulti: { snapshots: {} },
taskSessions: { items: {} },
clearQueueStatus,
}) as unknown as AppState,
);
registerWorkspacesHandlers(store)["workspace.deleted"]!({
payload: { id: WORKSPACE_ID },
} as never);
expect(store.getState().workspaces.activeId).toBe(SECOND_WORKSPACE_ID);
expect(store.getState().workspaces.activeIdRevision).toBe(4);
});
});
+9 -1
View File
@@ -4,6 +4,11 @@ import type { WsHandlers } from "@/lib/ws/handlers/types";
type WorkspaceItem = WorkspaceState["items"][number];
function nextActiveWorkspaceRevision(workspaces: WorkspaceState, activeId: string | null): number {
const revision = workspaces.activeIdRevision ?? 0;
return workspaces.activeId === activeId ? revision : revision + 1;
}
function handleWorkspaceDeleted(store: StoreApi<AppState>, workspaceId: string): void {
const currentState = store.getState();
const workspaceTaskIds = new Set(
@@ -26,10 +31,11 @@ function handleWorkspaceDeleted(store: StoreApi<AppState>, workspaceId: string):
state.workspaces.activeId === workspaceId
? (items[0]?.id ?? null)
: state.workspaces.activeId;
const activeIdRevision = nextActiveWorkspaceRevision(state.workspaces, activeId);
const clearBoards = state.workspaces.activeId === workspaceId;
return {
...state,
workspaces: { items, activeId },
workspaces: { items, activeId, activeIdRevision },
workflows: clearBoards ? { items: [], activeId: null } : state.workflows,
kanban: clearBoards ? { workflowId: null, steps: [], tasks: [] } : state.kanban,
};
@@ -61,11 +67,13 @@ export function registerWorkspacesHandlers(store: StoreApi<AppState>): WsHandler
)
: [newWorkspace, ...state.workspaces.items];
const activeId = state.workspaces.activeId ?? payload.id;
const activeIdRevision = nextActiveWorkspaceRevision(state.workspaces, activeId);
return {
...state,
workspaces: {
items,
activeId,
activeIdRevision,
},
};
});
+18
View File
@@ -0,0 +1,18 @@
{
"tabLabel": "Failed",
"openTask": "Open task",
"reasonUnknown": "No failure reason was recorded.",
"unknownFailureTime": "Failure time unknown",
"originAgentCreated": "Created by an agent",
"originRoutine": "Created by a routine",
"originOnboarding": "Created during onboarding",
"originAutomationRun": "Automation run",
"originAutomationTask": "Created by automation",
"originGeneric": "Automated",
"emptyTitle": "Nothing has failed in {{workspace}}.",
"emptyTitleUnnamedWorkspace": "Nothing has failed in this workspace.",
"emptyDescription": "Failed tasks show up here for triage. They are never counted toward the sidebar total.",
"errorTitle": "Could not load failed tasks",
"errorDescription": "Something went wrong loading the tasks that failed in this workspace. This will retry automatically.",
"truncatedNotice": "Showing the first failed tasks. More are waiting."
}
+5 -1
View File
@@ -31,5 +31,9 @@
"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."
"emptyTitleUnnamedWorkspace": "Nothing is waiting on you in this workspace.",
"tabLabel": "Needs you",
"failedClauseSome_one": "This list does not count failed tasks. {{count}} task has also failed. See the Failed tab.",
"failedClauseSome_other": "This list does not count failed tasks. {{count}} tasks have also failed. See the Failed tab.",
"failedClauseNone": "This list does not count failed tasks. None have failed in this workspace right now; check the Failed tab any time."
}
@@ -0,0 +1,18 @@
{
"tabLabel": "Ƒàĩĺēď",
"openTask": "Ōƥēń ţàśķ",
"reasonUnknown": "Ńō ƒàĩĺũŕē ŕēàśōń ŵàś ŕēćōŕďēď.",
"unknownFailureTime": "Ƒàĩĺũŕē ţĩḿē ũńķńōŵń",
"originAgentCreated": "Ćŕēàţēď ƀŷ àń àĝēńţ",
"originRoutine": "Ćŕēàţēď ƀŷ à ŕōũţĩńē",
"originOnboarding": "Ćŕēàţēď ďũŕĩńĝ ōńƀōàŕďĩńĝ",
"originAutomationRun": "Àũţōḿàţĩōń ŕũń",
"originAutomationTask": "Ćŕēàţēď ƀŷ àũţōḿàţĩōń",
"originGeneric": "Àũţōḿàţēď",
"emptyTitle": "Ńōţĥĩńĝ ĥàś ƒàĩĺēď ĩń {{workspace}}.",
"emptyTitleUnnamedWorkspace": "Ńōţĥĩńĝ ĥàś ƒàĩĺēď ĩń ţĥĩś ŵōŕķśƥàćē.",
"emptyDescription": "Ƒàĩĺēď ţàśķś śĥōŵ ũƥ ĥēŕē ƒōŕ ţŕĩàĝē. Ţĥēŷ àŕē ńēvēŕ ćōũńţēď ţōŵàŕď ţĥē śĩďēƀàŕ ţōţàĺ.",
"errorTitle": "Ćōũĺď ńōţ ĺōàď ƒàĩĺēď ţàśķś",
"errorDescription": "Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ ĺōàďĩńĝ ţĥē ţàśķś ţĥàţ ƒàĩĺēď ĩń ţĥĩś ŵōŕķśƥàćē. Ţĥĩś ŵĩĺĺ ŕēţŕŷ àũţōḿàţĩćàĺĺŷ.",
"truncatedNotice": "Śĥōŵĩńĝ ţĥē ƒĩŕśţ ƒàĩĺēď ţàśķś. Ḿōŕē àŕē ŵàĩţĩńĝ."
}
@@ -31,5 +31,9 @@
"openTask": "Ōƥēń ţàśķ",
"questionCount_one": "{{count}} qũēśţĩōń ĩń ţĥĩś ƀũńďĺē",
"questionCount_other": "{{count}} qũēśţĩōńś ĩń ţĥĩś ƀũńďĺē",
"emptyTitleUnnamedWorkspace": "Ńōţĥĩńĝ ĩś ŵàĩţĩńĝ ōń ŷōũ ĩń ţĥĩś ŵōŕķśƥàćē."
"emptyTitleUnnamedWorkspace": "Ńōţĥĩńĝ ĩś ŵàĩţĩńĝ ōń ŷōũ ĩń ţĥĩś ŵōŕķśƥàćē.",
"tabLabel": "Ńēēďś ŷōũ",
"failedClauseSome_one": "Ţĥĩś ĺĩśţ ďōēś ńōţ ćōũńţ ƒàĩĺēď ţàśķś. {{count}} ţàśķ ĥàś àĺśō ƒàĩĺēď. Śēē ţĥē Ƒàĩĺēď ţàƀ.",
"failedClauseSome_other": "Ţĥĩś ĺĩśţ ďōēś ńōţ ćōũńţ ƒàĩĺēď ţàśķś. {{count}} ţàśķś ĥàvē àĺśō ƒàĩĺēď. Śēē ţĥē Ƒàĩĺēď ţàƀ.",
"failedClauseNone": "Ţĥĩś ĺĩśţ ďōēś ńōţ ćōũńţ ƒàĩĺēď ţàśķś. Ńōńē ĥàvē ƒàĩĺēď ĩń ţĥĩś ŵōŕķśƥàćē ŕĩĝĥţ ńōŵ; ćĥēćķ ţĥē Ƒàĩĺēď ţàƀ àńŷ ţĩḿē."
}
@@ -0,0 +1,18 @@
{
"tabLabel": "Falhadas",
"openTask": "Abrir tarefa",
"reasonUnknown": "Não foi registado nenhum motivo de falha.",
"unknownFailureTime": "Hora da falha desconhecida",
"originAgentCreated": "Criada por um agente",
"originRoutine": "Criada por uma rotina",
"originOnboarding": "Criada durante a integração",
"originAutomationRun": "Execução de automação",
"originAutomationTask": "Criada por automação",
"originGeneric": "Automatizada",
"emptyTitle": "Nada falhou em {{workspace}}.",
"emptyTitleUnnamedWorkspace": "Nada falhou neste espaço de trabalho.",
"emptyDescription": "As tarefas falhadas aparecem aqui para triagem. Nunca são contadas no total da barra lateral.",
"errorTitle": "Não foi possível carregar as tarefas falhadas",
"errorDescription": "Ocorreu um erro ao carregar as tarefas que falharam neste espaço de trabalho. Isto será repetido automaticamente.",
"truncatedNotice": "A mostrar as primeiras tarefas falhadas. Há mais à espera."
}
@@ -31,5 +31,9 @@
"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."
"emptyTitleUnnamedWorkspace": "Nada está à sua espera neste espaço de trabalho.",
"tabLabel": "Precisa de si",
"failedClauseSome_one": "Esta lista não conta tarefas falhadas. {{count}} tarefa também falhou. Consulte o separador Falhadas.",
"failedClauseSome_other": "Esta lista não conta tarefas falhadas. {{count}} tarefas também falharam. Consulte o separador Falhadas.",
"failedClauseNone": "Esta lista não conta tarefas falhadas. Nenhuma falhou neste espaço de trabalho neste momento; pode consultar o separador Falhadas a qualquer momento."
}
@@ -0,0 +1,18 @@
{
"tabLabel": "失败",
"openTask": "打开任务",
"reasonUnknown": "未记录失败原因。",
"unknownFailureTime": "失败时间未知",
"originAgentCreated": "由代理创建",
"originRoutine": "由例行程序创建",
"originOnboarding": "在引导过程中创建",
"originAutomationRun": "自动化运行",
"originAutomationTask": "由自动化创建",
"originGeneric": "自动化",
"emptyTitle": "{{workspace}} 中没有任何失败事项。",
"emptyTitleUnnamedWorkspace": "此工作区中没有任何失败事项。",
"emptyDescription": "失败的任务会显示在此处以供排查,永远不会计入侧边栏的总数。",
"errorTitle": "无法加载失败的任务",
"errorDescription": "加载此工作区中失败的任务时出错,系统将自动重试。",
"truncatedNotice": "仅显示最先出现的失败任务,还有更多等待处理。"
}
@@ -31,5 +31,9 @@
"openTask": "打开任务",
"questionCount_one": "此问题集中有 {{count}} 个问题",
"questionCount_other": "此问题集中有 {{count}} 个问题",
"emptyTitleUnnamedWorkspace": "此工作区中没有任何事项在等待您处理。"
"emptyTitleUnnamedWorkspace": "此工作区中没有任何事项在等待您处理。",
"tabLabel": "需要你处理",
"failedClauseSome_one": "此列表不计入失败的任务。{{count}} 个任务也失败了,请查看失败标签页。",
"failedClauseSome_other": "此列表不计入失败的任务。{{count}} 个任务也失败了,请查看失败标签页。",
"failedClauseNone": "此列表不计入失败的任务。此工作区目前没有失败的任务,您可以随时查看失败标签页。"
}
@@ -0,0 +1,18 @@
{
"tabLabel": "失敗",
"openTask": "開啟任務",
"reasonUnknown": "未記錄失敗原因。",
"unknownFailureTime": "失敗時間未知",
"originAgentCreated": "由代理建立",
"originRoutine": "由例行程序建立",
"originOnboarding": "在引導過程中建立",
"originAutomationRun": "自動化執行",
"originAutomationTask": "由自動化建立",
"originGeneric": "自動化",
"emptyTitle": "{{workspace}} 中沒有任何失敗事項。",
"emptyTitleUnnamedWorkspace": "此工作區中沒有任何失敗事項。",
"emptyDescription": "失敗的任務會顯示在此處以供排查,永遠不會計入側邊欄的總數。",
"errorTitle": "無法載入失敗的任務",
"errorDescription": "載入此工作區中失敗的任務時出錯,系統將自動重試。",
"truncatedNotice": "僅顯示最先出現的失敗任務,還有更多等待處理。"
}
@@ -31,5 +31,9 @@
"openTask": "開啟任務",
"questionCount_one": "此問題集中有 {{count}} 個問題",
"questionCount_other": "此問題集中有 {{count}} 個問題",
"emptyTitleUnnamedWorkspace": "此工作區中沒有任何事項在等待您處理。"
"emptyTitleUnnamedWorkspace": "此工作區中沒有任何事項在等待您處理。",
"tabLabel": "需要你處理",
"failedClauseSome_one": "此列表不計入失敗的任務。{{count}} 個任務也失敗了,請查看失敗標籤頁。",
"failedClauseSome_other": "此列表不計入失敗的任務。{{count}} 個任務也失敗了,請查看失敗標籤頁。",
"failedClauseNone": "此列表不計入失敗的任務。此工作區目前沒有失敗的任務,您可以隨時查看失敗標籤頁。"
}
@@ -0,0 +1,18 @@
{
"tabLabel": "失敗",
"openTask": "開啟任務",
"reasonUnknown": "未記錄失敗原因。",
"unknownFailureTime": "失敗時間未知",
"originAgentCreated": "由代理建立",
"originRoutine": "由例行程式建立",
"originOnboarding": "在引導過程中建立",
"originAutomationRun": "自動化執行",
"originAutomationTask": "由自動化建立",
"originGeneric": "自動化",
"emptyTitle": "{{workspace}} 中沒有任何失敗事項。",
"emptyTitleUnnamedWorkspace": "此工作區中沒有任何失敗事項。",
"emptyDescription": "失敗的任務會顯示在此處以供排查,永遠不會計入側邊欄的總數。",
"errorTitle": "無法載入失敗的任務",
"errorDescription": "載入此工作區中失敗的任務時出錯,系統將自動重試。",
"truncatedNotice": "僅顯示最先出現的失敗任務,還有更多等待處理。"
}
@@ -31,5 +31,9 @@
"openTask": "開啟任務",
"questionCount_one": "此問題集中有 {{count}} 個問題",
"questionCount_other": "此問題集中有 {{count}} 個問題",
"emptyTitleUnnamedWorkspace": "此工作區中沒有任何事項在等待您處理。"
"emptyTitleUnnamedWorkspace": "此工作區中沒有任何事項在等待您處理。",
"tabLabel": "需要你處理",
"failedClauseSome_one": "此清單不計入失敗的任務。{{count}} 個任務也失敗了,請檢視失敗標籤頁。",
"failedClauseSome_other": "此清單不計入失敗的任務。{{count}} 個任務也失敗了,請檢視失敗標籤頁。",
"failedClauseNone": "此清單不計入失敗的任務。此工作區目前沒有失敗的任務,您可以隨時檢視失敗標籤頁。"
}
+10 -2
View File
@@ -193,7 +193,7 @@ type OfficeBootstrapState = {
onboardingComplete: boolean | null;
};
function useOfficeRouteBootstrap(
export function useOfficeRouteBootstrap(
officeEnabled: boolean,
routeWorkspaceId: string | null,
): OfficeBootstrapState {
@@ -241,13 +241,21 @@ function useOfficeRouteBootstrap(
userSettingsResponse?.settings?.workspace_id ?? null,
);
const workspaceBeforeHydration = store.getState().workspaces.activeId;
store.getState().hydrate({
workspaces: { items: workspaceItems, activeId: activeWorkspaceId },
workspaces: { items: workspaceItems, activeId: workspaceBeforeHydration },
userSettings: {
...mapUserSettingsResponse(userSettingsResponse),
workspaceId: activeWorkspaceId,
},
});
// Routing the actual switch through `setActiveWorkspace` (rather than
// letting `hydrate` overwrite `activeId` directly) keeps
// `activeIdRevision` accurate for consumers that key staleness off it,
// such as the Failed-inbox cache.
if (activeWorkspaceId !== workspaceBeforeHydration) {
store.getState().setActiveWorkspace(activeWorkspaceId);
}
// Data loading is not this bootstrap's job: agents, projects, inbox and
// meta follow the active workspace via `useOfficeWorkspaceData`, mounted
// in the always-present `AppSidebar`. Setting the active workspace above
@@ -0,0 +1,92 @@
import type { ReactNode } from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { StoreApi } from "zustand";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import type { AppState } from "@/lib/state/store";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://localhost:8443" }),
}));
const listWorkspacesMock = vi.fn();
const fetchUserSettingsMock = vi.fn();
const getOnboardingStateMock = vi.fn();
vi.mock("@/lib/api", () => ({
listWorkspaces: (...args: unknown[]) => listWorkspacesMock(...args),
fetchUserSettings: (...args: unknown[]) => fetchUserSettingsMock(...args),
}));
vi.mock("@/lib/api/domains/office-api", () => ({
getOnboardingState: (...args: unknown[]) => getOnboardingStateMock(...args),
}));
import { useOfficeRouteBootstrap } from "./office-routes";
const OFFICE_WORKSPACE_ID = "ws-office-1";
const OFFICE_WORKSPACES_RESPONSE = {
workspaces: [
{
id: OFFICE_WORKSPACE_ID,
name: "Office workspace",
owner_id: "user-1",
office_workflow_id: "office-flow-1",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
],
total: 1,
};
function makeWrapper(initialActiveId: string | null) {
let captured: StoreApi<AppState> | null = null;
function Capture() {
captured = useAppStoreApi();
return null;
}
function Wrapper({ children }: { children: ReactNode }) {
return (
<StateProvider initialState={{ workspaces: { items: [], activeId: initialActiveId } }}>
<Capture />
{children}
</StateProvider>
);
}
return { Wrapper, getStore: () => captured };
}
// `useOfficeRouteBootstrap` used to hydrate `workspaces.activeId` directly,
// bypassing `setActiveWorkspace` and leaving `activeIdRevision` unbumped.
// Consumers keyed on that revision (the Failed-inbox cache) could then render
// a stale, pre-switch snapshot after navigating through Office.
describe("useOfficeRouteBootstrap", () => {
it("routes the resolved active workspace through setActiveWorkspace, bumping activeIdRevision", async () => {
getOnboardingStateMock.mockResolvedValue({ completed: true });
listWorkspacesMock.mockResolvedValue(OFFICE_WORKSPACES_RESPONSE);
fetchUserSettingsMock.mockResolvedValue(null);
const { Wrapper, getStore } = makeWrapper("ws-kanban-1");
renderHook(() => useOfficeRouteBootstrap(true, null), { wrapper: Wrapper });
await waitFor(() => {
expect(getStore()?.getState().workspaces.activeId).toBe(OFFICE_WORKSPACE_ID);
});
expect(getStore()?.getState().workspaces.activeIdRevision).toBe(1);
});
it("does not bump activeIdRevision when the resolved workspace matches the one already active", async () => {
getOnboardingStateMock.mockResolvedValue({ completed: true });
listWorkspacesMock.mockResolvedValue(OFFICE_WORKSPACES_RESPONSE);
fetchUserSettingsMock.mockResolvedValue(null);
const { Wrapper, getStore } = makeWrapper(OFFICE_WORKSPACE_ID);
renderHook(() => useOfficeRouteBootstrap(true, null), { wrapper: Wrapper });
await waitFor(() => {
expect(listWorkspacesMock).toHaveBeenCalled();
});
expect(getStore()?.getState().workspaces.activeId).toBe(OFFICE_WORKSPACE_ID);
expect(getStore()?.getState().workspaces.activeIdRevision ?? 0).toBe(0);
});
});
+18 -3
View File
@@ -555,7 +555,7 @@ function UpdatesRoute() {
);
}
function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
export function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
const store = useAppStoreApi();
const bootstrappedRef = useRef(false);
@@ -568,8 +568,23 @@ function SettingsRouteBootstrap({ pathname }: { pathname: string }) {
const initialState = await loadSettingsInitialState(
() => store.getState().agentProfiles.version,
);
if (!cancelled && Object.keys(initialState).length > 0) {
store.getState().hydrate(initialState);
if (cancelled || Object.keys(initialState).length === 0) return;
const desiredWorkspaceId = initialState.workspaces?.activeId ?? null;
const workspaceBeforeHydration = store.getState().workspaces.activeId;
// Routing the actual switch through `setActiveWorkspace` (rather than
// letting `hydrate` overwrite `activeId` directly) keeps
// `activeIdRevision` accurate for consumers that key staleness off it,
// such as the Failed-inbox cache.
store.getState().hydrate(
initialState.workspaces
? {
...initialState,
workspaces: { ...initialState.workspaces, activeId: workspaceBeforeHydration },
}
: initialState,
);
if (initialState.workspaces && desiredWorkspaceId !== workspaceBeforeHydration) {
store.getState().setActiveWorkspace(desiredWorkspaceId);
}
}
@@ -0,0 +1,105 @@
import type { ReactNode } from "react";
import { render, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { StoreApi } from "zustand";
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
import type { AppState } from "@/lib/state/store";
vi.mock("@/lib/config", () => ({
getBackendConfig: () => ({ apiBaseUrl: "http://localhost:8443" }),
}));
const listWorkspacesMock = vi.fn();
const listExecutorsMock = vi.fn();
const listAgentsMock = vi.fn();
const listAgentDiscoveryMock = vi.fn();
const listAvailableAgentsMock = vi.fn();
const fetchUserSettingsMock = vi.fn();
vi.mock("@/lib/api/domains/workspace-api", async (importOriginal) => ({
...(await importOriginal<object>()),
listWorkspaces: (...args: unknown[]) => listWorkspacesMock(...args),
}));
vi.mock("@/lib/api/domains/settings-api", async (importOriginal) => ({
...(await importOriginal<object>()),
fetchUserSettings: (...args: unknown[]) => fetchUserSettingsMock(...args),
listAgentDiscovery: (...args: unknown[]) => listAgentDiscoveryMock(...args),
listAgents: (...args: unknown[]) => listAgentsMock(...args),
listAvailableAgents: (...args: unknown[]) => listAvailableAgentsMock(...args),
listExecutors: (...args: unknown[]) => listExecutorsMock(...args),
}));
import { SettingsRouteBootstrap } from "./settings-routes";
const SETTINGS_WORKSPACE_ID = "ws-settings-1";
const SETTINGS_WORKSPACES_RESPONSE = {
workspaces: [
{
id: SETTINGS_WORKSPACE_ID,
name: "Settings workspace",
owner_id: "user-1",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
},
],
total: 1,
};
function makeWrapper(initialActiveId: string | null) {
let captured: StoreApi<AppState> | null = null;
function Capture() {
captured = useAppStoreApi();
return null;
}
function Wrapper({ children }: { children: ReactNode }) {
return (
<StateProvider initialState={{ workspaces: { items: [], activeId: initialActiveId } }}>
<Capture />
{children}
</StateProvider>
);
}
return { Wrapper, getStore: () => captured };
}
function stubApis() {
listExecutorsMock.mockResolvedValue({ executors: [] });
listAgentsMock.mockResolvedValue({ agents: [] });
listAgentDiscoveryMock.mockResolvedValue({ agents: [] });
listAvailableAgentsMock.mockResolvedValue({ agents: [], tools: [] });
fetchUserSettingsMock.mockResolvedValue(null);
}
// `SettingsRouteBootstrap` used to hydrate `workspaces.activeId` directly,
// bypassing `setActiveWorkspace` and leaving `activeIdRevision` unbumped.
// Consumers keyed on that revision (the Failed-inbox cache) could then render
// a stale, pre-switch snapshot after navigating through Settings.
describe("SettingsRouteBootstrap", () => {
it("routes the resolved active workspace through setActiveWorkspace, bumping activeIdRevision", async () => {
stubApis();
listWorkspacesMock.mockResolvedValue(SETTINGS_WORKSPACES_RESPONSE);
const { Wrapper, getStore } = makeWrapper("ws-kanban-1");
render(<SettingsRouteBootstrap pathname="/settings" />, { wrapper: Wrapper });
await waitFor(() => {
expect(getStore()?.getState().workspaces.activeId).toBe(SETTINGS_WORKSPACE_ID);
});
expect(getStore()?.getState().workspaces.activeIdRevision).toBe(1);
});
it("does not bump activeIdRevision when the resolved workspace matches the one already active", async () => {
stubApis();
listWorkspacesMock.mockResolvedValue(SETTINGS_WORKSPACES_RESPONSE);
const { Wrapper, getStore } = makeWrapper(SETTINGS_WORKSPACE_ID);
render(<SettingsRouteBootstrap pathname="/settings" />, { wrapper: Wrapper });
await waitFor(() => {
expect(listWorkspacesMock).toHaveBeenCalled();
});
expect(getStore()?.getState().workspaces.activeId).toBe(SETTINGS_WORKSPACE_ID);
expect(getStore()?.getState().workspaces.activeIdRevision ?? 0).toBe(0);
});
});