Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion docs/plugin-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1078,7 +1078,9 @@ in the bottom drawer by default or the existing companion column when the contri
sets `channelPlacement: "side"`. The bottom drawer closes when the viewer leaves the
channel. A side-column panel stays open in the channel's workspace: leaving the channel
or page unmounts it, and returning shows it again until the viewer closes it, another
channel panel or Details replaces it, or the plugin is disabled. While the channel is read-only or cached it stays hidden. With `channelPlacement: "tab"` the launcher instead
channel panel or Details replaces it, or the plugin is disabled. While the channel is read-only or cached it stays hidden. For a side panel, `pressed` means the panel is shown in
the open pane, not merely selected: while the pane is hidden the panel stays selected but
reads unpressed, and `toggle` shows it again. With `channelPlacement: "tab"` the launcher instead
opens one tab per channel in the side pane, grouped with thread and other panel tabs;
a new target replaces that tab, and a link tab already open for the target becomes it.
The tab receives `PanelProps.context` too, so it can open or push further tabs. A launcher
Expand Down
10 changes: 7 additions & 3 deletions docs/shell-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,8 +461,10 @@ closing that profile or losing authorization also removes its log tab.
Right-click any channel tab and choose **Close all tabs** to close this channel's
thread, plugin and settings tabs across both panes, including hidden side-pane
tabs. The permanent channel conversation stays; focus returns there if its previous
target closed, while typing in surviving content remains uninterrupted. Other
channels and saved message drafts are untouched. **Close other tabs** in the
target closed, while typing in surviving content remains uninterrupted. A
launcher's side panel, such as Todos, is not a tab, so it stays as it was:
shown in an open pane, or hidden in a closed one. Other channels and saved
message drafts are untouched. **Close other tabs** in the
same menu does the same, except that the right-clicked tab stays open, selected
and focused. On the channel conversation it is the same as Close all tabs. On a
detail tab, such as an agent log, the tab that owns it stays instead, without
Expand All @@ -476,7 +478,9 @@ A thread that offers it also names its conversation above its original message,
as Inbox rows do (`#channel` or `DM · names`); that name is a link that does
the same.
Shift+F10 on a focused tab opens the same
menu. Hiding the pane remains separate from closing its tabs.
menu. Hiding the pane remains separate from closing its tabs. It also hides a
launcher's side panel without closing it: the launcher reads pressed only while
the pane shows the panel, and one press brings back the pane with it.
The main-header split control toggles existing pane content without closing its
tabs or resetting their contents. With no content it is disabled but remains
focusable, so closing the last tab has a stable focus-return target. There is no
Expand Down
14 changes: 12 additions & 2 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1143,6 +1143,9 @@ function ChannelWorkspace({
target: string;
id: string;
pane: "main" | "side";
/** The side pane's visibility to restore: a kept tab shows, and a
* launcher's side panel keeps the visibility it had before the close. */
paneOpen: boolean;
}>(undefined);
const dismissContent = (
visits: readonly string[],
Expand Down Expand Up @@ -1174,6 +1177,8 @@ function ChannelWorkspace({
target: targetKey(fallback),
id: keep,
pane,
paneOpen:
!!(keptTab || keptEntry || keep === "settings") || tabState.paneOpen,
};
}
navigator.dismiss(visits, fallback);
Expand Down Expand Up @@ -1234,11 +1239,16 @@ function ChannelWorkspace({
);
const keptTab = tabState.tabs.find((tab) => tab.id === keep);
const keptEntry = remaining[0];
// A launcher's side panel outlives the tabs, as when its last tab
// closes. The return keeps the pane as it was, shown or hidden, even
// when the channel visit it lands on had saved an open pane.
const pane = keptTab
? tabPane(keptTab)
: keptEntry
? entryPane(tabState.mainEntries, keptEntry)
: "main";
: tabState.sidePanel
? "side"
: "main";
const fallback = closeTarget(pane, remaining);
if (!navigator || !fallback) return;
const visits = [
Expand Down Expand Up @@ -1478,7 +1488,7 @@ function ChannelWorkspace({
if (kept.pane === "main") tabState.selectMain(kept.id);
else {
tabState.select(kept.id);
tabState.setPaneOpen(true);
tabState.setPaneOpen(kept.paneOpen);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P3, optional] Preserve a newer launcher opening during the pending return

If Todos is reopened after a hidden-pane Close all but before the native history.go() return lands, the launcher still accepts the click: the old history attempt remains live until popstate (browser-history.ts:364–370). Its open callback sets paneOpen=true without changing the empty selected id (useConversationTabs.tsx:436–440), so retained still matches and this assignment reapplies the captured false. The accepted opening is then hidden again.

This is an optional ordering improvement: the impact is another launcher press, with no lost work, and native traversal duration was not measured. Consider updating the pending visibility intent on a newer launcher/pane action so the latest choice survives enterVisit, with a deliberately delayed-return regression case. The ordinary prior-P2 sequence is fixed.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Leaving this optional one open; not addressed in this revision.

The window is between the dismissal and the native history.go() popstate, and the cost is one extra launcher press with nothing lost. Closing it properly means tracking pane actions during the pending return in both directions (a reopen and a hide), plus a delayed-return fixture to prove it. That is more machinery than this fix should carry without a measured traversal window. Happy to take it as a follow-up if it shows up in use.

}
focusKept.current = kept.id;
}
Expand Down
10 changes: 8 additions & 2 deletions src/bundled/channels/useChannelPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ type Opening = ChannelDrawerOpening & {
};
/** The page's tab workspace, which owns `channelPlacement: "tab"` panels. */
export type ChannelPanelTabs = {
/** Side panels show only while the pane is open; closing it retains them. */
paneOpen: boolean;
pressed(panel: RegisteredPanel, context: ChannelPanelContext): boolean;
toggle(
panel: RegisteredPanel,
Expand Down Expand Up @@ -103,6 +105,10 @@ export function useChannelPanels(
if (retained && !available.includes(retained.panel))
forgetRetained?.(undefined);
}, [local, selected, retained, forgetRetained, available]);
const shown =
selected?.panel.channelPlacement !== "side" || tabs?.paneOpen !== false
? selected
: undefined;
const activeOpening = useRef<Opening>(undefined);
useLayoutEffect(() => {
activeOpening.current = selected;
Expand Down Expand Up @@ -147,7 +153,7 @@ export function useChannelPanels(
pressed={
tabbed
? !!tabs?.pressed(panel, context)
: selected?.panel === panel
: shown?.panel === panel
}
openMembers={
openMembers
Expand All @@ -167,7 +173,7 @@ export function useChannelPanels(
toggle={(target) => {
if (!bound(context, panel)) return;
if (tabbed) tabs?.toggle(panel, context, target);
else if (selected?.panel === panel) hide(selected);
else if (shown?.panel === panel) hide(shown);
else {
onOpen?.();
setOpened({
Expand Down
3 changes: 2 additions & 1 deletion src/bundled/channels/useChannelTabState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -835,7 +835,8 @@ export function useChannelTabState(
tabs: tab ? [tab] : [],
selected: (side && keep) || "",
mainSelected: main ? keep : undefined,
paneOpen: side,
// A launcher's side panel is not a tab, so a pane showing one stays.
paneOpen: side || (s.paneOpen && !!s.sidePanel),
thread: undefined,
settings,
};
Expand Down
75 changes: 73 additions & 2 deletions src/bundled/channels/useConversationTabs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,10 @@ it.each(["member", "guest"] as const)(
},
);

function launcherHarness(mainPane = false) {
function launcherHarness(
mainPane = false,
placement: RegisteredPanel["channelPlacement"] = "tab",
) {
const channels = [{ id: "alpha", name: "Alpha" }];
const session = {
viewer: "viewer",
Expand All @@ -906,7 +909,7 @@ function launcherHarness(mainPane = false) {
pluginId: "fixture",
revision: "1",
title: "Work",
channelPlacement: "tab",
channelPlacement: placement,
matches: (target) => target.startsWith("work:"),
channelLauncher: (props) => {
launcher = props;
Expand Down Expand Up @@ -951,9 +954,77 @@ function launcherHarness(mainPane = false) {
pressed: () => launcher.pressed,
entries: () => state.entries,
workspace: () => workspace,
// As the page shows it: the side panel is visible only in the open pane.
sideShown: () => state.paneOpen && !!workspace.drawer.side,
tabs: () => state.tabs,
};
}

it.each([
["open", true],
["closed", false],
])(
"Close all tabs leaves a launcher's side panel as the %s pane showed it, since it isn't a tab",
(_, open) => {
const h = launcherHarness(false, "side");
act(() => {
h.workspace().openConversationThread("alpha", "root", "root");
});
h.toggle("alpha");
expect(h.tabs()).toHaveLength(1);
expect(h.sideShown()).toBe(true);
if (!open) act(() => h.workspace().closePane());
act(() => {
expect(h.workspace().closeAllTabs()).toBe(true);
});
expect(h.tabs()).toHaveLength(0);
expect(h.sideShown()).toBe(open);
expect(h.pressed()).toBe(open);
},
);

it("a side panel's launcher reads unpressed while the closed pane hides it, and one press shows it", () => {
const h = launcherHarness(false, "side");
act(() => {
h.workspace().openConversationThread("alpha", "root", "root");
});
h.toggle("alpha");
expect(h.pressed()).toBe(true);
act(() => h.workspace().closePane());
expect(h.sideShown()).toBe(false);
expect(h.pressed()).toBe(false);
h.toggle("alpha");
expect(h.sideShown()).toBe(true);
expect(h.pressed()).toBe(true);
// The pane returns with its tab, and the next press hides the panel.
expect(h.tabs()).toHaveLength(1);
h.toggle("alpha");
expect(h.workspace().drawer.side).toBeFalsy();
expect(h.pressed()).toBe(false);
});

it("Close other tabs on a main-pane tab keeps a launcher's side panel open beside it", () => {
setThreadPlacementPreference("main");
onTestFinished(() => setThreadPlacementPreference("side"));
const h = launcherHarness(true, "side");
h.toggle("alpha");
act(() => {
h.workspace().openConversationThread("alpha", "first", "first");
});
act(() => {
h.workspace().openConversationThread("alpha", "second", "second");
});
expect(h.workspace().selectedMain).toBe("thread:alpha:second");
expect(h.sideShown()).toBe(true);
act(() => {
expect(h.workspace().closeOtherTabs("thread:alpha:first")).toBe(true);
});
expect(h.tabs().map((tab) => tab.id)).toEqual(["thread:alpha:first"]);
expect(h.workspace().selectedMain).toBe("thread:alpha:first");
expect(h.sideShown()).toBe(true);
expect(h.pressed()).toBe(true);
});

it("a launcher takes over a tab a link already opened for its target", () => {
const h = launcherHarness();
act(() => {
Expand Down
1 change: 1 addition & 0 deletions src/bundled/channels/useConversationTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@ export function useConversationTabs({
tabState.setPaneOpen(true);
},
{
paneOpen: tabState.paneOpen,
pressed: (panel, context) => {
const entry = channelTab(panel, context.channelId);
return !!entry && tabState.paneOpen && selectedTab === tabId(entry);
Expand Down
Loading
Loading