Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,18 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

**Before upgrading.** No migrations run in this release: the database stays at
`0088_approval_reviewer_guidance`, and a 1.0.1 database is used as it is. Upgrading from 1.0.0 or
**The sidebar in sections.** The conversation sidebar now draws its conversations in three
collapsible sections — Group chats, Direct chats and Projects — and a person can make folders and
file conversations into them, by dragging a conversation onto a folder, from the conversation's
right-click menu, or from the "Move to project" button beside an open conversation. A folder is
one person's filing, so two people can file the same
conversation differently, and filing one takes it out of Group/Direct for that person only. Which
sections are folded is kept on the account, so it follows a person across devices. Upgrading runs
`0089`, which only adds: `projects` and `channel_projects` with a unique index, and nothing
existing is altered.

**Before upgrading.** Apart from `0089` above, no migrations run in this release: the database
stays at `0088_approval_reviewer_guidance`, and a 1.0.1 database is used as it is. Upgrading from 1.0.0 or
0.3.x runs the migrations listed under 1.0.1, and a database that has run them does not go back:
restore the backup taken before the upgrade instead. Three things change for an existing OpenBot
Desktop installation:
Expand Down
113 changes: 113 additions & 0 deletions TASK-sidebar-sections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Task: Group the sidebar conversations into collapsible sections (Group chats / Direct chats / Projects)

> Handoff note (devops agent, 2026-11-12). Repo: `/root/repos/openbot-src` (bun 1.3.14, workspace
> `app` + `server`). Kaneo: DevOps project, task #8 (stages #9–#12).
> **The work is already ~70% done and uncommitted** — see "Current state" at the bottom.
> Do not restart from scratch: finish, verify, commit.

You are working in the OpenBot monorepo (CopilotKit/OpenBot). First read the existing sidebar /
channel-list components in `app/src/` and the channels routes in `server/src/`, and follow the
conventions you find there (naming, data fetching, state management, design tokens).

## Goal
The sidebar shows conversations in three collapsible sections, in this order:
1. Group chats — channels with more than one human member or more than one Bot.
2. Direct chats — 1:1 channels between the signed-in person and a single coworker.
3. Projects — user-defined folders a person can create, rename, delete, and move channels into.

## Requirements

### Backend (server)
- PostgreSQL migration: `projects` (id, owner user id, name, sort order, created_at) and a
nullable `project_id` on channels (or a join table — justify the choice in the PR description).
A channel belongs to at most one project.
- Routes per existing API conventions:
- `GET /api/projects` — the caller's projects with their channels' ids;
- `POST /api/projects`, `PATCH /api/projects/:id`, `DELETE /api/projects/:id` (deleting a
project must NOT delete its channels — they return to the ungrouped list);
- `PATCH /api/channels/:id/project` — move a channel into a project or out (`null`).
- Authorization: only the project owner can rename/delete it and move channels into it; moving
requires channel membership (the same check the channel routes use). Never leak channel titles
to non-members through project endpoints.
- Persist per-user collapsed/expanded section state server-side (extend `agent_preferences` or
an equivalent existing mechanism; no browser localStorage).

### Frontend (app)
- Section header: chevron, title, conversation count; click toggles collapse; state survives reload.
- Channels sorted by most recent activity (keep current ordering logic). A channel inside a
Project does not also appear under Group/Direct.
- Group/Direct classification computed from data the app already fetches — no N+1 requests.
- Channel context menu: "Move to project ▸" (user's projects + "New project…"), "Remove from
project" when applicable; shown only per the authorization rules.
- Projects section header has "+" to create a project (inline rename input).
- Per-section empty states; sidebar search (if present) filters across sections and
force-expands sections with matches.
- Use existing design tokens/components; correct in light and dark themes; no new dependencies.

### Constraints
- Do not change the channel permission model: every existing channel route keeps resolving the
caller in `channel_memberships`. This feature is organization-only.
- Do not break the desktop (Tauri) build; sidebar must work at narrow widths.
- Follow docs/development.md: run migrations locally; lint/typecheck/test targets must pass.
- Tests: server route tests for project CRUD authorization and non-member title isolation;
component tests for section toggle and move-to-project menu if the repo has a component-test setup.
- Keep the diff reviewable: one migration, server routes + tests, frontend + tests, docs note.

## Definition of done
- Three sections render; group/direct classification matches membership.
- Create project "Website redesign", move two channels in, collapse/expand, reload — state persists.
- Deleting the project returns channels to normal sections, intact.
- Existing tests pass; new tests cover the rules above.

## Defaults (record any deviations in the PR description)
- A group channel CAN be in a project (any channel the user is a member of).
- Projects are per-user: the owner sees channels grouped, other members see them as usual.

---

## Current state in this checkout (measured 2026-11-12 05:2x UTC)

Uncommitted on `main`, 12 modified + 15 new files. Earlier session ran out of tool iterations
mid-frontend; verify everything, do not assume it is correct.

Server half (believed complete, unreviewed):
- `server/src/db/schema/projects.ts` — `projects` + `channel_projects` (join table with
`owner_user_id`; decision recorded in the schema comments: a folder is per person, so a
`project_id` column on `channels` would force one folder for everybody who sees the channel)
- `server/drizzle/0089_exotic_ultragirl.sql` + `meta/0089_snapshot.json`
- `server/src/projects/store.ts` (261 lines), `server/src/projects/routes.ts` (171 lines)
- wiring: `server/src/app.ts`, `server/src/index.ts`, `server/src/channels/routes.ts`
(`memberCount`, `projectId` in the roster payload), `server/src/db/schema/index.ts`
- section collapse state: `shared/user-preferences.ts`, `server/src/user-preferences.ts`,
`server/src/user-preferences-routes.ts`

App half (in progress):
- `app/src/components/app-sidebar/conversation-sections.tsx` (461 lines), `section-header.tsx`,
`move-to-project-menu.tsx`, `new-project-dialog.tsx`, `delete-project-dialog.tsx`,
`project-name-input.tsx`
- `app/src/lib/channels/sections.ts` (209), `order.ts`, `unread.ts`,
`app/src/lib/projects/{queries,mutations}.ts`, `app/src/lib/settings/sidebar-sections.ts`
- `app/src/components/app-sidebar/app-sidebar.tsx`, `channel.tsx`, `app/src/lib/channels/queries.ts`

Gate status at handoff:
- `bun run typecheck` → **app fails**: 10 TS errors, all "Property 'sidebarSections' is missing in
type 'UserPreferences'" in `tests/app-navigation.test.tsx`, `channel-pagination.test.tsx`,
`narrow-sidebar-sheet.test.tsx`, `self-host-banner.test.tsx`, `skins-account.test.tsx`,
`user-preferences.test.tsx`. Fix by one uniform mechanism (a fixture builder or a default in the
type), not file-by-file patching.
- `server` typecheck was killed with SIGKILL (looked like OOM) and is therefore **unverified**;
re-run it alone.
- No tests exist for projects yet. Docs untouched.

Environment notes:
- `bun` 1.3.14 at `/root/.bun/bin/bun`; deps installed (1.6G `node_modules`).
- Test Postgres: container `openbot-test-pg` on `127.0.0.1:55432`, db `openbot_test`
(user/password `openbot`). Migration 0089 was applied there by the earlier session — re-run
`bun run db:migrate` if in doubt.
- Running deployment: container `openbot` (server on `127.0.0.1:3001`). Do not restart or redeploy
it from here without asking; verify the UI in a dev server instead (`bun run dev`).
- `gh` is **not installed**, the remote is upstream `https://github.com/CopilotKit/OpenBot` over
HTTPS, there is no fork and no token: do **not** attempt to push or open a PR. Deliver a local
branch `feat/sidebar-sections`, a `sidebar-sections.patch`, and a filled-in PR body draft.
- Repo git identity is empty: set it repo-locally before the first commit
(`git config user.name "Sergey Shabalin"; git config user.email "mail@sshabalin.ru"`).
192 changes: 40 additions & 152 deletions app/src/components/app-sidebar/app-sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { IconLogout, IconPlus, IconSearch } from "@tabler/icons-react";
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { Link, type LinkOptions, useParams } from "@tanstack/react-router";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Link, type LinkOptions } from "@tanstack/react-router";
import type * as React from "react";
import { useRef, useState } from "react";
import { BrandName } from "@/components/edition/brand-name";
Expand Down Expand Up @@ -30,30 +29,32 @@ import {
} from "@/components/ui/sidebar";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import { useSignOutOffered } from "@/lib/auth/sign-out-offered";
import { untitledLine } from "@/lib/channels/label";
import {
type ChannelSummary,
channelListQueryOptions,
} from "@/lib/channels/queries";
import { matchingChannels } from "@/lib/channels/order";
import { channelListQueryOptions } from "@/lib/channels/queries";
import { useChannelEvents } from "@/lib/channels/use-channel-events";
import { appConfig } from "@/lib/generated/application-config";
import { EASE_OUT, ENTRANCE_SECONDS } from "@/lib/motion";
import { isNavigationLink, useNavigationItems } from "@/lib/navigation";
import { relativeTimeShort } from "@/lib/relative-time";
import {
type MessageListEmphasis,
useMessageListEmphasis,
} from "@/lib/settings/message-list";
import { projectsQueryOptions } from "@/lib/projects/queries";
import { useMessageListEmphasis } from "@/lib/settings/message-list";
import { useSidebarSections } from "@/lib/settings/sidebar-sections";
import { BotsNavBadge } from "../bot-profile/attention";
import { BrowserNavItem } from "../browser/browser-nav-item";
import { navigationRender } from "../edition/team-features-dialog";
import { OpenBotVersion } from "../layout/openbot-version";
import { Button } from "../ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty";
import { Channel } from "./channel";
import { ChannelPagination } from "./channel-pagination";
import { ConversationSections } from "./conversation-sections";
import { useSignOut } from "./use-sign-out";

/*
* The roster's two reading rules live in `lib/channels/order.ts` now: the sidebar applies them per
* section, and the skins' sheets still import them from here. Re-exported rather than moved out of
* sight, because these are what other surfaces already reach for.
*/
export { hasUnseenActivity, isUnread } from "@/lib/channels/unread";
export { matchingChannels, pinnedFirst } from "@/lib/channels/order";

const appLinkOptions = { to: "/" } satisfies LinkOptions;

const userMenuItemClassName = "gap-2 px-2 py-1.5";
Expand All @@ -80,144 +81,22 @@ function UserAvatar() {
*/
const MAX_ANIMATED_ROWS = 60;

/**
* The roster, narrowed to what the person typed.
*
* Matches the channel's name, its summary, its opening and the last message, because those are the
* things the row can actually show — searching against something invisible returns results a person
* cannot account for. The opening and the last message are included because one of them is what the
* second line draws until the conversation has been named. Message history beyond that line is not
* here to search: it lives in the thread store, and reaching for it is a server endpoint rather than
* a filter.
*
* An empty query returns the input array unchanged rather than a copy, so typing and clearing does
* not hand `AnimatePresence` a new array identity and restage the whole list.
*/
export function matchingChannels(
channels: ChannelSummary[] | undefined,
query: string,
): ChannelSummary[] {
if (!channels) {
return [];
}
const needle = query.trim().toLowerCase();
if (!needle) {
return channels;
}
return channels.filter((channel) =>
[channel.name, channel.summary, channel.opening, channel.lastMessage].some(
(field) => field?.toLowerCase().includes(needle),
),
);
}

/**
* Pinned channels first, everything else after, newest activity first within each group.
*
* The mirror of a server rule, not the rule itself: the roster query orders pinned-first and its
* cursor carries the pin, so a pinned channel arrives on page one however long ago it was last
* spoken in. Sorting here as well is for what happens between refetches — the socket patches a pin
* onto a loaded row without moving it, and re-sorts a page by recency alone — which is the same
* reason `byRecency` in use-channel-events.ts mirrors the recency rule. A stable partition, so the
* recency order inside each group is whatever arrived.
*/
export function pinnedFirst(channels: ChannelSummary[]): ChannelSummary[] {
return [...channels].sort((a, b) => Number(b.pinned) - Number(a.pinned));
}

/**
* Whether a Bot has said something this member has not had on screen yet.
*
* A Bot's message, and only a Bot's: your own message carries a null agent id and reading your own
* words needs no marker. ISO-8601 strings compare correctly as strings, which is the same bet the
* server's recency sort already makes.
*/
export function hasUnseenActivity(channel: ChannelSummary): boolean {
if (channel.lastMessageAgentId === null || channel.lastMessageAt === null) {
return false;
}
return (
channel.lastReadAt === null || channel.lastMessageAt > channel.lastReadAt
);
}

/** Unseen activity somewhere you are not looking. The open channel never shows the dot. */
export function isUnread(
channel: ChannelSummary,
openChannelId: string | undefined,
): boolean {
return channel.id !== openChannelId && hasUnseenActivity(channel);
}

/**
* A roster row that can animate.
*
* Two movements only: a channel that did not exist fades in, and a channel that was just spoken in
* moves to the top. Nothing else animates, a roster that reacts to being read is a roster that
* moves under the cursor.
*/
function ChannelRow({
channel,
animateOrder,
emphasis,
}: {
channel: ChannelSummary;
animateOrder: boolean;
emphasis: MessageListEmphasis;
}) {
const shouldReduceMotion = useReducedMotion();
// Whether this row is unread, as a boolean, for the same reason `Channel` computes `isOpen`
// that way: navigating re-renders the rows whose answer changed, not the whole roster.
const unread = useParams({
strict: false,
select: (params) =>
isUnread(channel, (params as { channelId?: string }).channelId),
});
return (
<motion.div
animate={{ opacity: 1, transform: "translateY(0px)" }}
initial={{
opacity: 0,
transform: shouldReduceMotion ? "none" : "translateY(-8px)",
}}
exit={{ opacity: 0 }}
layout={animateOrder && !shouldReduceMotion ? "position" : false}
transition={{ duration: ENTRANCE_SECONDS, ease: EASE_OUT }}
>
<Channel
emphasis={emphasis}
channelId={channel.id}
participantIds={channel.agentIds}
name={channel.name}
summary={channel.summary ?? undefined}
untitled={untitledLine(channel)}
lastMessageAt={
channel.lastMessageAt
? relativeTimeShort(channel.lastMessageAt)
: undefined
}
pinned={channel.pinned}
canRename={channel.canRename ?? false}
unread={unread}
// A turn running, or work the conversation waits on between turns: a hop, a routine.
busy={(channel.busy ?? false) || (channel.work?.length ?? 0) > 0}
/>
</motion.div>
);
}

export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const emphasis = useMessageListEmphasis();
const { data: currentUser } = useQuery(currentUserQueryOptions());
const signOut = useSignOut();
const signOutOffered = useSignOutOffered();
const channels = useInfiniteQuery(channelListQueryOptions());
// The folders, in the same breath as the roster: the Projects section is drawn from this read and
// each channel's own `projectId`, so no row is asked about separately.
const projects = useQuery(projectsQueryOptions());
const sidebar = useSidebarSections();
// One socket for the app, opened where the roster is kept live.
useChannelEvents();
const [search, setSearch] = useState("");
const scrollRoot = useRef<HTMLDivElement>(null);
const searching = search.trim().length > 0;
const visibleChannels = pinnedFirst(matchingChannels(channels.data, search));
const visibleChannels = matchingChannels(channels.data, search);
/*
* FILTERING DOES NOT ANIMATE. Rows exit and relayout on every keystroke otherwise, which is a
* list thrashing under somebody who is still typing — and the moving target is the very thing
Expand All @@ -228,6 +107,14 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
!searching && (channels.data?.length ?? 0) <= MAX_ANIMATED_ROWS;
const navigation = useNavigationItems();
const { setOpenMobile } = useSidebar();
const projectsCache = projects.data ?? [];
/*
* Whether there is anything to section at all. Nothing is drawn until the roster has answered, so
* a slow read shows a quiet sidebar rather than three sections announcing they are empty; and a
* person with no conversations and no folders keeps the one card that tells them how to start.
*/
const empty =
channels.data?.length === 0 && projectsCache.length === 0 && !searching;

return (
<Sidebar variant="inset" {...props}>
Expand Down Expand Up @@ -306,7 +193,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Empty>
</div>
) : null}
{!searching && channels.data?.length === 0 ? (
{empty ? (
<div className="py-4">
<Empty className="border border-dashed min-h-[40dvh]">
<EmptyHeader>
Expand All @@ -318,16 +205,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Empty>
</div>
) : null}
<AnimatePresence initial={false}>
{visibleChannels.map((channel) => (
<ChannelRow
emphasis={emphasis}
key={channel.id}
animateOrder={animateOrder}
channel={channel}
/>
))}
</AnimatePresence>
{!empty && channels.data !== undefined ? (
<ConversationSections
animateOrder={animateOrder}
channels={visibleChannels}
collapsed={sidebar.sections}
emphasis={emphasis}
onToggle={sidebar.toggle}
projects={projectsCache}
searching={searching}
/>
) : null}
<ChannelPagination
query={channels}
scrollRoot={scrollRoot}
Expand Down
Loading