diff --git a/apps/backend/internal/backendapp/boot_thread_views.go b/apps/backend/internal/backendapp/boot_thread_views.go index deddc98a58..6b89916854 100644 --- a/apps/backend/internal/backendapp/boot_thread_views.go +++ b/apps/backend/internal/backendapp/boot_thread_views.go @@ -6,12 +6,14 @@ func mapThreadViews(views []models.ThreadView) []map[string]any { result := make([]map[string]any, 0, len(views)) for _, view := range views { result = append(result, map[string]any{ - "id": view.ID, - "name": view.Name, - "taskScope": mapThreadTaskScope(view.TaskScope), - "filters": mapThreadViewClauses(view.Filters), - "sort": mapThreadViewSort(view.Sort), - "maxColumns": view.MaxColumns, + "id": view.ID, + "name": view.Name, + "taskScope": mapThreadTaskScope(view.TaskScope), + "filters": mapThreadViewClauses(view.Filters), + "sort": mapThreadViewSort(view.Sort), + "maxColumns": view.MaxColumns, + "layout": view.Layout, + "autoHideComposer": view.AutoHideComposer, }) } return result @@ -22,11 +24,13 @@ func mapThreadViewDraft(draft *models.ThreadViewDraft) map[string]any { return nil } return map[string]any{ - "baseViewId": draft.BaseViewID, - "taskScope": mapThreadTaskScope(draft.TaskScope), - "filters": mapThreadViewClauses(draft.Filters), - "sort": mapThreadViewSort(draft.Sort), - "maxColumns": draft.MaxColumns, + "baseViewId": draft.BaseViewID, + "taskScope": mapThreadTaskScope(draft.TaskScope), + "filters": mapThreadViewClauses(draft.Filters), + "sort": mapThreadViewSort(draft.Sort), + "maxColumns": draft.MaxColumns, + "layout": draft.Layout, + "autoHideComposer": draft.AutoHideComposer, } } diff --git a/apps/backend/internal/backendapp/boot_thread_views_test.go b/apps/backend/internal/backendapp/boot_thread_views_test.go new file mode 100644 index 0000000000..1bd59b8023 --- /dev/null +++ b/apps/backend/internal/backendapp/boot_thread_views_test.go @@ -0,0 +1,19 @@ +package backendapp + +import ( + "testing" + + "github.com/kandev/kandev/internal/user/models" +) + +// @covers AC-UI-THREADS-SAVED-VIEWS-005.1, AC-UI-THREADS-SAVED-VIEWS-005.7 +func TestBootThreadPresentation(t *testing.T) { + views := mapThreadViews([]models.ThreadView{{ID: "presentation", Layout: "grid", AutoHideComposer: true}}) + draft := mapThreadViewDraft(&models.ThreadViewDraft{BaseViewID: "presentation", Layout: "columns", AutoHideComposer: true}) + if views[0]["layout"] != "grid" || views[0]["autoHideComposer"] != true { + t.Fatalf("boot view lost presentation: %#v", views[0]) + } + if draft["layout"] != "columns" || draft["autoHideComposer"] != true { + t.Fatalf("boot draft lost presentation: %#v", draft) + } +} diff --git a/apps/backend/internal/user/dto/thread_views_test.go b/apps/backend/internal/user/dto/thread_views_test.go index 7f984a21fd..3c0535b597 100644 --- a/apps/backend/internal/user/dto/thread_views_test.go +++ b/apps/backend/internal/user/dto/thread_views_test.go @@ -11,15 +11,17 @@ func TestThreadViewDTOMapsPersistedFields(t *testing.T) { maxColumns := 3 settings := &models.UserSettings{ ThreadViews: []models.ThreadView{{ - ID: "view-one", - Name: "One", - TaskScope: models.ThreadTaskScope{Mode: models.ThreadTaskScopeSelected, TaskIDs: []string{"task-a"}}, - Filters: []models.ThreadViewClause{}, - Sort: models.ThreadViewSort{Key: "priority", Direction: "desc"}, - MaxColumns: &maxColumns, + ID: "view-one", + Name: "One", + TaskScope: models.ThreadTaskScope{Mode: models.ThreadTaskScopeSelected, TaskIDs: []string{"task-a"}}, + Filters: []models.ThreadViewClause{}, + Sort: models.ThreadViewSort{Key: "priority", Direction: "desc"}, + MaxColumns: &maxColumns, + Layout: models.ThreadLayoutGrid, + AutoHideComposer: true, }}, ThreadActiveViewID: "view-one", - ThreadViewDraft: &models.ThreadViewDraft{BaseViewID: "view-one"}, + ThreadViewDraft: &models.ThreadViewDraft{BaseViewID: "view-one", Layout: models.ThreadLayoutColumns, AutoHideComposer: true}, } got := FromUserSettings(settings) if len(got.ThreadViews) != 1 || got.ThreadViews[0].ID != "view-one" { @@ -28,6 +30,12 @@ func TestThreadViewDTOMapsPersistedFields(t *testing.T) { if got.ThreadActiveViewID != "view-one" || got.ThreadViewDraft == nil { t.Fatalf("active/draft = %q/%+v, want persisted values", got.ThreadActiveViewID, got.ThreadViewDraft) } + if got.ThreadViews[0].Layout != models.ThreadLayoutGrid || !got.ThreadViews[0].AutoHideComposer { + t.Fatal("saved presentation was lost in the DTO") + } + if got.ThreadViewDraft.Layout != models.ThreadLayoutColumns || !got.ThreadViewDraft.AutoHideComposer { + t.Fatal("draft presentation was lost in the DTO") + } } func TestNullableThreadViewDraftDistinguishesOmittedAndNull(t *testing.T) { diff --git a/apps/backend/internal/user/handlers/thread_presentation_test.go b/apps/backend/internal/user/handlers/thread_presentation_test.go new file mode 100644 index 0000000000..2d75c43019 --- /dev/null +++ b/apps/backend/internal/user/handlers/thread_presentation_test.go @@ -0,0 +1,73 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func threadSettingsRequest(t *testing.T, router *gin.Engine, method, body string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(method, "/api/v1/user/settings", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, req) + return response +} + +func readThreadPresentation(t *testing.T, response *httptest.ResponseRecorder) map[string]any { + t.Helper() + if response.Code != http.StatusOK { + t.Fatalf("status = %d: %s", response.Code, response.Body.String()) + } + var payload struct { + Settings map[string]any `json:"settings"` + } + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + return payload.Settings +} + +// @covers AC-UI-THREADS-SAVED-VIEWS-005.1, AC-UI-THREADS-SAVED-VIEWS-005.2 +func TestHTTPThreadPresentationRoundTripAndOmittedCollections(t *testing.T) { + router := newTestUserSettingsRouter(t) + patch := `{"thread_views":[{"id":"one","name":"One","task_scope":{"mode":"all","task_ids":[]},"filters":[],"sort":{"key":"priority","direction":"desc"},"max_columns":7,"layout":"grid","auto_hide_composer":true}],"thread_active_view_id":"one","thread_view_draft":{"base_view_id":"one","task_scope":{"mode":"all","task_ids":[]},"max_columns":null,"layout":"columns","auto_hide_composer":true}}` + readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodPatch, patch)) + readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodPatch, `{"thread_active_view_id":"one"}`)) + settings := readThreadPresentation(t, threadSettingsRequest(t, router, http.MethodGet, "")) + view := settings["thread_views"].([]any)[0].(map[string]any) + draft := settings["thread_view_draft"].(map[string]any) + if view["layout"] != "grid" || view["auto_hide_composer"] != true || view["max_columns"] != float64(7) { + t.Fatalf("view = %+v", view) + } + if draft["layout"] != "columns" || draft["auto_hide_composer"] != true || draft["max_columns"] != nil { + t.Fatalf("draft = %+v", draft) + } +} + +// @covers AC-UI-THREADS-SAVED-VIEWS-005.3 +func TestHTTPThreadPresentationRejectsInvalidWritesAtomically(t *testing.T) { + for _, fields := range []string{ + `"layout":"masonry"`, `"layout":{}`, `"auto_hide_composer":"true"`, + `"layout":""`, `"layout":null`, `"auto_hide_composer":null`, + } { + t.Run(fields, func(t *testing.T) { + router := newTestUserSettingsRouter(t) + before := threadSettingsRequest(t, router, http.MethodGet, "").Body.String() + patch := `{"thread_views":[{"id":"new","name":"Invalid","task_scope":{"mode":"all","task_ids":[]},` + fields + `}],"thread_active_view_id":"new"}` + response := threadSettingsRequest(t, router, http.MethodPatch, patch) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400: %s", response.Code, response.Body.String()) + } + after := threadSettingsRequest(t, router, http.MethodGet, "").Body.String() + if before != after { + t.Fatal("rejected write changed persisted settings") + } + }) + } +} diff --git a/apps/backend/internal/user/models/thread_view_json.go b/apps/backend/internal/user/models/thread_view_json.go new file mode 100644 index 0000000000..037c87eed0 --- /dev/null +++ b/apps/backend/internal/user/models/thread_view_json.go @@ -0,0 +1,52 @@ +package models + +import ( + "bytes" + "encoding/json" + "errors" +) + +// Omitted presentation fields have compatibility defaults. Explicit nulls +// and empty layouts are not valid new settings values. +// Unknown layout strings are validated by the service layer; JSON checks +// reject explicit values that would otherwise become zero-value defaults. +func decodeThreadViewJSON(data []byte, target any) error { + if err := json.Unmarshal(data, target); err != nil { + return err + } + var fields struct { + Layout json.RawMessage `json:"layout"` + AutoHideComposer json.RawMessage `json:"auto_hide_composer"` + } + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + layout := string(bytes.TrimSpace(fields.Layout)) + if layout == "null" || layout == `""` { + return errors.New("thread layout must be columns or grid") + } + if bytes.Equal(bytes.TrimSpace(fields.AutoHideComposer), []byte("null")) { + return errors.New("auto_hide_composer must be a boolean") + } + return nil +} + +func (v *ThreadView) UnmarshalJSON(data []byte) error { + type value ThreadView + var decoded value + if err := decodeThreadViewJSON(data, &decoded); err != nil { + return err + } + *v = ThreadView(decoded) + return nil +} + +func (v *ThreadViewDraft) UnmarshalJSON(data []byte) error { + type value ThreadViewDraft + var decoded value + if err := decodeThreadViewJSON(data, &decoded); err != nil { + return err + } + *v = ThreadViewDraft(decoded) + return nil +} diff --git a/apps/backend/internal/user/models/thread_views.go b/apps/backend/internal/user/models/thread_views.go index 518877f86c..49112739ce 100644 --- a/apps/backend/internal/user/models/thread_views.go +++ b/apps/backend/internal/user/models/thread_views.go @@ -5,6 +5,8 @@ import "encoding/json" const ( ThreadTaskScopeAll = "all" ThreadTaskScopeSelected = "selected" + ThreadLayoutColumns = "columns" + ThreadLayoutGrid = "grid" ) // ThreadTaskScope limits a Threads saved view to all eligible tasks or to an @@ -16,12 +18,14 @@ type ThreadTaskScope struct { // ThreadView stores one persisted Threads task query. type ThreadView struct { - ID string `json:"id"` - Name string `json:"name"` - TaskScope ThreadTaskScope `json:"task_scope"` - Filters []ThreadViewClause `json:"filters"` - Sort ThreadViewSort `json:"sort"` - MaxColumns *int `json:"max_columns"` + ID string `json:"id"` + Name string `json:"name"` + TaskScope ThreadTaskScope `json:"task_scope"` + Filters []ThreadViewClause `json:"filters"` + Sort ThreadViewSort `json:"sort"` + MaxColumns *int `json:"max_columns"` + Layout string `json:"layout"` + AutoHideComposer bool `json:"auto_hide_composer"` } // ThreadViewClause stores an opaque filter clause. The frontend owns the @@ -40,9 +44,11 @@ type ThreadViewSort struct { // ThreadViewDraft stores an unsaved edit against one saved Threads view. type ThreadViewDraft struct { - BaseViewID string `json:"base_view_id"` - TaskScope ThreadTaskScope `json:"task_scope"` - Filters []ThreadViewClause `json:"filters"` - Sort ThreadViewSort `json:"sort"` - MaxColumns *int `json:"max_columns"` + BaseViewID string `json:"base_view_id"` + TaskScope ThreadTaskScope `json:"task_scope"` + Filters []ThreadViewClause `json:"filters"` + Sort ThreadViewSort `json:"sort"` + MaxColumns *int `json:"max_columns"` + Layout string `json:"layout"` + AutoHideComposer bool `json:"auto_hide_composer"` } diff --git a/apps/backend/internal/user/service/thread_views.go b/apps/backend/internal/user/service/thread_views.go index d01cae9082..fb980f8b89 100644 --- a/apps/backend/internal/user/service/thread_views.go +++ b/apps/backend/internal/user/service/thread_views.go @@ -30,6 +30,12 @@ func applyThreadViews(settings *models.UserSettings, req *UpdateUserSettingsRequ if err := validateThreadViews(views); err != nil { return err } + views = append([]models.ThreadView(nil), views...) + for i := range views { + if views[i].Layout == "" { + views[i].Layout = models.ThreadLayoutColumns + } + } settings.ThreadViews = views if req.ThreadActiveViewID == nil && !threadViewIDExists(views, settings.ThreadActiveViewID) { return fmt.Errorf("thread_active_view_id %q does not match any saved view", settings.ThreadActiveViewID) @@ -61,6 +67,9 @@ func applyThreadViewState(settings *models.UserSettings, req *UpdateUserSettings return err } draft.BaseViewID = strings.TrimSpace(draft.BaseViewID) + if draft.Layout == "" { + draft.Layout = models.ThreadLayoutColumns + } settings.ThreadViewDraft = &draft return nil } @@ -82,6 +91,9 @@ func validateThreadViews(views []models.ThreadView) error { return fmt.Errorf("thread_views: duplicate view id %q", view.ID) } seen[view.ID] = struct{}{} + if err := validateThreadLayout(view.Layout); err != nil { + return fmt.Errorf("thread_views.%s: %w", view.ID, err) + } if err := validateThreadViewBody(view.TaskScope, view.Filters, view.MaxColumns, false); err != nil { return fmt.Errorf("thread_views.%s: %w", view.ID, err) } @@ -93,9 +105,21 @@ func validateThreadViewDraft(draft models.ThreadViewDraft, views []models.Thread if !threadViewIDExists(views, strings.TrimSpace(draft.BaseViewID)) { return fmt.Errorf("thread_view_draft.base_view_id %q does not match any saved view", draft.BaseViewID) } + if err := validateThreadLayout(draft.Layout); err != nil { + return err + } return validateThreadViewBody(draft.TaskScope, draft.Filters, draft.MaxColumns, true) } +func validateThreadLayout(layout string) error { + switch layout { + case "", models.ThreadLayoutColumns, models.ThreadLayoutGrid: + return nil + default: + return fmt.Errorf("unsupported thread layout %q", layout) + } +} + func validateThreadViewBody( scope models.ThreadTaskScope, filters []models.ThreadViewClause, diff --git a/apps/backend/internal/user/service/thread_views_test.go b/apps/backend/internal/user/service/thread_views_test.go index 85924b7747..3adaf02b93 100644 --- a/apps/backend/internal/user/service/thread_views_test.go +++ b/apps/backend/internal/user/service/thread_views_test.go @@ -1,6 +1,7 @@ package service import ( + "encoding/json" "strings" "testing" @@ -140,3 +141,33 @@ func validThreadView(id string) models.ThreadView { } func stringPtr(value string) *string { return &value } + +// @covers AC-UI-THREADS-SAVED-VIEWS-005.3 +func TestApplyThreadPresentationRejectsUnknownLayout(t *testing.T) { + for _, target := range []string{"view", "draft"} { + t.Run(target, func(t *testing.T) { + settings := &models.UserSettings{ThreadViews: []models.ThreadView{validThreadView("view-one")}, ThreadActiveViewID: "view-one"} + body := `"task_scope":{"mode":"all","task_ids":[]},"layout":"masonry","auto_hide_composer":true` + var err error + if target == "view" { + var views []models.ThreadView + if err := json.Unmarshal([]byte(`[{"id":"view-one","name":"Replacement",`+body+`}]`), &views); err != nil { + t.Fatal(err) + } + err = applyThreadViews(settings, &UpdateUserSettingsRequest{ThreadViews: &views}) + } else { + var draft *models.ThreadViewDraft + if err := json.Unmarshal([]byte(`{"base_view_id":"view-one",`+body+`}`), &draft); err != nil { + t.Fatal(err) + } + err = applyThreadViewState(settings, &UpdateUserSettingsRequest{ThreadViewDraft: &draft}) + } + if err == nil || !strings.Contains(err.Error(), "layout") { + t.Fatalf("error = %v, want unsupported layout", err) + } + if settings.ThreadViews[0].Name != "view-one" || settings.ThreadViewDraft != nil { + t.Fatal("rejected presentation mutated settings") + } + }) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index de24f0a940..10318fe3c4 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -826,7 +826,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin SidebarDraft *models.SidebarViewDraft `json:"sidebar_draft"` ThreadViews json.RawMessage `json:"thread_views"` ThreadActiveViewID json.RawMessage `json:"thread_active_view_id"` - ThreadViewDraft *models.ThreadViewDraft `json:"thread_view_draft"` + ThreadViewDraft json.RawMessage `json:"thread_view_draft"` SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` SidebarTaskColorAutomation json.RawMessage `json:"sidebar_task_color_automation"` SidebarTaskColors json.RawMessage `json:"sidebar_task_colors"` @@ -951,8 +951,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin } settings.SidebarDraft = payload.SidebarDraft if len(payload.ThreadViews) > 0 { - var threadViews []models.ThreadView - if err := json.Unmarshal(payload.ThreadViews, &threadViews); err != nil { + threadViews, err := decodeStoredThreadViews(payload.ThreadViews) + if err != nil { return nil, err } if len(threadViews) > 0 { @@ -976,7 +976,11 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin } settings.ThreadActiveViewID = settings.ThreadViews[0].ID } - settings.ThreadViewDraft = payload.ThreadViewDraft + threadDraft, err := decodeStoredThreadDraft(payload.ThreadViewDraft) + if err != nil { + return nil, err + } + settings.ThreadViewDraft = threadDraft settings.SidebarTaskPrefs = normalizeSidebarTaskPrefs(payload.SidebarTaskPrefs) settings.SidebarTaskColorAutomation = decodeSidebarTaskColorAutomation(payload.SidebarTaskColorAutomation) settings.SidebarTaskColors = decodeSidebarTaskColors(payload.SidebarTaskColors) diff --git a/apps/backend/internal/user/store/thread_views.go b/apps/backend/internal/user/store/thread_views.go index 4f98ef4902..e55f4b1651 100644 --- a/apps/backend/internal/user/store/thread_views.go +++ b/apps/backend/internal/user/store/thread_views.go @@ -1,6 +1,10 @@ package store -import "github.com/kandev/kandev/internal/user/models" +import ( + "encoding/json" + + "github.com/kandev/kandev/internal/user/models" +) const ( DefaultThreadViewID = "view-all-threads" @@ -17,9 +21,63 @@ func DefaultThreadViews() []models.ThreadView { Filters: []models.ThreadViewClause{}, Sort: models.ThreadViewSort{Key: "attention", Direction: "asc"}, MaxColumns: &maxColumns, + Layout: models.ThreadLayoutColumns, }} } +// Stored presentation is decoded independently so an unknown display value +// cannot discard the query or prevent the rest of user settings from loading. +func normalizeThreadPresentation(layoutRaw, autoHideRaw json.RawMessage) (string, bool) { + var layout string + if err := json.Unmarshal(layoutRaw, &layout); err != nil || layout != models.ThreadLayoutGrid { + layout = models.ThreadLayoutColumns + } + var autoHide bool + if err := json.Unmarshal(autoHideRaw, &autoHide); err != nil { + autoHide = false + } + return layout, autoHide +} + +func decodeStoredThreadViews(raw json.RawMessage) ([]models.ThreadView, error) { + type storedView models.ThreadView + var stored []struct { + storedView + Layout json.RawMessage `json:"layout"` + AutoHideComposer json.RawMessage `json:"auto_hide_composer"` + } + if err := json.Unmarshal(raw, &stored); err != nil { + return nil, err + } + views := make([]models.ThreadView, len(stored)) + for i, value := range stored { + views[i] = models.ThreadView(value.storedView) + views[i].Layout, views[i].AutoHideComposer = normalizeThreadPresentation(value.Layout, value.AutoHideComposer) + } + return views, nil +} + +func decodeStoredThreadDraft(raw json.RawMessage) (*models.ThreadViewDraft, error) { + if len(raw) == 0 { + return nil, nil + } + type storedDraft models.ThreadViewDraft + var stored *struct { + storedDraft + Layout json.RawMessage `json:"layout"` + AutoHideComposer json.RawMessage `json:"auto_hide_composer"` + } + if err := json.Unmarshal(raw, &stored); err != nil { + return nil, err + } + if stored == nil { + return nil, nil + } + draft := models.ThreadViewDraft(stored.storedDraft) + draft.Layout, draft.AutoHideComposer = normalizeThreadPresentation(stored.Layout, stored.AutoHideComposer) + return &draft, nil +} + func threadViewIDExists(views []models.ThreadView, id string) bool { for _, view := range views { if view.ID == id { diff --git a/apps/backend/internal/user/store/thread_views_test.go b/apps/backend/internal/user/store/thread_views_test.go index 21d311371d..c1907197c6 100644 --- a/apps/backend/internal/user/store/thread_views_test.go +++ b/apps/backend/internal/user/store/thread_views_test.go @@ -69,3 +69,47 @@ func TestDefaultThreadViewUsesFiveColumns(t *testing.T) { t.Fatalf("default task scope = %+v, want all", view.TaskScope) } } + +// @covers AC-UI-THREADS-SAVED-VIEWS-005.1, AC-UI-THREADS-SAVED-VIEWS-005.3 +func TestStoredThreadPresentationNormalizesIndependently(t *testing.T) { + cases := []struct { + name, fields, layout string + autoHide bool + }{ + {"legacy", ``, "columns", false}, + {"grid", `,"layout":"grid","auto_hide_composer":true`, "grid", true}, + {"unknown layout", `,"layout":"masonry","auto_hide_composer":true`, "columns", true}, + {"malformed layout", `,"layout":{},"auto_hide_composer":true`, "columns", true}, + {"malformed toggle", `,"layout":"grid","auto_hide_composer":"true"`, "grid", false}, + {"null fields", `,"layout":null,"auto_hide_composer":null`, "columns", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + body := `"task_scope":{"mode":"selected","task_ids":["task-sentinel"]},"filters":[],"sort":{"key":"priority","direction":"desc"},"max_columns":7` + tc.fields + raw := `{"thread_views":[{"id":"custom","name":"Custom",` + body + `}],"thread_active_view_id":"custom","thread_view_draft":{"base_view_id":"custom",` + body + `}}` + settings, err := scanUserSettings(settingsScanner{raw: raw}, DefaultUserID) + if err != nil { + t.Fatalf("scan: %v", err) + } + encoded, err := marshalUserSettingsPayload(settings) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var payload struct { + Views []map[string]any `json:"thread_views"` + Draft map[string]any `json:"thread_view_draft"` + } + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatal(err) + } + for _, value := range []map[string]any{payload.Views[0], payload.Draft} { + if value["layout"] != tc.layout || value["auto_hide_composer"] != tc.autoHide { + t.Fatalf("presentation = %v/%v, want %s/%v", value["layout"], value["auto_hide_composer"], tc.layout, tc.autoHide) + } + if value["max_columns"] != float64(7) || value["task_scope"].(map[string]any)["task_ids"].([]any)[0] != "task-sentinel" { + t.Fatalf("query changed: %+v", value) + } + } + }) + } +} diff --git a/apps/web/app/threads/threads-page-client.tsx b/apps/web/app/threads/threads-page-client.tsx index affc427540..8457dcb0ec 100644 --- a/apps/web/app/threads/threads-page-client.tsx +++ b/apps/web/app/threads/threads-page-client.tsx @@ -26,12 +26,14 @@ function ThreadsPageHeader({ repositories, threads, activeMobileTaskId, + gridHeightFallback, }: { workspaceId: string | null | undefined; query: ThreadViewQueryResult; repositories: ComponentProps["repositories"]; threads: readonly ActiveThread[]; activeMobileTaskId: string | null; + gridHeightFallback: boolean; }) { return ( thread.taskId === activeMobileTaskId) + 1} @@ -193,19 +196,22 @@ export function ThreadsPageClient() {
( + renderHeader={(activeMobileTaskId, gridHeightFallback) => ( )} /> diff --git a/apps/web/components/task/chat/chat-input-area.test.tsx b/apps/web/components/task/chat/chat-input-area.test.tsx index a511586c20..b09602f54b 100644 --- a/apps/web/components/task/chat/chat-input-area.test.tsx +++ b/apps/web/components/task/chat/chat-input-area.test.tsx @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { act, cleanup, render, renderHook, screen } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-library/react"; import type { ReactNode } from "react"; const toastMock = vi.fn(); const handleSendMessageMock = vi.fn(); const useKeyboardShortcutMock = vi.hoisted(() => vi.fn()); let mockProceedStepName: string | null = null; +let mockQueuePopulated = false; const mockState = { userSettings: { keyboardShortcuts: {}, chatSubmitKey: "enter" }, @@ -46,7 +47,7 @@ vi.mock("@/components/task/share/share-button", () => ({ })); vi.mock("@/components/task/chat/chat-input-container", () => ({ - ChatInputContainer: () => null, + ChatInputContainer: () =>