PR #3626
Sections
Review

feat(threads): add grid layouts and auto-hide composers

main ← feature/explore-threads-grid-30e 92 files +1847 −312 PR #3626 ↗

Threads saved views now store layout and auto-hide preference, render a two-row grid, and hide the composer until hover or focus.

Why this change

The Threads deck shows only a single row of columns and always shows the composer. This wastes vertical space and hides the overview when many threads are active.

What it does

Architecture, end to end

Presentation flows from SQLite through validation to the frontend store and board. The board picks grid or columns and the column controls composer disclosure.

flowchart LR
  Store[(SQLite user_settings)] --> Service[service/thread_views.go]
  Service --> Models[models/thread_views.go]
  Models --> Boot[backendapp/boot_thread_views.go]
  Boot --> Wire[lib/state/thread-view-wire.ts]
  Wire --> Zustand[Zustand threadViews]
  Zustand --> Board[threads-board.tsx]
  Board --> Layout[thread-layout.ts]
  Board --> Column[thread-column.tsx]
  Column --> Disclosure[use-composer-disclosure.ts]
  Disclosure --> Region[composer-disclosure.tsx]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

type ThreadView struct
Click for details →

Adds layout and autoHideComposer to the persisted view and draft so the backend can store the presentation choice.

Model
const (
  ThreadTaskScopeAll      = "all"
  ThreadTaskScopeSelected = "selected"
  ThreadLayoutColumns     = "columns"
  ThreadLayoutGrid        = "grid"
)

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"`
  Layout           string             `json:"layout"`
  AutoHideComposer bool               `json:"auto_hide_composer"`
}

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"`
  Layout           string             `json:"layout"`
  AutoHideComposer bool               `json:"auto_hide_composer"`
}
func decodeThreadViewJSON(data []byte, target any) error
Click for details →

Rejects explicit null or empty layout and null auto_hide_composer before the service layer applies defaults.

Validation
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 validateThreadLayout(layout string) error
Click for details →

Validates layout values and defaults an empty layout to columns so old clients keep working.

Apply and validate
func applyThreadViews(settings *models.UserSettings, req *UpdateUserSettingsRequest) error {
  if req.ThreadViews == nil {
    return nil
  }
  views := *req.ThreadViews
  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
  return nil
}

func validateThreadLayout(layout string) error {
  switch layout {
  case "", models.ThreadLayoutColumns, models.ThreadLayoutGrid:
    return nil
  default:
    return fmt.Errorf("unsupported thread layout %q", layout)
  }
}
func normalizeThreadPresentation(layoutRaw, autoHideRaw json.RawMessage) (string, bool)
Click for details →

Decodes stored presentation independently so an unknown value does not discard the query or block settings load.

Decode
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
}
export function resolveThreadLayout(opts): ThreadLayoutResult
Click for details →

Picks grid or columns based on layout, mobile, task count, and measured height, with a fallback to columns when space is low.

Resolver
export function resolveThreadLayout({
  layout,
  isMobile,
  taskCount,
  contentHeight,
}: {
  layout: ThreadLayout;
  isMobile: boolean;
  taskCount: number;
  contentHeight: number | null;
}): ThreadLayoutResult {
  const columns: ThreadLayoutResult = {
    layout: "columns",
    rows: 1,
    columns: taskCount,
    heightFallback: false,
  };
  if (layout !== "grid" || isMobile || taskCount === 0) return columns;
  if (contentHeight === null) return columns;
  if (taskCount > 1 && contentHeight < 612) return { ...columns, heightFallback: true };
  return {
    layout: "grid",
    rows: taskCount === 1 ? 1 : 2,
    columns: Math.ceil(taskCount / 2),
    heightFallback: false,
  };
}
Board style
function boardLayoutStyle(composition: ThreadLayoutResult) {
  if (composition.layout !== "grid") return undefined;
  return {
    gridAutoFlow: "column",
    gridTemplateRows: `repeat(${composition.rows}, minmax(0, 1fr))`,
    gridTemplateColumns: `repeat(${composition.columns}, minmax(360px, 1fr))`,
  };
}

// In ThreadsBoard:
// className={composition.layout === "grid" ? "grid" : "flex"}
// style={boardLayoutStyle(composition)}
// autoHideComposer={autoHideComposer && !isMobile && isFinePointer}
export function useComposerDisclosure(opts)
Click for details →

Controls hover, focus, and activity holds for auto-hide on desktop and keeps the composer visible when busy or required.

Controller
export function useComposerDisclosure({
  enabled,
  sessionId,
}: {
  enabled: boolean;
  sessionId: string | null;
}) {
  const scope = `${enabled}:${sessionId ?? ""}`;
  const [storedState, setState] = useState(() => initialState(scope));
  const { forced, held } = collectActivity(state.activity);
  const expanded =
    !enabled || forced || (!state.manual && (state.revealed || state.focused || held));

  useEffect(() => {
    if (!enabled || !state.hovered) return;
    const timer = setTimeout(() => update((c) => ({ ...c, revealed: true, manual: false })), 150);
    return () => clearTimeout(timer);
  }, [enabled, state.hovered, update]);

  useEffect(() => {
    if (!enabled || state.hovered || state.focused || held || !state.revealed) return;
    const timer = setTimeout(() => update((c) => ({ ...c, revealed: false })), 300);
    return () => clearTimeout(timer);
  }, [enabled, state.hovered, state.focused, state.revealed, held, update]);

  const collapse = useCallback(() => {
    update((current) =>
      collectActivity(current.activity).forced ? current : { ...current, manual: true, revealed: false }
    );
  }, [update]);
  return { enabled, expanded, canCollapse: enabled && expanded && !forced, collapse, reveal, reportActivity };
}
Region and CSS
export function ComposerDisclosureRegion({ children }: { children: ReactNode }) {
  const disclosure = useComposerDisclosureContext();
  const collapsed = disclosure?.expanded === false;
  return (
    <div className={disclosure?.enabled ? "thread-composer-disclosure" : "contents"} data-state={collapsed ? "closed" : "open"} inert={collapsed || undefined}>
      <div className="thread-composer-clip">
        <div className="thread-composer-content">{children}</div>
      </div>
    </div>
  );
}

// CSS: grid-template-rows 0fr -> 1fr, 200ms in, 160ms out
// .thread-composer-content { opacity 0 -> 1, translateY(6px) -> 0 }
Viewport preserve
export function useTranscriptViewportResize({ scrollRef, enabled, isVisible }: TranscriptViewportOptions) {
  useEffect(() => {
    const element = scrollRef.current;
    if (!element || !enabled || !isVisible) return;
    let previous = readViewport(element);
    const observer = new ResizeObserver(() => {
      const current = readViewport(element);
      const heightChanged = current.height !== previous.height && previous.height > 0;
      if (heightChanged && current.width === previous.width && previous.atBottom) {
        element.scrollTop = element.scrollHeight - element.clientHeight;
      }
      previous = readViewport(element);
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [scrollRef, enabled, isVisible]);
}
Read the changes as a list

Persisted presentation fields

apps/backend/internal/user/models/thread_views.go

Adds layout and autoHideComposer to the persisted view and draft so the backend can store the presentation choice.

Model
const (
  ThreadTaskScopeAll      = "all"
  ThreadTaskScopeSelected = "selected"
  ThreadLayoutColumns     = "columns"
  ThreadLayoutGrid        = "grid"
)

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"`
  Layout           string             `json:"layout"`
  AutoHideComposer bool               `json:"auto_hide_composer"`
}

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"`
  Layout           string             `json:"layout"`
  AutoHideComposer bool               `json:"auto_hide_composer"`
}

Strict JSON validation

apps/backend/internal/user/models/thread_view_json.go

Rejects explicit null or empty layout and null auto_hide_composer before the service layer applies defaults.

Validation
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
}

Service defaults and layout check

apps/backend/internal/user/service/thread_views.go

Validates layout values and defaults an empty layout to columns so old clients keep working.

Apply and validate
func applyThreadViews(settings *models.UserSettings, req *UpdateUserSettingsRequest) error {
  if req.ThreadViews == nil {
    return nil
  }
  views := *req.ThreadViews
  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
  return nil
}

func validateThreadLayout(layout string) error {
  switch layout {
  case "", models.ThreadLayoutColumns, models.ThreadLayoutGrid:
    return nil
  default:
    return fmt.Errorf("unsupported thread layout %q", layout)
  }
}

Store decode with safe fallback

apps/backend/internal/user/store/thread_views.go

Decodes stored presentation independently so an unknown value does not discard the query or block settings load.

Decode
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
}

Grid layout resolver and board

apps/web/components/threads/thread-layout.ts

Picks grid or columns based on layout, mobile, task count, and measured height, with a fallback to columns when space is low.

Resolver
export function resolveThreadLayout({
  layout,
  isMobile,
  taskCount,
  contentHeight,
}: {
  layout: ThreadLayout;
  isMobile: boolean;
  taskCount: number;
  contentHeight: number | null;
}): ThreadLayoutResult {
  const columns: ThreadLayoutResult = {
    layout: "columns",
    rows: 1,
    columns: taskCount,
    heightFallback: false,
  };
  if (layout !== "grid" || isMobile || taskCount === 0) return columns;
  if (contentHeight === null) return columns;
  if (taskCount > 1 && contentHeight < 612) return { ...columns, heightFallback: true };
  return {
    layout: "grid",
    rows: taskCount === 1 ? 1 : 2,
    columns: Math.ceil(taskCount / 2),
    heightFallback: false,
  };
}
Board style
function boardLayoutStyle(composition: ThreadLayoutResult) {
  if (composition.layout !== "grid") return undefined;
  return {
    gridAutoFlow: "column",
    gridTemplateRows: `repeat(${composition.rows}, minmax(0, 1fr))`,
    gridTemplateColumns: `repeat(${composition.columns}, minmax(360px, 1fr))`,
  };
}

// In ThreadsBoard:
// className={composition.layout === "grid" ? "grid" : "flex"}
// style={boardLayoutStyle(composition)}
// autoHideComposer={autoHideComposer && !isMobile && isFinePointer}

Composer disclosure controller

apps/web/components/task/chat/use-composer-disclosure.ts

Controls hover, focus, and activity holds for auto-hide on desktop and keeps the composer visible when busy or required.

Controller
export function useComposerDisclosure({
  enabled,
  sessionId,
}: {
  enabled: boolean;
  sessionId: string | null;
}) {
  const scope = `${enabled}:${sessionId ?? ""}`;
  const [storedState, setState] = useState(() => initialState(scope));
  const { forced, held } = collectActivity(state.activity);
  const expanded =
    !enabled || forced || (!state.manual && (state.revealed || state.focused || held));

  useEffect(() => {
    if (!enabled || !state.hovered) return;
    const timer = setTimeout(() => update((c) => ({ ...c, revealed: true, manual: false })), 150);
    return () => clearTimeout(timer);
  }, [enabled, state.hovered, update]);

  useEffect(() => {
    if (!enabled || state.hovered || state.focused || held || !state.revealed) return;
    const timer = setTimeout(() => update((c) => ({ ...c, revealed: false })), 300);
    return () => clearTimeout(timer);
  }, [enabled, state.hovered, state.focused, state.revealed, held, update]);

  const collapse = useCallback(() => {
    update((current) =>
      collectActivity(current.activity).forced ? current : { ...current, manual: true, revealed: false }
    );
  }, [update]);
  return { enabled, expanded, canCollapse: enabled && expanded && !forced, collapse, reveal, reportActivity };
}
Region and CSS
export function ComposerDisclosureRegion({ children }: { children: ReactNode }) {
  const disclosure = useComposerDisclosureContext();
  const collapsed = disclosure?.expanded === false;
  return (
    <div className={disclosure?.enabled ? "thread-composer-disclosure" : "contents"} data-state={collapsed ? "closed" : "open"} inert={collapsed || undefined}>
      <div className="thread-composer-clip">
        <div className="thread-composer-content">{children}</div>
      </div>
    </div>
  );
}

// CSS: grid-template-rows 0fr -> 1fr, 200ms in, 160ms out
// .thread-composer-content { opacity 0 -> 1, translateY(6px) -> 0 }
Viewport preserve
export function useTranscriptViewportResize({ scrollRef, enabled, isVisible }: TranscriptViewportOptions) {
  useEffect(() => {
    const element = scrollRef.current;
    if (!element || !enabled || !isVisible) return;
    let previous = readViewport(element);
    const observer = new ResizeObserver(() => {
      const current = readViewport(element);
      const heightChanged = current.height !== previous.height && previous.height > 0;
      if (heightChanged && current.width === previous.width && previous.atBottom) {
        element.scrollTop = element.scrollHeight - element.clientHeight;
      }
      previous = readViewport(element);
    });
    observer.observe(element);
    return () => observer.disconnect();
  }, [scrollRef, enabled, isVisible]);
}

Data and storage

Two new presentation fields extend the saved view. Both are optional on read and strict on write.

FieldTypeNotes
layoutstringcolumns or grid, defaults to columns when omitted or unknown on read
auto_hide_composerbooleantrue hides composer until hover or focus on desktop, false always shows it
max_columnsinteger | nullstill limits admitted tasks across both grid rows

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Touches persisted user settings and boot payload, so a bad default could affect all users on load.
  • Grid fallback and mobile single-column keep the change reversible without data migration.
  • Covered by backend validation tests, store round-trip tests, and E2E for layouts and disclosure.

Trade-offs and review notes

Where to look first

  1. Check decodeThreadViewJSON and validateThreadLayout for correct rejection of empty, null, and unknown layouts.
  2. Confirm normalizeThreadPresentation and decodeStoredThreadViews do not discard the query on bad presentation.
  3. Verify resolveThreadLayout height fallback and board grid style with two rows and column-major order.
  4. Review useComposerDisclosure timers, forced and held activity, and ComposerDisclosureRegion inert handling.