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]);
}