Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5381097
feat(sidebar): expose section creation from menus and drag
tellaho Oct 27, 2026
d245c57
fix(sidebar): refine section creation menu and spacing
tellaho Oct 27, 2026
e48302b
fix(sidebar): give section creation a labelled form
tellaho Oct 27, 2026
3b7478b
fix(sidebar): remove the section name label
tellaho Oct 27, 2026
954f143
feat(sidebar): reveal new section drop zone above channels
tellaho Oct 27, 2026
6725e41
fix(sidebar): align section drop cue with image uploads
tellaho Oct 27, 2026
643890c
fix(sidebar): tighten the section drop cue label
tellaho Oct 27, 2026
b2d6e5c
fix(sidebar): clarify the new-section move dialog
tellaho Oct 27, 2026
4b3a0d4
fix(sidebar): reveal section creation above the first section
tellaho Oct 27, 2026
3c797bc
test(channels): await the members dialog focus handoff
tellaho Oct 27, 2026
60b1eaf
fix(sidebar): preserve solo wording and drag previews
tellaho Oct 27, 2026
83e14f7
test(sidebar): synchronize drag targets with sidebar layout
tellaho Oct 27, 2026
863fedb
fix(sidebar): retain submitted section names on retry
tellaho Oct 27, 2026
3f8f78b
fix(sidebar): reconcile uncertain section creations before dismissal
tellaho Oct 27, 2026
c007c44
fix(sidebar): reconcile uncertain creations with writer-backed reads
tellaho Oct 28, 2026
f5b4215
fix(sidebar): reconcile creation across concurrent preference writes
tellaho Oct 28, 2026
05a20e5
fix(sidebar): preserve creation focus and unlock unwritten drafts
tellaho Oct 28, 2026
8ed70f0
fix(sidebar): offer preference recovery after uncertain creation
tellaho Oct 28, 2026
7d6f2a5
test(sidebar): assert creation stays disabled during preference recovery
tellaho Oct 28, 2026
d70db7b
fix(sidebar): restore recovery focus after the render commits
tellaho Oct 28, 2026
cae8747
test(sidebar): close forwarded mutation sockets between saves
tellaho Oct 28, 2026
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
112 changes: 92 additions & 20 deletions browser-host/sidebar-create-section.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ it("creates and assigns together, imports legacy fields into registers and keeps
it.each([
{ ...intent, createSection: { id, name: " " } },
{ ...intent, createSection: { id: "__proto__", name: "Launch" } },
{ ...intent, createSection: { id, name: "Launch", position: "last" } },
{ ...intent, createSection: { id, name: "Launch", position: 0 } },
])("rejects malformed create intent before writing: %j", (value) => {
expect(() => assertSidebarAssignmentIntent(value)).toThrow("Invalid");
});
Expand Down Expand Up @@ -101,26 +103,35 @@ it("enforces section limits and refuses an invalid or unreadable current head",
),
).toThrow("Invalid");
});
it("failed confirmation may follow publication; retry confirms the same section without another write", async () => {
let head;
let reads = 0;
let publications = 0;
const read = async () => {
if (++reads === 2) throw new Error("confirmation offline");
return head ? [head] : [];
};
const publish = async (value) => {
publications++;
head = value;
};
await expect(
mutateSidebarAssignment(intent, secret, read, publish),
).rejects.toThrow("confirmation offline");
const groups = await mutateSidebarAssignment(intent, secret, read, publish);
expect(groups.sections).toEqual([{ id, name: "Launch", order: 0 }]);
expect(groups.assignments).toEqual({ alpha: id });
expect(publications).toBe(1);
});
it.each([intent, { createSection: intent.createSection }])(
"failed confirmation may follow publication; retry confirms the same section without another write: %j",
async (creation) => {
let head;
let reads = 0;
let publications = 0;
const read = async () => {
if (++reads === 2) throw new Error("confirmation offline");
return head ? [head] : [];
};
const publish = async (value) => {
publications++;
head = value;
};
await expect(
mutateSidebarAssignment(creation, secret, read, publish),
).rejects.toThrow("confirmation offline");
expect(decode(head).sections).toEqual([{ id, name: "Launch", order: 0 }]);
const groups = await mutateSidebarAssignment(
creation,
secret,
read,
publish,
);
expect(groups.sections).toEqual([{ id, name: "Launch", order: 0 }]);
expect(groups.assignments).toEqual(creation.channelId ? { alpha: id } : {});
expect(publications).toBe(1);
},
);
it("a failed initial read never seeds or publishes", async () => {
let publications = 0;
await expect(
Expand Down Expand Up @@ -187,6 +198,32 @@ it("decodes all encrypted preferences despite oversized deleted text without rew
expect(decode(head)).toEqual(blob);
});

it("creates at the top in one event, preserves existing orders and keeps retry idempotent", () => {
const first = {
...intent,
createSection: { ...intent.createSection, position: "first" },
};
const created = prepareSidebarAssignment(
[
event({
version: 1,
sections: [{ id: "work", name: "Work", order: -7 }],
assignments: {},
}),
],
first,
secret,
);
const saved = decode(created.event);
expect(saved.sections.map(({ id }) => id)).toEqual([id, "work"]);
expect(saved.assignments).toEqual({ alpha: id });
expect(saved.meta.s[id].order[2]).toBe(-8);
expect(saved.meta.s.work.order[2]).toBe(-7);
expect(
prepareSidebarAssignment([created.event], first, secret).event,
).toBeUndefined();
});

it("appends after the rounded order imported from a fractional legacy head", () => {
const created = prepareSidebarAssignment(
[
Expand Down Expand Up @@ -257,3 +294,38 @@ it.each([
expect(decode(head)).toEqual(blob);
},
);

it("creates an empty section without changing any assignments, and confirms the same ID on retry", async () => {
const create = { createSection: { id, name: " Launch " } };
let head = event({
version: 1,
sections: [{ id: "work", name: "Work", order: 0 }],
assignments: { alpha: "work" },
});
const publications = [];
const read = async () => [head];
const publish = async (record) => {
publications.push(record);
head = record;
};
const groups = await mutateSidebarAssignment(create, secret, read, publish);
expect(groups.sections).toEqual([
{ id: "work", name: "Work", order: 0 },
{ id, name: "Launch", order: 1 },
]);
expect(groups.assignments).toEqual({ alpha: "work" });
expect(decode(head).meta.a).not.toHaveProperty("undefined");
expect(await mutateSidebarAssignment(create, secret, read, publish)).toEqual(
groups,
);
expect(publications).toHaveLength(1);
});

it.each([
{},
{ sectionId: "work" },
{ createSection: { id, name: " " } },
{ channelId: null, createSection: { id, name: "Launch" } },
])("rejects invalid empty-section envelopes: %j", (intent) => {
expect(() => assertSidebarAssignmentIntent(intent)).toThrow("Invalid");
});
75 changes: 70 additions & 5 deletions browser-host/sidebar-preference-writes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { SIDEBAR_HEAD_BYTES } from "./sidebar-preferences.mjs";
import { relayBrokerPlugin } from "./relay-broker.mjs";
import { prepareSidebarStar } from "./sidebar-toggle.mjs";
import { connectBrokerTransport } from "../src/features/relay/transport.ts";
import { createRelaySession } from "../src/features/relay/session.ts";
import { fixtureRelayUrl, fixtureAliases } from "../tests/relay-config.ts";

const disposals = [];
Expand Down Expand Up @@ -84,11 +85,15 @@ async function harness(chunkBytes) {
}
if (queryFailure) return queryFailure;
// Model an empty stale replica: only strong reads observe the writer head.
const head =
body[0].consistency === "strong"
? heads.get(body[0]["#d"][0])
: undefined;
return Response.json(head ? [head] : []);
return Response.json(
body.flatMap((filter) => {
const head =
filter.consistency === "strong"
? heads.get(filter["#d"][0])
: undefined;
return head ? [head] : [];
}),
);
},
}).configureServer({
httpServer: server,
Expand Down Expand Up @@ -146,6 +151,66 @@ async function harness(chunkBytes) {
},
};
}
it.each([false, true])(
"uncertain creation reconciles through the writer despite replica lag (recovery initially fails: %s)",
async (fails) => {
const h = await harness();
const owner = createRelaySession({
...h.transport,
writeSidebarAssignment: async (intent, signal) => {
await h.transport.writeSidebarAssignment(intent, signal);
if (fails)
h.failQuery(
Response.json({ error: "writer unavailable" }, { status: 503 }),
);
throw new Error("response lost");
},
});
const preferences = owner.session.sidebarPreferences;
const section = {
id: "12345678-1234-1234-1234-123456789abc",
name: "Launch",
};
try {
await preferences.ensure();
await expect(preferences.createSection(section)).rejects.toThrow(
"response lost",
);
if (fails) {
expect(preferences.sectionCreationWritable).toBe(false);
await expect(
preferences.createSection({
...section,
id: "22345678-1234-1234-1234-123456789abc",
}),
).rejects.toThrow("unavailable");
h.failQuery(undefined);
await preferences.refresh();
}
expect(preferences.snapshot().data.sections).toEqual([
{ ...section, order: 0 },
]);
expect(preferences.sectionCreationWritable).toBe(true);
const batches = h.calls.filter(
({ body }) => Array.isArray(body) && body.length === 5,
);
expect(batches).toHaveLength(fails ? 3 : 2);
expect(
batches[0].body.every((filter) => filter.consistency === undefined),
).toBe(true);
for (const { body } of batches.slice(1))
expect(body.every((filter) => filter.consistency === "strong")).toBe(
true,
);
expect(h.calls.filter(({ url }) => url.endsWith("/events"))).toHaveLength(
1,
);
} finally {
owner.dispose();
}
},
);

it("real broker Star roundtrip signs scoped requests and confirms before projecting", async () => {
const h = await harness(),
signal = new AbortController().signal;
Expand Down
4 changes: 2 additions & 2 deletions browser-host/sidebar-preferences.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function validAssignmentIntent(intent) {
intent &&
typeof intent === "object" &&
!Array.isArray(intent) &&
typeof intent.channelId === "string" &&
(intent.channelId === undefined || typeof intent.channelId === "string") &&
(intent.sectionId === undefined || typeof intent.sectionId === "string") &&
(intent.createSection === undefined ||
(intent.createSection &&
Expand All @@ -86,7 +86,7 @@ function validAssignmentIntent(intent) {
typeof intent.createSection.id === "string" &&
typeof intent.createSection.name === "string" &&
Object.keys(intent.createSection).every((key) =>
["id", "name"].includes(key),
["id", "name", "position"].includes(key),
))) &&
validSidebarAssignment(intent) &&
Object.keys(intent).every((key) =>
Expand Down
27 changes: 25 additions & 2 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,28 @@ belong to this persistent sidebar. Rows in saved groups, **Starred** and **Chann
can also be dragged with a pointer onto another saved group, **Starred** or
**Channels**; the drop is the same Move intent, with the same gate, optimistic
placement, rollback notice and Retry.
The **Channels** ellipsis menu also offers **Create section**, which creates an
empty section without moving a channel. Its dialog retains the name and section
ID after an attempted write fails and waits for confirmation before closing.
The submitted name stays read-only and focused; a rejected gate that attempted
no write leaves the draft editable. Failed confirmation uses plain copy and
offers Retry preferences whenever preferences are unavailable, with Create
disabled until recovery. After a failed creation,
the session refreshes preferences from the writer before allowing dismissal so
an accepted save stays visible even after Cancel. If that read fails too, new
creation is gated across menu, drop and create-and-move paths, and Retry retains
writer routing until reconciliation succeeds. Recovery waits for all queued
preference writes and repeats a writer read invalidated by a concurrent write.
Ordinary startup remains replica-eligible. Dragging a channel into
the gap above the first custom section expands an **Add to new section** drop
target inside the sidebar. With no custom sections, the target is above
**Channels**; **Starred** keeps its pinned position. The created section appears
at that same first position. The target stays revealed for the rest of that drag, with
reduced motion opening it immediately; other section destinations remain available.
Dropping there opens the existing create-and-move dialog; cancelling leaves the
channel in place, and no preference is written until submission. These entry
points supplement the per-channel **Move channel → Create new…** route and use
the existing gated preference queue and encrypted section record.
Only a channel's select surface starts a drag, the surface its row menu wraps.
**Starred** accepts drops only when it is already visible; starting a drag does
not reveal an empty section. Use the row menu to star the first channel.
Expand Down Expand Up @@ -268,8 +290,9 @@ A register is `[version, device, value]`, with a nonnegative safe-integer millis
version and 16 lowercase hex device ID. Meta-less relay heads import at event
seconds × 1,000 with the zero device ID. Edits exceed every observed register
version and use a random in-memory device ID. Sections sort by canonical `(order,id)`
and project dense display orders; new sections append after the canonical live
maximum. Orphan assignments are omitted from the legacy projection, not deleted
and project dense display orders; menu-created sections append after the canonical
live maximum, while drop-created sections precede the live minimum. Orphan
assignments are omitted from the legacy projection, not deleted
from metadata. Read projection accepts Desktop string values on retained section
name/icon registers; only projected live text receives UI length limits. Already
satisfied intents return without rewriting the head, including assignment removal
Expand Down
2 changes: 1 addition & 1 deletion src/bundled/channels/ChannelMembersDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ it("DMs are view-only and never search outside their members", async () => {
});
it("Escape closes the dialog and restores focus to its header button", async () => {
const t = await setup();
expect(screen.getByRole("searchbox")).toHaveFocus();
await vi.waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus());
await t.user.keyboard("{Escape}");
await vi.waitFor(() =>
expect(screen.queryByRole("dialog")).not.toBeInTheDocument(),
Expand Down
50 changes: 50 additions & 0 deletions src/bundled/channels/Channels.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,48 @@
border-radius: var(--radius-row);
background: var(--affordance-panel-hover);
}
.createSectionSlot + .channelSection {
margin-top: 0;
}
.createSectionHitArea {
position: absolute;
inset: calc(-1 * var(--space-4)) 0 0;
pointer-events: none;
}
.createSectionReveal {
overflow: hidden;
}
.createSectionDrop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-1);
/* Match the avatar uploader's compact 80px drop area at the default scale. */
min-height: calc(5 * var(--space-4));
margin-bottom: var(--space-4);
padding: var(--space-2);
border: 2px solid transparent;
border-radius: var(--radius-control);
background: var(--surface-inset);
color: var(--text-subtle);
text-align: center;
transition: background-color var(--duration-state) var(--easing-state);
}
.createSectionDrop > svg {
flex-shrink: 0;
}
.createSectionDrop[data-drop-target] {
border-color: var(--text-standard);
border-style: dashed;
background: var(--affordance-subtle-hover);
color: var(--text-standard);
}
@media (prefers-reduced-motion: reduce) {
.createSectionDrop {
transition: none;
}
}
[data-channel-dragging],
.channelSection[data-section-dragging] {
opacity: 0.3;
Expand Down Expand Up @@ -334,11 +376,19 @@
line-height: var(--text-body-sm--line-height);
cursor: grabbing;
}
.channelDragOverlay[data-section-creation-preview] {
/* The new-section slot opens below the gap where the pointer first enters. */
translate: var(--space-3) calc(-100% - var(--space-3));
}
.channelDragOverlay > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The highlighted destination replaces the preview while it accepts the drop. */
body:has([data-create-section-drop][data-drop-target]) .channelDragOverlay {
visibility: hidden;
}
.sectionHeader {
position: relative;
}
Expand Down
11 changes: 0 additions & 11 deletions src/bundled/channels/CreateSidebarSection.module.css

This file was deleted.

Loading
Loading