summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-10-15 15:01:24 +0900
committerAdam Malczewski <[email protected]>2026-10-15 15:01:24 +0900
commitab51a8144c3faf1d545e33195f2190492219e861 (patch)
treeb12ddbf7465db1281022b1b19d34fbc59083a9c7
parentc23aa41d1ffa71ac905da8ac9488b07322327695 (diff)
downloaddispatch-web-ab51a8144c3faf1d545e33195f2190492219e861.tar.gz
dispatch-web-ab51a8144c3faf1d545e33195f2190492219e861.zip
fix(tabs): 20% black compounding group boxes; trim bottom padding under a last-row nested box so spacing stays even
-rw-r--r--src/features/tabs/index.ts1
-rw-r--r--src/features/tabs/tab-groups.test.ts26
-rw-r--r--src/features/tabs/tab-groups.ts24
-rw-r--r--src/features/tabs/ui.test.ts34
-rw-r--r--src/features/tabs/ui/TabGroupBox.svelte17
5 files changed, 96 insertions, 6 deletions
diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts
index a0c53f8..980e8ee 100644
--- a/src/features/tabs/index.ts
+++ b/src/features/tabs/index.ts
@@ -6,6 +6,7 @@ export {
groupDescendants,
groupGutterPx,
groupParentCandidates,
+ lastRowIsGroup,
visibleGroupParents,
} from "./tab-groups";
export type { Tab, TabsState } from "./tabs";
diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts
index ca2492c..83f273c 100644
--- a/src/features/tabs/tab-groups.test.ts
+++ b/src/features/tabs/tab-groups.test.ts
@@ -6,6 +6,7 @@ import {
groupDescendants,
groupGutterPx,
groupParentCandidates,
+ lastRowIsGroup,
visibleGroupParents,
} from "./tab-groups";
import type { Tab } from "./tabs";
@@ -122,3 +123,28 @@ describe("groupBleedPx / groupGutterPx", () => {
expect(groupBleedPx(12, 12)).toBeGreaterThan(0);
});
});
+
+describe("lastRowIsGroup (mirrors grid row-dense placement)", () => {
+ const leaf = (id: string) => ({ tab: tab(id), children: [] });
+ const group = (id: string) => ({ tab: tab(id), children: [leaf(`${id}-kid`)] });
+
+ it("is false with only cards", () => {
+ expect(lastRowIsGroup([])).toBe(false);
+ expect(lastRowIsGroup([leaf("a"), leaf("b"), leaf("c")])).toBe(false);
+ });
+
+ it("is true when a nested group comes last", () => {
+ expect(lastRowIsGroup([leaf("a"), group("g")])).toBe(true);
+ expect(lastRowIsGroup([group("g")])).toBe(true);
+ });
+
+ it("stays true when a later card back-fills an earlier half row", () => {
+ // [a, _] then [g, g]; b fills a's half row → g is still the last row.
+ expect(lastRowIsGroup([leaf("a"), group("g"), leaf("b")])).toBe(true);
+ });
+
+ it("is false when a later card has to open a new row below the group", () => {
+ expect(lastRowIsGroup([group("g"), leaf("a")])).toBe(false);
+ expect(lastRowIsGroup([leaf("a"), leaf("b"), group("g"), leaf("c")])).toBe(false);
+ });
+});
diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts
index ef16451..12870ee 100644
--- a/src/features/tabs/tab-groups.ts
+++ b/src/features/tabs/tab-groups.ts
@@ -129,6 +129,30 @@ export function directGroupChildren(tabs: readonly Tab[], conversationId: string
}
/**
+ * Whether a group box's LAST grid row is a nested group box (rather than a
+ * card). Mirrors the grid's `row dense` auto-placement: the group parent's
+ * double-width card fills row 0; a card takes the first free half-row (or
+ * opens a new row); a nested group (spans both columns) always opens a new
+ * row. Used to trim the outer box's bottom padding so the gap below a
+ * nested box matches the gap at its sides (an even-looking box).
+ */
+export function lastRowIsGroup(children: readonly TabGroupNode[]): boolean {
+ let openHalfRows = 0; // rows with exactly one free cell
+ let lastIsGroup = false; // the row-0 parent card is not a group row
+ for (const child of children) {
+ if (child.children.length > 0) {
+ lastIsGroup = true; // opens a new (last) row
+ } else if (openHalfRows > 0) {
+ openHalfRows -= 1; // back-fills an earlier half row; last row unchanged
+ } else {
+ openHalfRows += 1; // opens a new (last) row, half-filled
+ lastIsGroup = false;
+ }
+ }
+ return lastIsGroup;
+}
+
+/**
* Horizontal bleed (px) of a group box at nesting `level` (1 = outermost)
* when the deepest box is `maxDepth`. A box bleeds OUTWARD by this much (a
* negative margin + equal padding), so the cards inside every box sit on
diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts
index 49d70f2..645c2dd 100644
--- a/src/features/tabs/ui.test.ts
+++ b/src/features/tabs/ui.test.ts
@@ -382,8 +382,10 @@ describe("TabList", () => {
s?.paddingLeft,
s?.paddingLeft,
]);
- expect(box).toHaveClass("bg-black/50");
}
+ // Every box paints 20% black, so nested boxes compound darker per level.
+ expect(outer).toHaveClass("bg-black/20");
+ expect(inner).toHaveClass("bg-black/20");
expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${outer?.style.paddingLeft}`);
});
@@ -430,6 +432,36 @@ describe("TabList", () => {
expect(card("p")).toHaveClass("col-span-2");
});
+ it("trims the bottom padding under a nested box on the last row to match its side gaps", () => {
+ // p's children: c2 (card) then c1 (nested group) → c1's box is the last row.
+ const lastIsGroup: readonly Tab[] = [
+ { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" },
+ { conversationId: "c2", model: "m", title: "C2", workspaceId: "w", tabGroupParentId: "p" },
+ { conversationId: "c1", model: "m", title: "C1", workspaceId: "w", tabGroupParentId: "p" },
+ { conversationId: "g", model: "m", title: "G", workspaceId: "w", tabGroupParentId: "c1" },
+ ];
+ render(TabList, {
+ props: {
+ tabs: lastIsGroup,
+ activeConversationId: "p",
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ onNewDraft: vi.fn(),
+ },
+ });
+ const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]');
+ const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]');
+ const px = (v: string | undefined) => Number.parseFloat(v ?? "");
+ const outerSide = px(outer?.style.paddingLeft);
+ const innerBleed = px(inner?.style.paddingLeft);
+ // Side gap between the two box edges = outer bleed − inner bleed; the
+ // bottom gap must equal it.
+ expect(px(outer?.style.paddingBottom)).toBe(outerSide - innerBleed);
+ expect(px(outer?.style.paddingTop)).toBe(outerSide);
+ // The inner box ends in a card → untouched, even on all sides.
+ expect(px(inner?.style.paddingBottom)).toBe(innerBleed);
+ });
+
it("right-click opens the menu; 'Remove from group' ungroups the tab", async () => {
const onSetTabGroup = renderGrouped();
await fireEvent.contextMenu(card("c2"), { clientX: 10, clientY: 10 });
diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte
index 0bf3ad2..289cb6d 100644
--- a/src/features/tabs/ui/TabGroupBox.svelte
+++ b/src/features/tabs/ui/TabGroupBox.svelte
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { TabGroupNode } from "../tab-groups";
- import { groupBleedPx } from "../tab-groups";
+ import { groupBleedPx, lastRowIsGroup } from "../tab-groups";
import type { Tab } from "../tabs";
import TabGroupBox from "./TabGroupBox.svelte";
@@ -12,8 +12,9 @@
* outward (negative margin + equal padding, `groupBleedPx`) so the cards
* inside stay on the same two columns as top-level cards: every card keeps
* identical dimensions at every depth. The padding is EQUAL on all four
- * sides (= the bleed) so the box looks even. The 50%-black background
- * compounds, so each nesting level reads darker than the one around it.
+ * sides (= the bleed) so the box looks even — except below a nested box on
+ * the last row, where it is trimmed to match that box's side gaps. Every box paints 20% black,
+ * which compounds: each nesting level reads darker than the one around it.
*/
let {
node,
@@ -30,12 +31,18 @@
} = $props();
const bleed = $derived(groupBleedPx(level, maxDepth));
+ // Even spacing: when the last row is a nested box, the gap below it would
+ // be this box's full padding while its side gaps are only the DIFFERENCE
+ // in bleed (the nested box bleeds outward too) — trim the bottom to match.
+ const padBottom = $derived(
+ lastRowIsGroup(node.children) ? bleed - groupBleedPx(level + 1, maxDepth) : bleed,
+ );
</script>
<div
- class="col-span-2 rounded-lg bg-black/50"
+ class="col-span-2 rounded-lg bg-black/20"
style:margin-inline="-{bleed}px"
- style:padding="{bleed}px"
+ style:padding="{bleed}px {bleed}px {padBottom}px"
role="group"
aria-label={`Subagents of ${node.tab.title}`}
data-tab-group={node.tab.conversationId}