Skip to content
Merged
10 changes: 7 additions & 3 deletions src/components/chat/composer-connection-status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,17 @@ import { cn } from "@/lib/utils"
// and anything unknown falls back to "disconnected".
type ConnStatusKey = "connected" | "connecting" | "error" | "disconnected"

// Colour-coded heart icons per connection state (HeartCrack stands in for the
// requested HeartX, which lucide does not ship).
// Heart icons per connection state (HeartCrack stands in for the requested
// HeartX, which lucide does not ship). The steady "connected" state carries no
// colour override, so it inherits the row's default `text-muted-foreground`
// (matching the sibling context-usage circle) — the common resting state
// shouldn't stand out. Only the transient/abnormal states stay colour-coded:
// connecting amber, error red, disconnected dimmed.
const STATUS_ICON: Record<
ConnStatusKey,
{ Icon: LucideIcon; className: string }
> = {
connected: { Icon: HeartHandshake, className: "text-emerald-500" },
connected: { Icon: HeartHandshake, className: "" },
connecting: { Icon: HeartPulse, className: "text-amber-500 animate-pulse" },
error: { Icon: HeartCrack, className: "text-red-500" },
disconnected: { Icon: HeartOff, className: "text-muted-foreground/60" },
Expand Down
8 changes: 7 additions & 1 deletion src/components/chat/message-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3595,7 +3595,13 @@ export function MessageInput({
<div className="flex min-w-0 items-center gap-1">
<ConversationFolderBranchPicker tabId={attachmentTabId} />
</div>
<div className="flex shrink-0 items-center gap-3">
{/* `pr-px` offsets the composer chrome's 1px border: the send button
sits INSIDE that border while this status row sits outside it, so
without the 1px nudge the trailing icon hangs 1px past the button.
With it, the connection icon's RIGHT edge is flush (0px) with the
send button's right edge in the action bar above — no centring
slot, which would inset the narrow icon and break the alignment. */}
<div className="flex shrink-0 items-center gap-3 pr-px">
<ComposerContextUsage tabId={attachmentTabId ?? null} />
<ComposerConnectionStatus tabId={attachmentTabId ?? null} />
</div>
Expand Down
207 changes: 207 additions & 0 deletions src/components/conversations/sidebar-conversation-grouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
reuseSet,
selectChatConversationsWithReuse,
selectPinnedWithReuse,
worktreeChildrenByParent,
type SidebarRow,
} from "./sidebar-conversation-grouping"

Expand Down Expand Up @@ -185,6 +186,64 @@ describe("groupByFolderWithReuse", () => {
})
})

describe("worktreeChildrenByParent", () => {
const folder = (
id: number,
parent_id: number | null,
sort_order = 0,
name = `folder-${id}`
) => ({ id, parent_id, sort_order, name })

it("returns an empty map when no repo has worktree children", () => {
const folders = [folder(10, null), folder(20, null)]
expect(worktreeChildrenByParent([10, 20], folders).size).toBe(0)
})

it("groups each repo's open worktree children under it", () => {
const folders = [
folder(10, null),
folder(12, 10, 2, "beta"),
folder(11, 10, 1, "alpha"),
folder(20, null),
folder(21, 20, 0, "wt"),
]
const map = worktreeChildrenByParent([10, 20], folders)
// 11 (sort_order 1) before 12 (sort_order 2).
expect(map.get(10)).toEqual([11, 12])
expect(map.get(20)).toEqual([21])
// Only repos WITH children are keys.
expect([...map.keys()].sort((a, b) => a - b)).toEqual([10, 20])
})

it("orders children by sort_order, then name, then id", () => {
const folders = [
folder(10, null),
folder(13, 10, 0, "b"),
folder(12, 10, 0, "a"),
folder(11, 10, 0, "a"),
]
// equal sort_order → by name ("a" < "b"): 11/12 before 13; tie on name → id.
expect(worktreeChildrenByParent([10], folders).get(10)).toEqual([
11, 12, 13,
])
})

it("omits an orphan worktree whose parent is not a top-level entry", () => {
// Parent 10 is closed → absent from the top-level set, so worktree 11 is
// nobody's child here and no container is produced.
const folders = [folder(11, 10), folder(20, null)]
expect(worktreeChildrenByParent([11, 20], folders).size).toBe(0)
})

it("does not mutate the inputs", () => {
const folders = [folder(10, null), folder(11, 10)]
const top = [10]
worktreeChildrenByParent(top, folders)
expect(top).toEqual([10])
expect(folders.map((f) => f.id)).toEqual([10, 11])
})
})

describe("reuseSet", () => {
it("returns the previous set when membership is unchanged", () => {
const prev = new Set(["a:1", "b:2"])
Expand Down Expand Up @@ -783,6 +842,154 @@ describe("buildRows", () => {
})
})

describe("buildRows — Show worktrees container tree", () => {
// Trim the trailing (always-present) Chat section for exact folder assertions.
const trimChats = (rows: SidebarRow[]): SidebarRow[] => {
const i = rows.findIndex(
(r) => r.kind === "section" && r.section === "chats"
)
return i === -1 ? rows : rows.slice(0, i)
}

it("nests a container repo's root sub-group + worktrees, both at depth 1", () => {
const rootConv = conv(1, 10)
const wtConv = conv(2, 11)
const rows = buildRows({
pinned: [],
pinnedExpanded: true,
orderedFolderIds: [10],
byFolder: new Map([
[10, [rootConv]],
[11, [wtConv]],
]),
folderExpanded: {},
folderTotalCounts: new Map([
[10, 1],
[11, 1],
]),
foldersExpanded: true,
chatConversations: [],
chatsExpanded: true,
containerChildren: new Map([[10, [11]]]),
})
expect(trimChats(rows)).toEqual([
{ kind: "section", section: "folders", expanded: true, count: 1 },
{ kind: "folder", folderId: 10 }, // container
{ kind: "root-group", folderId: 10 }, // repo's own sessions
{ kind: "conversation", conversation: rootConv, depth: 1 },
{ kind: "folder", folderId: 11 }, // worktree
{ kind: "conversation", conversation: wtConv, depth: 1 },
])
})

it("hides the whole subtree when the container is collapsed", () => {
const rows = buildRows({
pinned: [],
pinnedExpanded: true,
orderedFolderIds: [10],
byFolder: new Map([
[10, [conv(1, 10)]],
[11, [conv(2, 11)]],
]),
folderExpanded: { 10: false },
folderTotalCounts: new Map(),
foldersExpanded: true,
chatConversations: [],
chatsExpanded: true,
containerChildren: new Map([[10, [11]]]),
})
// Container header only — no root-group, no worktree rows.
expect(trimChats(rows)).toEqual([
{ kind: "section", section: "folders", expanded: true, count: 1 },
{ kind: "folder", folderId: 10 },
])
})

it("collapses only the root sub-group, leaving worktrees visible", () => {
const wtConv = conv(2, 11)
const rows = buildRows({
pinned: [],
pinnedExpanded: true,
orderedFolderIds: [10],
byFolder: new Map([
[10, [conv(1, 10)]],
[11, [wtConv]],
]),
folderExpanded: {},
folderTotalCounts: new Map(),
foldersExpanded: true,
chatConversations: [],
chatsExpanded: true,
containerChildren: new Map([[10, [11]]]),
rootGroupCollapsed: new Set([10]),
})
expect(trimChats(rows)).toEqual([
{ kind: "section", section: "folders", expanded: true, count: 1 },
{ kind: "folder", folderId: 10 },
{ kind: "root-group", folderId: 10 }, // header shown, sessions hidden
{ kind: "folder", folderId: 11 },
{ kind: "conversation", conversation: wtConv, depth: 1 },
])
})

it("emits an empty hint for an empty root sub-group / worktree", () => {
const rows = buildRows({
pinned: [],
pinnedExpanded: true,
orderedFolderIds: [10],
byFolder: new Map(),
folderExpanded: {},
folderTotalCounts: new Map([
[10, 4],
[11, 0],
]),
foldersExpanded: true,
chatConversations: [],
chatsExpanded: true,
containerChildren: new Map([[10, [11]]]),
})
expect(trimChats(rows)).toEqual([
{ kind: "section", section: "folders", expanded: true, count: 1 },
{ kind: "folder", folderId: 10 },
{ kind: "root-group", folderId: 10 },
{ kind: "empty", folderId: 10, totalConversationCount: 4 },
{ kind: "folder", folderId: 11 },
{ kind: "empty", folderId: 11, totalConversationCount: 0 },
])
})

it("renders a repo with no worktree children flat at depth 0 (no root-group)", () => {
const c = conv(1, 20)
const rows = buildRows({
pinned: [],
pinnedExpanded: true,
orderedFolderIds: [10, 20],
byFolder: new Map([
[11, [conv(9, 11)]],
[20, [c]],
]),
folderExpanded: {},
folderTotalCounts: new Map(),
foldersExpanded: true,
chatConversations: [],
chatsExpanded: true,
// Only repo 10 is a container; repo 20 stays a plain flat folder.
containerChildren: new Map([[10, [11]]]),
})
const trimmed = trimChats(rows)
// Repo 20 (plain): header + its own session at depth 0 — no root-group.
expect(trimmed).toContainEqual({ kind: "folder", folderId: 20 })
expect(trimmed).toContainEqual({
kind: "conversation",
conversation: c,
depth: 0,
})
expect(
trimmed.some((r) => r.kind === "root-group" && r.folderId === 20)
).toBe(false)
})
})

describe("mergeChildrenById", () => {
it("keeps live events over the snapshot by id and adds new children", () => {
const snapA = conv(100, 1, { status: "pending" })
Expand Down
Loading
Loading