Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
f8c177d
feat(chat): open the composer card's submenus on hover and focus
Sep 8, 2026
1c7df08
feat(chat): align a flyout's first row with the row that opened it
Sep 8, 2026
91be055
fix(chat): cancel a pending hover-open when the pointer leaves the card
Sep 9, 2026
1f83414
style(menus): make the row highlight an inset rounded slab
Sep 9, 2026
1c63a35
style(chat): a small switch size, a faint toggle label, a tighter chip
Sep 9, 2026
8b81d2e
copy(chat): call the terminal toggle "Source view"
gdenisov Sep 11, 2026
5202fd2
style(chat): sit the terminal in the pane with a 4px gutter
gdenisov Sep 11, 2026
d67c2f3
style(chat): an even band under the composer, and a real tooltip on t…
gdenisov Sep 11, 2026
4477572
fix(chat): take the hovers and presses behind an open card
gdenisov Sep 11, 2026
23d38f4
fix(chat): make the OpenRouter key address a link
gdenisov Sep 11, 2026
577ea26
style(chat): a 6px rail, 2px ticks, and no tick under the thumb
gdenisov Sep 11, 2026
42f5c9f
fix(chat): put the model row's tick at the row's end
gdenisov Sep 11, 2026
c5265f0
style(chat): drop the composer's shadow, and the clip that cut it
gdenisov Sep 11, 2026
2521f56
style(chat): a brighter top to the effort ramp, a terminal squared to…
gdenisov Sep 11, 2026
3c41119
style(chat): close the seam under the flyout, and unpad the under-bar
gdenisov Sep 11, 2026
fb331ea
style(chat): a small fast switch, and model names truncated from the …
gdenisov Sep 11, 2026
2d1c3e2
style(chat): account row controls flush right, labelled, and a star t…
gdenisov Sep 11, 2026
f5c6a65
fix(chat): keep the row lit under its controls, and let a lone star t…
gdenisov Sep 11, 2026
d2917bc
fix(chat): stand the tick in the same box its row's controls do
gdenisov Sep 11, 2026
a1d50b1
style(chat): tooltips above their trigger
gdenisov Sep 11, 2026
8e68b38
style(chat): one width for the card and its flyouts
gdenisov Sep 12, 2026
bc728ed
style(chat): let prettier have the tick's class string
gdenisov Sep 12, 2026
8914625
docs(chat): changelog for the hover-menu pass
gdenisov Sep 12, 2026
ab973e5
docs: changelog entries for the two projects this touches
gdenisov Sep 12, 2026
3a944b2
feat(workspace_ui): one Menu, one Dropdown, one placement module
gdenisov Sep 12, 2026
82803d3
refactor: every menu in the workspace is the shared Menu; the key pic…
gdenisov Sep 12, 2026
fe93ba0
docs: changelog entries for the menu system, in the three projects it…
gdenisov Sep 12, 2026
fd9818b
test(system_interface): find the launcher's filter menu on the page, …
gdenisov Sep 12, 2026
a232207
style: menus one step rounder, and a crispy pass over the branch's co…
gdenisov Sep 12, 2026
36db91e
docs(changelog): the overlay shadow's new values
gdenisov Sep 12, 2026
006d9f0
docs(changelog): the menu card's radius is 16px now, and the slab's 12px
gdenisov Sep 12, 2026
338b559
style(chat): the effort slider's dots take the colour of what they si…
gdenisov Sep 12, 2026
cdc881d
fix(menus): the review pass over the shared menu and its callers
gdenisov Sep 12, 2026
1055fe6
docs(changelog): the dots' 70%, where the note still said 75
gdenisov Sep 12, 2026
993d122
Merge origin/main into persimmon-herring
gdenisov Sep 15, 2026
f55b98e
test(system_interface): main's collapse tests find the menus on the page
gdenisov Sep 15, 2026
7a2965d
style(chat): a quieter chip and a rebalanced sign-in dialog
gdenisov Sep 16, 2026
0ac5764
style: a crispy pass over the branch's newer comments
gdenisov Sep 16, 2026
60f7e5c
Merge origin/main into persimmon-herring
gdenisov Sep 16, 2026
f1eebb2
test: main's new tooltip tests meet the shared menus
gdenisov Sep 16, 2026
5df803a
Merge remote-tracking branch 'origin/main' into persimmon-herring
gdenisov Sep 17, 2026
c941976
feat(chat): the fast-mode chooser is a submenu, and the model-options…
gdenisov Sep 18, 2026
c366b91
style(chat): the fast submenu's marks share one lane, and its stepper…
gdenisov Sep 18, 2026
c820f09
copy(chat): the fast-mode lines say the same thing in half the words
gdenisov Sep 18, 2026
2b6281d
style(chat): the turn-limit field's digits stop touching its steppers
gdenisov Sep 18, 2026
b4c67bd
copy(chat): the fast-mode lines drop their full stops
gdenisov Sep 18, 2026
7613b4d
style(chat): the default toggle keeps its colour on the mode that hol…
gdenisov Sep 18, 2026
64505d0
test(chat): one helper puts a chat on a fast-capable model
gdenisov Sep 19, 2026
116864f
test(chat): a half-typed turn limit is shown to hold the fast submenu…
gdenisov Sep 19, 2026
11e6cc2
docs(chat): fast mode's docstring names the submenu, not the deleted …
gdenisov Sep 19, 2026
13ee9d6
fix(chat): picking a mode drops the half-typed turn limit with the field
gdenisov Sep 19, 2026
9e71911
refactor(chat): a fast mode is named in one place
gdenisov Sep 19, 2026
d10695f
docs(chat): the README's fast row opens a submenu, not a modal
gdenisov Sep 19, 2026
df86626
style(chat): the turn-limit row takes the shared row's shape rather t…
gdenisov Sep 19, 2026
a26ce48
fix(chat): the fast submenu's rule is a separator
gdenisov Sep 19, 2026
15180b9
refactor(chat): the modes the chooser offers are read off the one tab…
gdenisov Sep 19, 2026
db69b53
refactor(chat): a mode's sentence is chosen by naming the mode, not b…
gdenisov Sep 19, 2026
5a68664
style(chat): the "next" pill is one recipe, in the menu's own style m…
gdenisov Sep 19, 2026
3ceb079
docs(chat): the fast-mode paragraph is re-filled after its edit
gdenisov Sep 19, 2026
049809c
docs(chat): the redraw test's paragraphs are re-filled after the rename
gdenisov Sep 19, 2026
e304c25
refactor(chat): the default row's sentence is written once
gdenisov Sep 19, 2026
7f53675
style(chat): a crispy pass over the fast-mode submenu's comments
gdenisov Sep 19, 2026
40a4335
style: a crispy pass over every comment the branch added
gdenisov Sep 19, 2026
162188e
feat(chat): a finished background task reads as a notice, not a user …
gdenisov Sep 19, 2026
550011a
Merge remote-tracking branch 'origin/main' into persimmon-herring
gdenisov Sep 21, 2026
def0c60
docs: the changelogs say what survived the desktop rewrite
gdenisov Sep 21, 2026
6887278
refactor(desktop): the desktop's menus are the workspace's one Menu
gdenisov Sep 22, 2026
1d72b10
refactor(chat): the rail's row menu is the workspace's one Menu
gdenisov Sep 22, 2026
a994829
Merge remote-tracking branch 'origin/main' into persimmon-herring
gdenisov Sep 22, 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
8 changes: 4 additions & 4 deletions system/apps/chat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,10 +225,10 @@ folder as `fast_mode.json`, `GET`/`PUT /api/chats/<chat-id>/fast-mode`):
at `data/.apps/chat/settings.json`; auto with a limit of 5 unless changed), and
a chat whose mode calls for it launches through the `fast` create template, a
handoff's successor included. The model picker's fast row states the chat's
mode and opens a small chooser where the mode, auto's turn limit and the
default for new chats are set; `/fast on` and `/fast off` typed in the
composer choose the mode too. The first time auto switches a chat in a
workspace, a one-time notice over the model bar explains it.
mode and opens a submenu where the mode, auto's turn limit and the default for
new chats are set; `/fast on` and `/fast off` typed in the composer choose the
mode too. The first time auto switches a chat in a workspace, a one-time notice
over the model bar explains it.

## Provider accounts

Expand Down
33 changes: 33 additions & 0 deletions system/apps/chat/changelog/persimmon-herring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
The composer's model card is the model/provider menu, and it is the workspace's shared Menu.

`ModelBar` becomes `ModelProviderMenu`, built on the shared Menu (`workspace_ui/components/menu`): the component keeps its rows -- the effort slider, the fast-mode row, the account list with its controls, the model list with its search -- and the shared component owns how the menu opens on a click of the chip, closes on a press outside or Escape and on nothing else, sits behind an invisible sheet so nothing behind it hovers or takes a click, and grows its Provider and Model submenus on hover (or keyboard focus) after a 40ms intent delay, behind a safe triangle, each submenu's first row level with the row that opened it and the box sliding up only as far as it must to stay on screen. The card/flyout vocabulary goes with the port: the styles module is `modelProviderMenuStyles`, the class names say submenu, and the test hooks are the shared menu's (`data-menu-part`, `data-menu-row`).

Picking a model closes the submenu and leaves the menu up, with the new model's effort and fast rows there to adjust. Picking the account the chat already runs on does the same. The menu and its submenus are one width (300px), and the seam where a submenu meets the row that opened it is closed: the submenu tucks 5px under the menu's edge, which is where the row's highlight ends.

The menu's controls are quieter: the composer's under-bar switch is the small size, and the effort slider is a 6px rail on the hover fill with 2px ticks of the text colour at 75%, placed by their centres on the thumb's own stops. The tick under the thumb is not drawn -- the ball is the mark for the level it is parked on -- and the ramp's dark end stops at 40% lightness rather than 30%, where the top of the scale read as switched off rather than turned up. Model names truncate from the FRONT, in the Model row and in the list: an `openrouter/qwen/qwen-2.5-72b-instruct` is a path whose head repeats down the whole list and whose tail is what tells one row from another.

Every tooltip in the chat sits above its trigger, because the chat's tooltips are raised by controls that live on the rows of a list, and a bubble below the pointer covers exactly the rows being chosen between. The model chip drops its native `title` for that bubble, and now says what it does ("Change model or provider") rather than naming the three things already written on it.

An account row's controls end where the row ends. The star, the rename pencil and the bin sit in the row's last three lanes, the tick stands down while the row is hovered so the group is flush rather than a slot short, and each carries a label ("Set as default", "Rename", "Delete"). The row stays lit while the pointer is on one of them -- they are siblings of the row button, so the highlight comes from the wrapper that contains all four. The default star is filled in CSS rather than by the icon helper's `filled`, which drops the stroke and so draws a glyph a stroke-width smaller than the outlines beside it; it sits beside the tick when the row has one and takes the tick's own lane when it does not. Both ticks -- the account rows' and the model list's -- stand in the same 20x20 box the control buttons do, so every glyph in these lists shares one centre line.

The provider chooser's key picker is the shared Dropdown (`workspace_ui/components/dropdown`): a pick and nothing else, behind its own sheet, painting over the modal it opens from. The chooser's private trigger, backdrop and option styles go with it.

The chat's other face is called "Source view", and it sits in the pane rather than filling it: 4px of surface on three sides and an 8px corner (what the pane's own 12px corner leaves once you step 4px inward), with the bottom edge running to the card's own, which is where the composer's bottom edge is on the front face. The composer keeps its border and has no shadow in either state, and the faces no longer clip: `overflow: hidden` on a face only ever caught what a child cast past its edge, which is what was trimming that shadow. The row under the composer sits in an even 8px band instead of 4px above and a page-sized 24px below, and squares up with the composer's own edges.

The OpenRouter sign-in panel's key address is a link. It was prose in the lane's description ("From openrouter.ai/keys."), which leaves the reader retyping it; the URL moves into `signup_url`, the field the lane model already has for where to get a key, and the panel renders it as "Get one at openrouter.ai/keys." the way the Opencode Go lane does.

The effort slider's level dots take the colour of what they are drawn on: the surface colour where the fill has passed them, the text colour on the bare track ahead of the thumb, both at 70% rather than the 75% the one colour wore. One colour throughout left the dots below the thumb sinking into the green.

The card no longer throws work away when the pointer drifts off it: a rename mid-type, an armed "Remove?" and a typed model search each hold their submenu open (the shared menu's `holdsSubmenuOpen`), so while one is live only a click or Escape takes the submenu down. And the model list keeps the height it filled while it is being filtered: the matches shrink inside the box, rather than the box chasing them down the screen with every keystroke.

A polish pass over the chip and the sign-in dialog. The chip's fast bolt stands on the row's gap alone, without a second dot after the effort. The dialog pulls its padding in to 16px sides with 12px top and bottom; the verifying and signed-in verdicts read at label strength over a smaller disc, since the header already names the screen; the one-time device code sits in a trimmer, label-weight box that stays level with its copy button; and copying the sign-in link moves the step highlight on to the code, the same as opening the link does.

The fast-mode chooser is a submenu, not a modal. Picking a speed is picking one of three, which is what this menu already does twice over, and a modal over the menu you opened it from covered the mode it was changing. The submenu carries everything the modal did -- the three modes with the sentence that says what each one does, the ticked current one, auto's editable turn limit, and a row that makes the chat's mode what new chats start in -- and it STAYS UP on a pick, because picking auto is usually followed by setting the limit it runs to. A half-typed limit holds it open against a drifting pointer, the same way a half-typed rename holds the provider submenu. These are the only two-line rows in the menu: three modes is a short list, and what separates them is not their names but what each one does.

Three details of that submenu. The mode rows carry no width of their own, so their trailing tick shares a lane with every other row's -- the shared slab already sets one, and a `w-full` beside it takes the row 8px past the slab's own margins. The tick sits at the row's vertical centre rather than on its first line. And the default is a small switch rather than a tick: it travels one way, because exactly one mode is what new chats start in and turning this one off would leave the question unanswered, so on it states the setting and is inert, and off it is how the setting is moved here.

The turn-limit field keeps its steppers out. Chromium's user-agent sheet fades `::-webkit-inner-spin-button` to nothing until the field is hovered or focused, which leaves a number field looking like a text field: the one affordance saying "this is a number you can nudge" only appears once you have gone looking for it.

One row still opens a chooser of its own rather than a submenu -- the Model row while a switch is armed, which reopens the switch dialog. The shared menu has no kind for that shape, so it is drawn here from the menu's own row recipe (`menuRowClass`): the same slab, the same label a step back from its value, the same chevron. Pressing it closes the menu, because what opens is a modal over it. The armed Provider row's qualifier is "(Codex, next message)": the fuller sentence was written for a 340px card and, in a 300px menu, wrapped the row onto two lines and ellipsised the provider beside it.

The chat rail's row menu is the shared Menu too -- the last one in the workspace still drawing its own card. It keeps its three rows (rename, stop or restart, delete) and loses what the component already owns: a hand-rolled nudge back onto the screen, a document `click` listener and a document `keydown` listener, and the `stopPropagation` guards that existed to keep those two from firing on the menu itself. Dismissal is a press on the menu's own sheet or Escape, and a row is found by `data-menu-row` rather than `data-menu-item`.
4 changes: 2 additions & 2 deletions system/apps/chat/frontend/src/lightbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ export function openImageLightbox(imageUrl: string, altText: string): void {
closeButton.addEventListener("click", closeImageLightbox);

// Native `title` is not used anywhere in the workspace -- see workspace_ui's hoverTooltip.ts.
setHoverTooltip(downloadLink, "Download");
setHoverTooltip(closeButton, "Close");
setHoverTooltip(downloadLink, "Download", "above");
setHoverTooltip(closeButton, "Close", "above");

actions.appendChild(downloadLink);
actions.appendChild(closeButton);
Expand Down
10 changes: 10 additions & 0 deletions system/apps/chat/frontend/src/models/FastMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,13 @@ describe("fastModeLabel", () => {
expect(fastMode.fastModeLabel({ mode: "auto", is_switched: true })).toBe("Auto (off now)");
});
});

describe("fastModeDetail", () => {
it("explains each mode, auto with the limit it runs to", async () => {
const fastMode = await freshModule();
expect(fastMode.fastModeDetail("off", 5)).toBe("Standard speed always");
expect(fastMode.fastModeDetail("on", 5)).toBe("Fast mode always");
expect(fastMode.fastModeDetail("auto", 1)).toBe("Fast for the first 1 turn, then standard");
expect(fastMode.fastModeDetail("auto", 4)).toBe("Fast for the first 4 turns, then standard");
});
});
29 changes: 26 additions & 3 deletions system/apps/chat/frontend/src/models/FastMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,34 @@ export async function updateFastModeState(chatId: string, next: ChatFastModeStat
}
}

/** What each mode is called, everywhere one is named. */
export const FAST_MODE_LABELS: Readonly<Record<FastModeMode, string>> = {
off: "Off",
auto: "Auto",
on: "On",
};

/** The modes, in the order the chooser offers them, read off the table above. */
export const FAST_MODES: readonly FastModeMode[] = Object.keys(FAST_MODE_LABELS) as FastModeMode[];

/** What the model picker's fast row says for a state. */
export function fastModeLabel(state: ChatFastModeState): string {
if (state.mode === "off") return "Off";
if (state.mode === "on") return "On";
return state.is_switched ? "Auto (off now)" : "Auto";
const label = FAST_MODE_LABELS[state.mode];
return state.mode === "auto" && state.is_switched ? `${label} (off now)` : label;
}

/** The line under a mode in the chooser; auto's names the limit it runs to. */
export function fastModeDetail(mode: FastModeMode, turnLimit: number): string {
switch (mode) {
case "off":
return "Standard speed always";
case "on":
return "Fast mode always";
case "auto": {
const turns = turnLimit === 1 ? "1 turn" : `${turnLimit} turns`;
return `Fast for the first ${turns}, then standard`;
}
}
}

/** Forget every chat's state, so a test starts clean. */
Expand Down
2 changes: 1 addition & 1 deletion system/apps/chat/frontend/src/models/Response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ export interface UserMessageEvent extends BaseTranscriptEvent {
// harness's parser off the shared detector table): how this message renders.
// Absent = the baseline user bubble. The raw harness markers (claude's isMeta /
// sentinel tags) never reach the wire -- the decision does.
display?: "hidden" | "chip" | "skill_expansion" | "permission_resolution" | "status";
display?: "hidden" | "chip" | "skill_expansion" | "permission_resolution" | "status" | "notice";
// Chip title ("Stop hook feedback", "Background task", ...) or skill name.

display_label?: string;
Expand Down
130 changes: 38 additions & 92 deletions system/apps/chat/frontend/src/root/ChatRail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@
import m from "mithril";
import { icon } from "@imbue/workspace-ui/src/components/icons";
import { hoverTooltipAttrs } from "@imbue/workspace-ui/src/components/hoverTooltip";
import { menuCardClass, menuDividerClass, menuRowClass } from "@imbue/workspace-ui/src/components/menu";
import { createMenu, type MenuRow } from "@imbue/workspace-ui/src/components/menu";
import { anchorForPoint } from "@imbue/workspace-ui/src/menu-position";
import { isUnread } from "./chatUnread";
import { destroyChat, renameChat, startChat, stopChat } from "./verbs";
import { isAgentStarted } from "./rows";
Expand Down Expand Up @@ -85,13 +86,20 @@ interface RenameState {
let rename: RenameState | null = null;
const deletingChatIds = new Set<string>();

interface MenuState {
chatId: string;
x: number;
y: number;
}
// The row whose menu is up, or null. Which row it belongs to is this file's; opening, placing,
// dismissing and closing are the component's, and its `onClose` keeps the two in step.
let menuChatId: string | null = null;

let menu: MenuState | null = null;
const railMenu = createMenu({
placement: "below",
role: "menu",
// What the old hand-built card set as `min-w-36`.
minWidth: 144,
extraClass: "chat-rail-menu",
onClose: () => {
menuChatId = null;
},
});

function beginRename(row: ChatRow): void {
rename = { chatId: row.chatId, draft: row.title, error: null };
Expand Down Expand Up @@ -122,8 +130,6 @@ function pruneDeleting(rows: readonly ChatRow[]): void {
}

function setRunningFromMenu(row: ChatRow, isRunning: boolean): void {
menu = null;
m.redraw();
const verb = isRunning ? startChat : stopChat;
verb(row.chatId).catch((error: unknown) => {
alert(
Expand All @@ -135,8 +141,6 @@ function setRunningFromMenu(row: ChatRow, isRunning: boolean): void {
/** Delete from the menu, after asking. When it is the chat the root shows, the root moves to
* the next one in the list first, so it is not left on a page whose chat is gone. */
function deleteFromMenu(attrs: ChatRailAttrs, row: ChatRow): void {
menu = null;
m.redraw();
const isConfirmed = window.confirm(
`Delete "${row.title}"?\n\nThis ends its agent and removes its conversation. It cannot be undone.`,
);
Expand All @@ -154,80 +158,26 @@ function deleteFromMenu(attrs: ChatRailAttrs, row: ChatRow): void {
});
}

function keepMenuOnScreen(dom: Element): void {
const element = dom as HTMLElement;
const rect = element.getBoundingClientRect();
const overflowX = rect.right - window.innerWidth;
const overflowY = rect.bottom - window.innerHeight;
if (overflowX > 0) element.style.left = `${Math.max(0, rect.left - overflowX)}px`;
if (overflowY > 0) element.style.top = `${Math.max(0, rect.top - overflowY)}px`;
}

function rowMenu(attrs: ChatRailAttrs, row: ChatRow, state: MenuState): m.Vnode {
/** The rows a chat's context menu offers. */
function rowMenuRows(attrs: ChatRailAttrs, row: ChatRow): MenuRow[] {
const isStopped = row.status === "stopped";
const rowClass = menuRowClass({ extra: "text-(length:--font-size-row) text-primary" });
return m(
"div",
return [
{ kind: "action", key: "rename", label: "Rename", onSelect: () => beginRename(row) },
{
class: `chat-rail-menu ${menuCardClass("fixed min-w-36")}`,
style: `left: ${state.x}px; top: ${state.y}px`,
role: "menu",
oncreate: ({ dom }: m.VnodeDOM) => keepMenuOnScreen(dom),
onclick: (event: MouseEvent) => event.stopPropagation(),
oncontextmenu: (event: MouseEvent) => event.preventDefault(),
kind: "action",
key: isStopped ? "start" : "stop",
label: isStopped ? "Restart chat" : "Stop chat",
onSelect: () => setRunningFromMenu(row, isStopped),
},
[
m(
"button",
{
type: "button",
class: rowClass,
role: "menuitem",
"data-menu-item": "rename",
onclick: () => {
menu = null;
beginRename(row);
},
},
"Rename",
),
m(
"button",
{
type: "button",
class: rowClass,
role: "menuitem",
"data-menu-item": isStopped ? "start" : "stop",
onclick: () => setRunningFromMenu(row, isStopped),
},
isStopped ? "Restart chat" : "Stop chat",
),
m("div", { class: menuDividerClass() }),
m(
"button",
{
type: "button",
class: menuRowClass({ extra: "text-(length:--font-size-row) text-danger" }),
role: "menuitem",
"data-menu-item": "delete",
onclick: () => deleteFromMenu(attrs, row),
},
"Delete chat",
),
],
);
}

function closeMenuOnClickAway(): void {
if (menu === null) return;
menu = null;
m.redraw();
}

function closeMenuOnEscape(event: KeyboardEvent): void {
if (event.key !== "Escape" || menu === null) return;
menu = null;
m.redraw();
{ kind: "divider" },
{
kind: "action",
key: "delete",
label: "Delete chat",
tone: "danger",
onSelect: () => deleteFromMenu(attrs, row),
},
];
}

// ---------- marks ----------
Expand Down Expand Up @@ -287,19 +237,14 @@ function statusDot(row: ChatRow, extraClass: string): m.Vnode {
// ---------- the component ----------

export const ChatRail: m.Component<ChatRailAttrs> = {
oncreate() {
document.addEventListener("click", closeMenuOnClickAway);
document.addEventListener("keydown", closeMenuOnEscape);
},
onremove() {
document.removeEventListener("click", closeMenuOnClickAway);
document.removeEventListener("keydown", closeMenuOnEscape);
menu = null;
// A menu still open when the rail unmounts would keep its Escape listener on the window.
railMenu.dispose();
},
view({ attrs }) {
const collapsed = isCollapsed(attrs.isCompact);
pruneDeleting(attrs.rows);
const menuRow = menu === null ? undefined : attrs.rows.find((row) => row.chatId === menu?.chatId);
const menuRow = menuChatId === null ? undefined : attrs.rows.find((row) => row.chatId === menuChatId);
return m(
"nav",
{
Expand Down Expand Up @@ -357,7 +302,7 @@ export const ChatRail: m.Component<ChatRailAttrs> = {
{ class: "chat-rail-list min-h-0 flex-1 overflow-y-auto px-2 pb-2" },
attrs.rows.map((row) => railRow(attrs, row, collapsed)),
),
menu !== null && menuRow !== undefined ? rowMenu(attrs, menuRow, menu) : null,
menuRow === undefined ? null : railMenu.view(rowMenuRows(attrs, menuRow)),
],
);
},
Expand Down Expand Up @@ -397,7 +342,8 @@ function railRow(attrs: ChatRailAttrs, row: ChatRow, collapsed: boolean): m.Vnod
oncontextmenu: (event: MouseEvent) => {
event.preventDefault();
if (isDeleting || row.isProvisional) return;
menu = { chatId: row.chatId, x: event.clientX, y: event.clientY };
menuChatId = row.chatId;
railMenu.open(anchorForPoint(event.clientX, event.clientY));
},
},
collapsed
Expand Down
Loading
Loading