diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5092397c24..d2290be58c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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:
diff --git a/TASK-sidebar-sections.md b/TASK-sidebar-sections.md
new file mode 100644
index 0000000000..71677128b3
--- /dev/null
+++ b/TASK-sidebar-sections.md
@@ -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"`).
\ No newline at end of file
diff --git a/app/src/components/app-sidebar/app-sidebar.tsx b/app/src/components/app-sidebar/app-sidebar.tsx
index ac8892ad42..1e60fa6e2e 100644
--- a/app/src/components/app-sidebar/app-sidebar.tsx
+++ b/app/src/components/app-sidebar/app-sidebar.tsx
@@ -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";
@@ -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";
@@ -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 (
-
- 0}
- />
-
- );
-}
-
export function AppSidebar({ ...props }: React.ComponentProps) {
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(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
@@ -228,6 +107,14 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
!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 (
@@ -306,7 +193,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
) : null}
- {!searching && channels.data?.length === 0 ? (
+ {empty ? (
) : null}
-
- {visibleChannels.map((channel) => (
-
- ))}
-
+ {!empty && channels.data !== undefined ? (
+
+ ) : null}
(null);
+ const [problem, setProblem] = useState(null);
return (
<>
@@ -94,6 +104,13 @@ export const Channel = memo(function Channel({
}
params={{ channelId }}
type="button"
+ // A row carries its channel, so a folder's header can be dropped on. Dragging is the
+ // pointer's way in; the menu and the panel beside an open conversation are the rest.
+ draggable
+ onDragStart={(event) => {
+ event.dataTransfer.setData(DRAGGED_CHANNEL_MIME, channelId);
+ event.dataTransfer.effectAllowed = "move";
+ }}
className="flex flex-row py-2 px-2.5 gap-2 items-center w-full hover:bg-foreground/5 rounded-lg [contain-intrinsic-size:auto_3.25rem] [content-visibility:auto]"
activeProps={{
className: "bg-foreground/5",
@@ -116,16 +133,24 @@ export const Channel = memo(function Channel({
{
- setPinProblem(null);
+ setProblem(null);
setPinned.mutate(
{ channelId, pinned: !pinned },
- { onError: (thrown) => setPinProblem(thrown.message) },
+ { onError: (thrown) => setProblem(thrown.message) },
);
}}
>
{pinned ? : }
{pinned ? "Unpin conversation" : "Pin conversation"}
+ {/* Filing is this person's own; the folders offered are the ones they own, and the row
+ only exists because they are a member of the conversation. */}
+
{canRename ? (
setRenaming(true)}>
@@ -141,9 +166,9 @@ export const Channel = memo(function Channel({
- {pinProblem ? (
+ {problem ? (
- {pinProblem}
+ {problem}
) : null}
+ isUnread(channel, (params as { channelId?: string }).channelId),
+ });
+ return (
+
+ 0}
+ projectId={channel.projectId ?? null}
+ projects={projects}
+ />
+
+ );
+}
+
+/** The rows of one section, or the line that stands in for them while it holds none. */
+function SectionRows({
+ channels,
+ projects,
+ emphasis,
+ animateOrder,
+ emptyText,
+ emptyHint,
+}: {
+ channels: ChannelSummary[];
+ projects: Project[];
+ emphasis: MessageListEmphasis;
+ animateOrder: boolean;
+ emptyText: string;
+ emptyHint?: string;
+}) {
+ if (channels.length === 0) {
+ return ;
+ }
+ return (
+
+ {pinnedFirst(channels).map((channel) => (
+
+ ))}
+
+ );
+}
+
+/** One of Group and Direct: a header, and the conversations membership puts there. */
+function MembershipSection({
+ id,
+ channels,
+ collapsed,
+ onToggle,
+ searching,
+ projects,
+ emphasis,
+ animateOrder,
+}: {
+ id: "group" | "direct";
+ channels: ChannelSummary[];
+ collapsed: boolean;
+ onToggle: () => void;
+ searching: boolean;
+ projects: Project[];
+ emphasis: MessageListEmphasis;
+ animateOrder: boolean;
+}) {
+ if (!sectionVisible(channels.length, searching)) return null;
+ const bodyId = `sidebar-section-${id}`;
+ return (
+
+
+
+
+
+
+ );
+}
+
+/**
+ * One folder's header, and what is filed in it.
+ *
+ * Folded on its own, without being remembered: the section's fold is the one a person asked to keep
+ * across devices, and a folder inside it is a detail of the moment they are looking at it. Rename is
+ * inline — the name is all a folder has — and Delete is confirmed by a dialog that says the
+ * conversations stay.
+ */
+function FolderBlock({
+ folder,
+ projects,
+ emphasis,
+ animateOrder,
+}: {
+ folder: Folder;
+ projects: Project[];
+ emphasis: MessageListEmphasis;
+ animateOrder: boolean;
+}) {
+ const queryClient = useQueryClient();
+ const rename = useMutation(renameProjectMutationOptions(queryClient));
+ /*
+ * The other way in: a row dragged from anywhere in the sidebar and dropped on this folder.
+ * Filed through `mutate` rather than the promise because this component stays on screen — the
+ * folder is not closed by the drop the way the menu is closed by the click, so the observer is
+ * still listening when the server answers.
+ */
+ const move = useMutation(moveChannelToProjectMutationOptions(queryClient));
+ /* Whether a row this folder accepts is over it, for the highlight a drop target owes. */
+ const [droppable, setDroppable] = useState(false);
+ const [collapsed, setCollapsed] = useState(false);
+ const [renaming, setRenaming] = useState(false);
+ const [confirming, setConfirming] = useState(false);
+ const bodyId = `sidebar-project-${folder.project.id}`;
+
+ return (
+
+
+
+ setRenaming(true)}>
+
+ Rename project…
+
+ setConfirming(true)}
+ variant="destructive"
+ >
+
+ Delete project…
+
+
+
+ {/* No toast in this app: why a drop did not take is said on the folder it was dropped on. */}
+ {move.error ? (
+
+ {move.error.message}
+
+ ) : null}
+
+
+
+
+
+ );
+}
+
+/**
+ * The Projects section: the "+" that makes a folder, each folder, and the hint while there are none.
+ *
+ * The new folder is named where it will be drawn — an inline box under the header — rather than in
+ * a dialog, so the person types the name in the place the row appears. Escape leaves nothing behind.
+ */
+function ProjectsSection({
+ folders,
+ projects,
+ collapsed,
+ onToggle,
+ searching,
+ emphasis,
+ animateOrder,
+}: {
+ folders: Folder[];
+ projects: Project[];
+ collapsed: boolean;
+ onToggle: () => void;
+ searching: boolean;
+ emphasis: MessageListEmphasis;
+ animateOrder: boolean;
+}) {
+ const queryClient = useQueryClient();
+ const create = useMutation(createProjectMutationOptions(queryClient));
+ const [creating, setCreating] = useState(false);
+ const bodyId = "sidebar-section-projects";
+ const conversationCount = folders.reduce(
+ (total, folder) => total + folder.channels.length,
+ 0,
+ );
+ /*
+ * While searching, a folder is drawn only if what is filed in it matched: an empty folder beside
+ * the box would be a heading for results that do not exist, and the ones that do are the answer.
+ */
+ const shown = searching
+ ? folders.filter((folder) => folder.channels.length > 0)
+ : folders;
+
+ if (!sectionVisible(conversationCount, searching) && !creating) return null;
+
+ return (
+
+ );
+}
+
+export function ConversationSections({
+ channels,
+ projects,
+ collapsed,
+ onToggle,
+ searching,
+ emphasis,
+ animateOrder,
+}: {
+ /** The roster, already narrowed by the search box. */
+ channels: ChannelSummary[];
+ projects: Project[];
+ /** Which sections this person folded, as their account has it. */
+ collapsed: SidebarSections;
+ onToggle: (id: SidebarSectionId) => void;
+ searching: boolean;
+ emphasis: MessageListEmphasis;
+ animateOrder: boolean;
+}) {
+ const sections = conversationSections(channels, projects);
+ // A search opens what it matched: a result inside a folded section is an answer the person was
+ // told about and not shown. The fold they chose is drawn over again as soon as the box is empty.
+ const folded = collapsedSections(collapsed, searching);
+
+ return (
+ <>
+ onToggle("group")}
+ projects={projects}
+ searching={searching}
+ />
+ onToggle("direct")}
+ projects={projects}
+ searching={searching}
+ />
+ onToggle("projects")}
+ projects={projects}
+ searching={searching}
+ />
+ >
+ );
+}
diff --git a/app/src/components/app-sidebar/delete-project-dialog.tsx b/app/src/components/app-sidebar/delete-project-dialog.tsx
new file mode 100644
index 0000000000..a8bb5fe244
--- /dev/null
+++ b/app/src/components/app-sidebar/delete-project-dialog.tsx
@@ -0,0 +1,74 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { deleteProjectMutationOptions } from "@/lib/projects/mutations";
+import type { Project } from "@/lib/projects/queries";
+
+/**
+ * Deletes a folder, and says what that does not do.
+ *
+ * Confirmed rather than immediate, because the row it was invoked on is one of several
+ * identical-looking folder rows — but the sentence matters more than the confirmation: a person
+ * deleting a folder has to be able to see that their conversations are not going with it.
+ */
+export function DeleteProjectDialog({
+ project,
+ open,
+ onOpenChange,
+}: {
+ project: Project;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const queryClient = useQueryClient();
+ const remove = useMutation(deleteProjectMutationOptions(queryClient));
+
+ return (
+
+ );
+}
diff --git a/app/src/components/app-sidebar/move-to-project-menu.tsx b/app/src/components/app-sidebar/move-to-project-menu.tsx
new file mode 100644
index 0000000000..5ef3a74d61
--- /dev/null
+++ b/app/src/components/app-sidebar/move-to-project-menu.tsx
@@ -0,0 +1,100 @@
+import { IconFolderMinus, IconFolderPlus, IconPlus } from "@tabler/icons-react";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { Fragment, useState } from "react";
+import {
+ ContextMenuItem,
+ ContextMenuSeparator,
+ ContextMenuSub,
+ ContextMenuSubContent,
+ ContextMenuSubTrigger,
+} from "@/components/ui/context-menu";
+import { moveToProjectOffers } from "@/lib/channels/sections";
+import { moveChannelToProjectMutationOptions } from "@/lib/projects/mutations";
+import type { Project } from "@/lib/projects/queries";
+import { NewProjectDialog } from "./new-project-dialog";
+
+/**
+ * "Move to project ▸" and "Remove from project", for one conversation's menu.
+ *
+ * Only ever the caller's own folders, because the folder list came from `/api/projects`, which is
+ * owner-scoped; and only ever on a row the roster drew, which it draws for members. So what the
+ * server enforces — owner files, member moves — is what this menu can offer.
+ *
+ * A refused move is said on the row that asked, like a refused pin: the menu has closed by then and
+ * a conversation that quietly stayed where it was reads as a click that did nothing.
+ */
+export function MoveToProjectMenu({
+ channelId,
+ currentProjectId,
+ projects,
+ onProblem,
+}: {
+ channelId: string;
+ /** The folder this conversation is filed in, or null. Decides whether "Remove" is offered. */
+ currentProjectId: string | null;
+ projects: Project[];
+ onProblem: (problem: string) => void;
+}) {
+ const queryClient = useQueryClient();
+ const move = useMutation(moveChannelToProjectMutationOptions(queryClient));
+ const [creating, setCreating] = useState(false);
+ const offers = moveToProjectOffers(projects, currentProjectId);
+
+ /*
+ * Filed through the promise, not through the mutation's per-call callbacks: the click that asks
+ * for the move also closes the menu, and this hook lives in that menu. TanStack Query delivers
+ * `mutate`'s callbacks through the observer, and an unmounted observer has no listeners left — so
+ * a refused move reported that way was reported to nobody and the row said nothing. The promise
+ * settles whether or not this component is still on screen, and `onProblem` belongs to the row.
+ */
+ const file = (projectId: string | null) =>
+ move
+ .mutateAsync({ channelId, projectId })
+ .catch((error: Error) => onProblem(error.message));
+
+ return (
+ <>
+
+
+
+ Move to project
+
+
+ {offers.map((offer) =>
+ offer.kind === "project" ? (
+ file(offer.projectId)}
+ >
+ {offer.name}
+
+ ) : (
+
+ {/* The way to a folder that does not exist yet reads as its own choice, not as
+ one of the folders. */}
+
+ setCreating(true)}>
+
+ New project…
+
+
+ ),
+ )}
+
+
+ {currentProjectId ? (
+ file(null)}>
+
+ Remove from project
+
+ ) : null}
+ {/* The folder is made first, then this conversation is filed into it — the row stays in the
+ menu it was opened from while the dialog explains itself. */}
+ file(project.id)}
+ onOpenChange={setCreating}
+ open={creating}
+ />
+ >
+ );
+}
diff --git a/app/src/components/app-sidebar/new-project-dialog.tsx b/app/src/components/app-sidebar/new-project-dialog.tsx
new file mode 100644
index 0000000000..4dbc405101
--- /dev/null
+++ b/app/src/components/app-sidebar/new-project-dialog.tsx
@@ -0,0 +1,117 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { useId, useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogBody,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Field, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { createProjectMutationOptions } from "@/lib/projects/mutations";
+import type { Project } from "@/lib/projects/queries";
+
+/**
+ * Makes a folder from wherever the person is.
+ *
+ * The Projects section has its own inline box beside the "+", which is the usual way in. This one
+ * exists for the other way: a conversation's menu, where the folder the conversation should go into
+ * does not exist yet, and leaving the menu to make it first would lose the row that was right
+ * there. `onCreated` is what files the conversation into the new folder without a second trip.
+ */
+export function NewProjectDialog({
+ open,
+ onOpenChange,
+ onCreated,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onCreated?: (project: Project) => void;
+}) {
+ const formId = useId();
+ const queryClient = useQueryClient();
+ const create = useMutation(createProjectMutationOptions(queryClient));
+ const [draft, setDraft] = useState("");
+
+ const save = async () => {
+ let project: Project;
+ try {
+ project = await create.mutateAsync(draft);
+ } catch {
+ // The failure is on the mutation and drawn below; staying open says "not done".
+ return;
+ }
+ setDraft("");
+ create.reset();
+ onOpenChange(false);
+ onCreated?.(project);
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/components/app-sidebar/project-name-input.tsx b/app/src/components/app-sidebar/project-name-input.tsx
new file mode 100644
index 0000000000..1d331caf74
--- /dev/null
+++ b/app/src/components/app-sidebar/project-name-input.tsx
@@ -0,0 +1,85 @@
+import { useId, useState } from "react";
+import { Input } from "@/components/ui/input";
+
+/**
+ * A project name typed where the project will be drawn.
+ *
+ * Inline rather than in a dialog: the Projects section is where the new folder appears, so the box
+ * that names it sits in the same place, and Enter creates it without the person having to find a
+ * Save button. Escape, or clicking away, leaves nothing behind.
+ *
+ * The same box renames an existing folder — one component, because it is one interaction, and a
+ * name is the only thing a project has to edit.
+ */
+export function ProjectNameInput({
+ initialName = "",
+ placeholder,
+ submitLabel,
+ pending,
+ problem,
+ onSubmit,
+ onCancel,
+}: {
+ initialName?: string;
+ placeholder: string;
+ /** Said on the submit button's screen-reader label, so the two uses of this box stay distinct. */
+ submitLabel: string;
+ pending: boolean;
+ /** What the server said when the last attempt failed, if it did. */
+ problem?: string | null;
+ onSubmit: (name: string) => void;
+ onCancel: () => void;
+}) {
+ const inputId = useId();
+ const [draft, setDraft] = useState(initialName);
+ const submit = () => {
+ const name = draft.replace(/\s+/g, " ").trim();
+ if (name.length === 0) {
+ onCancel();
+ return;
+ }
+ onSubmit(name);
+ };
+
+ return (
+
+ );
+}
diff --git a/app/src/components/app-sidebar/section-header.tsx b/app/src/components/app-sidebar/section-header.tsx
new file mode 100644
index 0000000000..5425df0e80
--- /dev/null
+++ b/app/src/components/app-sidebar/section-header.tsx
@@ -0,0 +1,81 @@
+import { IconChevronRight } from "@tabler/icons-react";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
+
+/**
+ * A sidebar section's header row: chevron, title, and how many conversations it holds.
+ *
+ * The whole row is the toggle, and it is a button rather than a link so a folded section is still
+ * reachable by keyboard and says so to a screen reader (`aria-expanded`). The chevron points the way
+ * the section opens rather than naming the state, which is the one animation this row has.
+ */
+export function SectionHeader({
+ bodyId,
+ title,
+ count,
+ collapsed,
+ onToggle,
+ action,
+}: {
+ /** The id of the list this header folds, so `aria-controls` names something that exists. */
+ bodyId: string;
+ title: string;
+ count: number;
+ collapsed: boolean;
+ onToggle: () => void;
+ /** Sits beside the title and does not toggle anything, such as the Projects section's "+". */
+ action?: React.ReactNode;
+}) {
+ return (
+
+
+ {action}
+
+ );
+}
+
+/**
+ * What an empty section says in the place a conversation would be.
+ *
+ * Plain text rather than the app's `Empty` card: three of those stacked down the sidebar would be
+ * three framed boxes where the person expected their conversations, and this is a line about one
+ * section, not a screen about the whole roster.
+ */
+export function SectionEmpty({
+ text,
+ hint,
+}: {
+ text: string;
+ /** One sentence about how a conversation gets here. The Projects hint is the only one today. */
+ hint?: string;
+}) {
+ return (
+
+ {text}
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+}
diff --git a/app/src/components/channels/conversation-actions.tsx b/app/src/components/channels/conversation-actions.tsx
index 9d8097387b..48c01c53ba 100644
--- a/app/src/components/channels/conversation-actions.tsx
+++ b/app/src/components/channels/conversation-actions.tsx
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { setChannelPinnedMutationOptions } from "@/lib/channels/mutations";
import { channelListQueryOptions } from "@/lib/channels/queries";
import { DeleteConversationDialog } from "./delete-conversation-dialog";
+import { MoveToProjectButton } from "./move-to-project-button";
/**
* Pin and Delete for the conversation that is open, in the Bot panel beside it.
@@ -61,6 +62,7 @@ export function ConversationActions({
{pinned ? : }
{pinned ? "Unpin" : "Pin"}
+