From eec65a1a733ec71a0f2f38ce32dfb193d773bb81 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:15:47 +0000 Subject: [PATCH] refactor(desktop): draw the issue peek in the window's own side panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Issues peek was a pane of its own that looked and moved unlike the side panel beside a task, an agent's page, and the Inbox. It now stands in the same frame: PanelFrame, taken out of SidePanel, draws the pane, its top row of tabs, the chosen tab's actions and the full-screen button, its resize edge, and its exits for both. The peek's one tab is the issue, whose × shuts the panel; its ⋯ is the issue's menu, Open as page among it. Whether it stands, how wide, and whether it fills the page are the window's side panel's own, as beside the Inbox, so the title bar's toggle and ⌥⌘B show and hide it once an issue has been peeked (issuesView.peeked). While the Issues pages stand the panel is no task's, its one tab drawn by the list (drawsOwn), so a plan behind them with no tab hides nothing. Escape steps out of full screen, then puts it away, and Open as page brings a full-screen peek back first. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../renderer/desktop/desktop-shell.test.ts | 22 ++ .../src/renderer/desktop/desktop-shell.tsx | 21 +- .../src/renderer/desktop/issue-detail.tsx | 16 +- .../src/renderer/desktop/issue-peek.tsx | 226 +++++++----------- .../src/renderer/desktop/issues-page.test.ts | 90 ++++++- .../src/renderer/desktop/issues-page.tsx | 67 +++--- .../src/renderer/desktop/side-panel.tsx | 212 +++++++++------- apps/desktop/src/renderer/styles/issues.css | 80 +------ .../src/renderer/tasks/use-side-panel.ts | 9 +- .../src/renderer/tasks/use-tasks-tab.test.ts | 12 + .../src/renderer/tasks/use-tasks-tab.ts | 58 +++-- apps/desktop/src/testing/pane-motion.ts | 6 + apps/desktop/src/testing/tasks-control.ts | 2 + packages/panel/src/desktop/pane-motion.tsx | 15 +- 14 files changed, 480 insertions(+), 356 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-shell.test.ts b/apps/desktop/src/renderer/desktop/desktop-shell.test.ts index 983311d1ce..d2606ca8c7 100644 --- a/apps/desktop/src/renderer/desktop/desktop-shell.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-shell.test.ts @@ -560,6 +560,28 @@ test("the Issues page's sidebar row and its command stand only while the account assert.equal(opens, 2); }); +test("the side panel's toggle stands over the Issues list once an issue has been peeked there, as over the Inbox once an item is chosen", async () => { + const control = tasksControl(); + const toggles = (container: HTMLElement) => + [...container.querySelectorAll("button")].some((button) => + ["Show panel", "Hide panel"].includes(button.getAttribute("aria-label") ?? ""), + ); + handState(experimentsOn(EXPERIMENT.ISSUES)); + const unpeeked = mount(false, false, { issuesView: { ...control.issuesView, open: true } }); + await settle(); + const before = toggles(unpeeked); + act(() => root?.unmount()); + document.body.innerHTML = ""; + + const peeked = mount(false, false, { + issuesView: { ...control.issuesView, open: true, peeked: "issue-1" }, + }); + await settle(); + + assert.equal(before, false, "nothing peeked, no panel to show"); + assert.equal(toggles(peeked), true); +}); + test("the Automations page's sidebar row and its command stand only while the account has its experiment on", async () => { handState({}); const off = mount(false); diff --git a/apps/desktop/src/renderer/desktop/desktop-shell.tsx b/apps/desktop/src/renderer/desktop/desktop-shell.tsx index 4d6856742b..84893769e6 100644 --- a/apps/desktop/src/renderer/desktop/desktop-shell.tsx +++ b/apps/desktop/src/renderer/desktop/desktop-shell.tsx @@ -35,7 +35,12 @@ import { ChipCards, type TaskFocus, WorkspaceDirectory } from "../tasks/chip-car import { MentionedAgents } from "../tasks/mentioned-agent-card"; import { NewTaskDialog } from "../tasks/new-task-dialog"; import { SIDE_PANEL_TAB } from "../tasks/use-side-panel"; -import { AUTOMATIONS_PAGE, type AutomationsPlace, type TasksControl } from "../tasks/use-tasks-tab"; +import { + AUTOMATIONS_PAGE, + type AutomationsPlace, + ISSUES_PAGE, + type TasksControl, +} from "../tasks/use-tasks-tab"; import { Tooltip } from "../tooltip"; import { DesktopAgentPage } from "./desktop-agent-page"; import { DesktopChat } from "./desktop-chat"; @@ -543,8 +548,8 @@ export function DesktopShell({ // Note that the shell says whether the panel's toggle stands, because the // toolbar beneath it leaves the toggle room then and only then. Factory, // the Automations page, and Chat stand in the plan's place, so they draw - // no panel to toggle; an agent's page draws the agent's own, and the Inbox - // the chosen item's task's. + // no panel to toggle; an agent's page draws the agent's own, the Inbox + // the chosen item's task's, and the Issues list the issue it peeked. const inboxOpen = tasks.inboxView.open; const pageAgent = tasks.agentView.agentId; const pagePerson = tasks.personView.userId; @@ -553,8 +558,16 @@ export function DesktopShell({ const chatOpen = tasks.chatView.open && chatOn; const issuesOpen = tasks.issuesView.open && issuesOn; const planToggle = tasks.page === TASKS_PAGE.DOCUMENT && !standingIn; + // The Issues list draws the panel beside it once an issue has been peeked there, as the Inbox does once an item is chosen. + const issuePeeked = + issuesOpen && + tasks.issuesView.place.kind === ISSUES_PAGE.LIST && + tasks.issuesView.peeked !== undefined; const panelToggle = - planToggle || pageAgent !== undefined || (inboxOpen && tasks.inboxView.chosen !== undefined); + planToggle || + pageAgent !== undefined || + (inboxOpen && tasks.inboxView.chosen !== undefined) || + issuePeeked; const taskDrawn = planToggle && tasks.region.kind === DOCUMENT_REGION.READY; // Note that a panel filling the window hides the task's column, call bar // and all, so the captions float in its place then. diff --git a/apps/desktop/src/renderer/desktop/issue-detail.tsx b/apps/desktop/src/renderer/desktop/issue-detail.tsx index 5f44a587bb..a010dfb291 100644 --- a/apps/desktop/src/renderer/desktop/issue-detail.tsx +++ b/apps/desktop/src/renderer/desktop/issue-detail.tsx @@ -7,6 +7,7 @@ import { usePerson } from "@luke/panel/member-directory"; import { useMinute } from "@luke/panel/tasks/task-time"; import { APP_COMMAND } from "@luke/settings/shortcuts"; import { + ArrowUpRightIcon, BREADCRUMBS_VARIANT, Breadcrumbs, ConfirmDialog, @@ -25,7 +26,7 @@ import { ExperimentTag } from "../experiment-marks"; import { DocumentByline, DocumentScroll } from "../tasks/document-frame"; import { RelativeTime } from "../tasks/relative-time"; import { Tooltip } from "../tooltip"; -import { ActionMenu } from "./action-menu"; +import { ActionMenu, type MenuAction } from "./action-menu"; import { archiveAnswered, archiveItem, @@ -86,8 +87,9 @@ export function IssueCrumbs({ } /** - * The issue's ⋯ menu, on its page's toolbar and on its peek's head: Start - * task… and Start agent…, Copy link, and Archive, which asks first and, once kept, hands back to + * The issue's ⋯ menu, on its page's toolbar and in its peek's top row: Open + * as page where it stands in the peek, Start task… and Start agent…, Copy + * link, and Archive, which asks first and, once kept, hands back to * `onArchived`, since the issue no longer stands where it was drawn. */ export function IssueMenu({ @@ -96,6 +98,7 @@ export function IssueMenu({ surfaceOpen = true, onChange, onArchived, + onOpenPage, }: { issue: Issue; identifier: string; @@ -104,8 +107,14 @@ export function IssueMenu({ /** Sends a change, answering whether it was kept. */ onChange: (change: IssueChange) => Promise; onArchived: () => void; + /** Opens the issue on its own page, from the peek; none on the page itself. */ + onOpenPage?: (() => void) | undefined; }): React.JSX.Element { const menu = useMenu(surfaceOpen); + const openPage: MenuAction | undefined = + onOpenPage === undefined + ? undefined + : { label: "Open as page", icon: , onSelect: onOpenPage }; const starts = useStartItems(issue, identifier); const copyLink = useCopyLinkItem()({ kind: LINK_PLACE.ISSUE, issueId: issue.id }); const archiving = useConfirmDialog({ subject: true, surfaceOpen }, () => @@ -125,6 +134,7 @@ export function IssueMenu({ label={ISSUE_ACTIONS_LABEL} menu={menu.drawn} groups={[ + ...(openPage === undefined ? [] : [[openPage]]), ...(starts.length === 0 ? [] : [starts]), ...(copyLink === undefined ? [] : [[copyLink]]), [archive], diff --git a/apps/desktop/src/renderer/desktop/issue-peek.tsx b/apps/desktop/src/renderer/desktop/issue-peek.tsx index 86ba9135ff..7646094ee3 100644 --- a/apps/desktop/src/renderer/desktop/issue-peek.tsx +++ b/apps/desktop/src/renderer/desktop/issue-peek.tsx @@ -1,33 +1,34 @@ import { issueIdentifier } from "@luke/features/issue-list-model"; import type { Issue } from "@luke/features/issue-wire"; import { useAppCommand } from "@luke/panel/app-commands"; -import { usePaneSlide } from "@luke/panel/desktop/pane-motion"; import type { IssueChange } from "@luke/panel/desktop/use-issues"; -import { EDGE_SIDE, useResizableEdge } from "@luke/panel/desktop/use-resizable-edge"; import { APP_COMMAND } from "@luke/settings/shortcuts"; -import { CloseIcon, ExpandIcon, IconButton } from "@luke/ui"; -import { Schema } from "effect"; import type React from "react"; import { useState } from "react"; import { DocumentScroll } from "../tasks/document-frame"; -import { Tooltip } from "../tooltip"; -import { readKept, writeKept } from "./folded-groups"; +import { SIDE_PANEL_WIDTH, type SidePanelControl } from "../tasks/use-side-panel"; import { IssueDocument, IssueMenu } from "./issue-detail"; +import { StatusGlyph } from "./issue-pickers"; +import { PANEL_LEAVING, PanelFrame, useSidePanelDrawing } from "./side-panel"; +import { Tab, TabStrip } from "./tab-strip"; /** * issue-peek.tsx -- one issue read beside the Issues board or list rather than in its place: the issue's own document under a head of its identifier, Open as page, its ⋯ menu, and Close. * * A press or Return on a card or row peeks its issue, and ⌘ held with - * either opens its page instead (`openingOf`). The peek is a pane of the - * Issues page's own, docked at its right as an automation's Runs are - * (`automation-runs-panel.tsx`), not a tab of the window's side panel, - * whose tabs are a task's: it slides in and out on the window's pane - * motion (`usePaneSlide`), the board or list narrowing beside it at once, - * and its left edge resizes it, its width kept on this Mac, a drag past - * its least width closing it. It draws the issue the list holds, so - * moving through the cards with the peek following reads nothing, and a - * change is sent through the list, which holds what was kept the moment - * it lands. Escape, or Close, puts it away. A window with no room for it + * either opens its page instead (`openingOf`). The peek is the window's + * side panel standing beside the Issues page, as it stands beside the + * Inbox for the chosen item's task: its frame, its top row, its + * full-screen button, its resize edge, and its motion are the panel's own + * (`PanelFrame`), and so are whether it is open, how wide it stands, and + * whether it fills the page, so the title bar's toggle and ⌥⌘B show and + * hide it as they do the panel anywhere. Its one tab is the issue, whose × + * closes the panel as closing a panel's last tab does, and the chosen + * tab's ⋯ is the issue's own menu, Open as page among it. It draws the + * issue the list holds, so moving through the cards with the peek + * following reads nothing, and a change is sent through the list, which + * holds what was kept the moment it lands. Escape steps out of full + * screen, then puts the peek away. A window with no room for the panel * beside a column of the board opens the issue's page instead * (`peekFits`), and a peek the window narrows past that room is put away. */ @@ -40,24 +41,14 @@ export const ISSUE_OPENING = { export type IssueOpening = (typeof ISSUE_OPENING)[keyof typeof ISSUE_OPENING]; -/** How wide the peek may stand, in CSS pixels, and where it starts: a document's narrow measure, as wide as the side panel's least at most. */ -const PEEK_WIDTH = { - MIN: 360, - MAX: 720, - DEFAULT: 440, -} as const; - -/** What the peek leaves the board or list beside it: a column of the board, a card's least width, and the page's insets either side. */ +/** + * What the peek leaves the board or list beside it: a column of the board, a + * card's least width, and the page's insets either side. It is the Issues + * page's `--document-reserve` (issues.css), which the panel's edge and its + * width read as the plan's. + */ const PEEK_RESERVE = 320; -/** Where the peek's width is kept in the renderer's storage. */ -const WIDTH_KEY = "luke.issuePeekWidth"; - -const storedWidth = Schema.fromJsonString(Schema.Finite); - -/** What the peek is called, as a region and as its head's words before the identifier. */ -const PEEK_LABEL = "Issue peek"; - /** How a press opens an issue: its page where ⌘ is held, its peek otherwise. */ export function openingOf(event: { readonly metaKey: boolean }): IssueOpening { return event.metaKey ? ISSUE_OPENING.PAGE : ISSUE_OPENING.PEEK; @@ -74,142 +65,97 @@ export function pageKey(event: React.KeyboardEvent): boolean { /** Whether the Issues page is wide enough for the peek at its least beside a column of the board. */ export function peekFits(page: HTMLElement): boolean { - return page.clientWidth >= PEEK_WIDTH.MIN + PEEK_RESERVE; -} - -/** A width held within the peek's bounds, in whole pixels. */ -function clampWidth(width: number): number { - return Math.min(PEEK_WIDTH.MAX, Math.max(PEEK_WIDTH.MIN, Math.round(width))); -} - -/** How wide the peek stands, and the drag that asks for another width. */ -export interface PeekWidth { - /** Always within {@link PEEK_WIDTH}. */ - width: number; - onResize: (width: number) => void; -} - -/** The peek's width, the default until first dragged, and kept as it is. */ -export function usePeekWidth(): PeekWidth { - const [width, setWidth] = useState(() => - clampWidth(readKept(WIDTH_KEY, storedWidth, PEEK_WIDTH.DEFAULT)), - ); - return { - width, - onResize: (asked) => { - const next = clampWidth(asked); - setWidth(next); - writeKept(WIDTH_KEY, storedWidth, next); - }, - }; -} - -/** The peek's head, the window's drag handle wherever no control stands: the issue's identifier, then Open as page, its ⋯ menu, and Close. */ -function PeekHead({ - issue, - identifier, - standing, - onChange, - onClose, - onOpenPage, -}: { - issue: Issue; - identifier: string; - /** Whether the peek still stands, rather than sliding out, which its menu and Archive's question do not outlive. */ - standing: boolean; - onChange: (change: IssueChange) => Promise; - onClose: () => void; - onOpenPage: () => void; -}): React.JSX.Element { - return ( -
-

{identifier}

-
- - } label="Open as page" onClick={onOpenPage} /> - - - - } label="Close" onClick={onClose} /> - -
-
- ); + return page.clientWidth >= SIDE_PANEL_WIDTH.MIN + PEEK_RESERVE; } /** - * The peek: the issue given, or nothing while none is peeked. It is drawn - * by whoever outlives it, so a peek put away is held drawn, its last issue - * still in it, through its slide out, and its edge still hears a drag that - * closed it coming back. + * The peek: the window's side panel beside the Issues page, drawing the issue + * given, while the panel is open and one is peeked. A panel shut is held + * drawn through its exit with the issue it last drew, and an issue put away + * with the panel open draws nothing. */ export function IssuePeek({ + panel, issue, workspaceName, - width, onChange, onClose, onOpenPage, }: { + /** The window's side panel: whether the peek stands, how wide, and whether it fills the page. */ + panel: SidePanelControl; issue: Issue | undefined; workspaceName: string; - width: PeekWidth; /** Sends a change to the issue peeked, answering whether it was kept. */ onChange: (change: IssueChange) => Promise; + /** Shuts the panel, handing the keyboard back to the issue's card. */ onClose: () => void; onOpenPage: (issue: Issue) => void; }): React.JSX.Element | null { - // Escape puts the peek away before it leaves the Issues page, being offered after the page's own. - useAppCommand(APP_COMMAND.EXIT_ISSUES, issue === undefined ? undefined : onClose); + const drawing = useSidePanelDrawing(panel); + const standing = drawing.panel !== undefined && issue !== undefined; + // Escape steps out of full screen first, then puts the peek away, before it leaves the Issues page. + useAppCommand( + APP_COMMAND.EXIT_ISSUES, + !standing || drawing.leaving !== PANEL_LEAVING.NONE + ? undefined + : panel.fullScreen + ? panel.onToggleFullScreen + : onClose, + ); const [last, setLast] = useState(issue); if (issue !== undefined && issue !== last) setLast(issue); - const edge = useResizableEdge({ - side: EDGE_SIDE.LEFT, - width: width.width, - bounds: PEEK_WIDTH, - reserve: () => PEEK_RESERVE, - label: "Resize issue peek", - onResize: width.onResize, - onToggleCollapsed: issue === undefined ? undefined : onClose, - }); - const slide = usePaneSlide(issue !== undefined); - const drawn = issue ?? last; - if (!slide.drawn || drawn === undefined) return null; + const leaving = drawing.leaving !== PANEL_LEAVING.NONE; + // Note that a panel shut is held drawn through its exit with the issue it last drew. + const drawn = issue ?? (leaving ? last : undefined); + if (drawing.panel === undefined || drawn === undefined) return null; const identifier = issueIdentifier(workspaceName, drawn.number); return ( -
{ - slide.pane.current = node; - }} - className="issue-peek-pane" - data-leaving={slide.leaving ? "" : undefined} - inert={slide.leaving} - style={{ width: width.width }} + + + } + label={identifier} + detail={drawn.title} + selected + quiet + onSelect={() => undefined} + onClose={onClose} + /> + +
+ } + actions={ + + {/* Keyed by the issue, so Archive's question asked of one is never answered for another. */} + onOpenPage(drawn)} + /> + + } > -
{/* Keyed by the issue, so a title or description being written in one is never sent as another's. */} - -
+ + ); } diff --git a/apps/desktop/src/renderer/desktop/issues-page.test.ts b/apps/desktop/src/renderer/desktop/issues-page.test.ts index 19e85de06f..fe004bb71e 100644 --- a/apps/desktop/src/renderer/desktop/issues-page.test.ts +++ b/apps/desktop/src/renderer/desktop/issues-page.test.ts @@ -36,6 +36,7 @@ import { playMotions } from "#testing/pane-motion"; import { landedBundles, onSideBundles, unlandedBundles } from "#testing/side-bundles"; import { tasksControl } from "#testing/tasks-control"; import { type SideBundles, sideBundles } from "../bundle-loader"; +import { type SidePanelControl, useSidePanel } from "../tasks/use-side-panel"; import { ISSUES_PAGE, type IssuesPlace, type NewTaskIssue } from "../tasks/use-tasks-tab"; import { IssuesPage } from "./issues-page"; @@ -316,6 +317,9 @@ afterEach(() => { for (const sheet of document.head.querySelectorAll("style[data-laid-out]")) sheet.remove(); }); +/** The window's side panel as the pages last drew it. */ +let panelNow: SidePanelControl | undefined; + /** The registry every render of one mount shares, so a render again is the same window. */ let registry = AtomRegistry.make(); @@ -334,6 +338,15 @@ function Pages({ start?: IssuesPlace | undefined; }): React.JSX.Element { const [place, setPlace] = useState(start); + const [peeked, setPeeked] = useState(undefined); + // The window's own side panel, which the peek stands in, beside no task. + const sidePanel = useSidePanel(undefined, { + id: undefined, + holds: {}, + bare: true, + drawsOwn: true, + }); + panelNow = sidePanel; const control = tasksControl({ workspace }); const tasks = tasksControl({ workspace, @@ -344,6 +357,7 @@ function Pages({ onSelect: (taskId) => tasksOpened.push(taskId), onOpenLink: (link) => linksOpened.push(link), repositories: { ...control.repositories, openGitHub: (url) => githubOpened.push(url) }, + sidePanel, issuesView: { ...control.issuesView, open: true, @@ -353,6 +367,8 @@ function Pages({ setPlace(to); }, onClose: () => (closed += 1), + peeked, + onPeek: setPeeked, }, }); return createElement( @@ -2403,7 +2419,7 @@ test("Group by Priority in the List's View options heads its issues by priority, /** What the window's Issues page measures across: room for the peek beside a column of the board, or too little. */ const ROOMY = 1280; -const CRAMPED = 600; +const CRAMPED = 500; /** The Issues page laid out `width` wide, as the window lays it; jsdom lays nothing out. */ function layPagesAt(width: number): void { @@ -2414,7 +2430,7 @@ function layPagesAt(width: number): void { /** The peek standing beside the board or list, if one does. */ function peekOf(page: HTMLElement): HTMLElement | null { - return page.querySelector(".issue-peek-pane:not([data-leaving]) .issue-peek"); + return page.querySelector(".side-panel:not([data-leaving]) .side-panel-content"); } /** What a peek reads: its issue's title, and the identifiers of the cards or rows marked as the one it reads. */ @@ -2503,7 +2519,7 @@ test("while a peek stands the arrows move through the cards and the peek follows assert.equal(closed, 1, "the second Escape leaves the Issues page"); }); -test("Close puts the peek away and hands the keyboard back to its card", async () => { +test("the tab's × puts the peek away and hands the keyboard back to its card", async () => { standing = [issue(1, { title: "Fix login" })]; const page = mount(); await settle(); @@ -2511,8 +2527,10 @@ test("Close puts the peek away and hands the keyboard back to its card", async ( act(() => cardOf(page, issue(1).id).click()); await settle(); - const close = peekOf(page)?.querySelector("button[aria-label='Close']"); - assert.ok(close, "the peek offers Close"); + const close = page.querySelector( + ".side-panel button[aria-label='Close ACME-1']", + ); + assert.ok(close, "the peek's tab offers its ×"); act(() => close.focus()); act(() => close.click()); await settle(); @@ -2539,7 +2557,8 @@ test("⌘-press and ⌘Return on a card, and Open as page from the peek, open th await settle(); act(() => cardOf(page, issue(1).id).click()); await settle(); - act(() => button("Open as page").click()); + act(() => button("Issue actions").click()); + act(() => menuItem("Open as page").click()); await settle(); assert.deepEqual(opened, [ @@ -2657,10 +2676,7 @@ test("Archive's question asked in a peek goes with the peek as it slides out, an await settle(); assert.ok(asked, "Archive asked first"); - assert.ok( - page.querySelector(".issue-peek-pane[data-leaving]"), - "the peek is still sliding out", - ); + assert.ok(page.querySelector(".side-panel[data-leaving]"), "the peek is still sliding out"); assert.ok( document.querySelector("[role=alertdialog], [role=dialog]") === null, "its question went with it", @@ -2681,7 +2697,7 @@ test("as the peek opens and closes, the board's columns glide from where they st vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function (this: Element) { const columns = [...document.querySelectorAll("[data-issue-column]")]; const at = columns.indexOf(this); - const narrowed = document.querySelector(".issue-peek-pane:not([data-leaving])") !== null; + const narrowed = document.querySelector(".side-panel:not([data-leaving])") !== null; const width = narrowed ? COLUMN.NARROW : COLUMN.WIDE; return new DOMRect(at < 0 ? 0 : at * width, 0, at < 0 ? 0 : width, 0); }); @@ -2955,3 +2971,55 @@ test("an issue's page counts a task's pull requests the list did not carry, in a act(() => button("Open Fix login for 2 more pull requests").click()); assert.deepEqual(tasksOpened, ["task-busy"]); }); + +test("the peek stands in the window's own side panel: its row's tab for the issue, its width, and its Expand panel, which fills the page until Escape brings it back, and Escape again puts it away", async () => { + standing = [issue(1, { title: "Fix login" }), issue(2)]; + const page = mount(); + await settle(); + layPagesAt(ROOMY); + + act(() => cardOf(page, issue(1).id).click()); + await settle(); + const pane = page.querySelector(".side-panel"); + const tab = pane?.querySelector(".side-panel-bar [role=tab][aria-selected='true']")?.textContent; + const width = pane?.style.width; + act(() => button("Expand panel").click()); + const filled = page.querySelector(".side-panel")?.getAttribute("data-full-screen"); + act(() => { + runAppCommand(APP_COMMAND.EXIT_ISSUES); + }); + await settle(); + const back = page.querySelector(".side-panel")?.getAttribute("data-full-screen"); + const stillPeeked = peeking(page).title; + act(() => { + runAppCommand(APP_COMMAND.EXIT_ISSUES); + }); + await settle(); + + assert.equal(tab, "ACME-1 Fix login"); + assert.equal(width, "400px", "the side panel's own width"); + assert.equal(filled, "true"); + assert.equal(back, "false", "Escape brings it back beside the board first"); + assert.equal(stillPeeked, "Fix login"); + assert.ok(peekOf(page) === null, "then puts it away"); + assert.equal(closed, 0); +}); + +test("Open as page from a peek filling the page brings the panel back from full screen, so no page after it draws one full screen", async () => { + standing = [issue(1, { title: "Fix login" })]; + const page = mount(); + await settle(); + layPagesAt(ROOMY); + + act(() => cardOf(page, issue(1).id).click()); + await settle(); + act(() => button("Expand panel").click()); + const filled = panelNow?.fullScreen; + act(() => button("Issue actions").click()); + act(() => menuItem("Open as page").click()); + await settle(); + + assert.equal(filled, true); + assert.equal(titleOf(page), "Fix login"); + assert.equal(panelNow?.fullScreen, false); +}); diff --git a/apps/desktop/src/renderer/desktop/issues-page.tsx b/apps/desktop/src/renderer/desktop/issues-page.tsx index f591a1a72e..aec91cea38 100644 --- a/apps/desktop/src/renderer/desktop/issues-page.tsx +++ b/apps/desktop/src/renderer/desktop/issues-page.tsx @@ -53,7 +53,7 @@ import { SquareKanbanIcon, UsersIcon, } from "lucide-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { LinkWorkspaceProvider } from "../copy-link-item"; import { ExperimentTag, SendFeedbackButton } from "../experiment-marks"; import { ISSUES_PAGE, type TasksControl } from "../tasks/use-tasks-tab"; @@ -65,7 +65,7 @@ import { IssueBoard, IssueBoardSkeleton, useBoardKeys } from "./issue-board"; import { IssueCrumbs, IssueDetail, IssueDetailSkeleton, IssueDocumentPage } from "./issue-detail"; import { type IssueFilterControl, useIssueFilter } from "./issue-filter-view"; import { IssueList, listRowIssue } from "./issue-list"; -import { ISSUE_OPENING, type IssueOpening, IssuePeek, peekFits, usePeekWidth } from "./issue-peek"; +import { ISSUE_OPENING, type IssueOpening, IssuePeek, peekFits } from "./issue-peek"; import { PRIORITY_ORDER, PriorityGlyph } from "./issue-pickers"; import { type IssueWork, IssueWorkProvider } from "./issue-work"; import { type IssueDraft, NewIssueDialog } from "./new-issue-dialog"; @@ -664,14 +664,9 @@ function IssuesPages({ tasks }: { tasks: TasksControl }): React.JSX.Element { pagesRef.current = node; setPages(node); }, []); - const [peeked, setPeeked] = useState(undefined); - // Note that a peek is of the workspace it was opened in, so another workspace listed puts it away for good. - const [peekedIn, setPeekedIn] = useState(workspace?.id); - if (peekedIn !== workspace?.id) { - setPeekedIn(workspace?.id); - setPeeked(undefined); - } - const peekWidth = usePeekWidth(); + // The peek is the window's side panel beside the page, drawing the issue last peeked while it is open. + const { sidePanel } = tasks; + const { peeked, onPeek } = view; const said = useChangeSaid(); const listedNow = useRef(workspace?.id); listedNow.current = workspace?.id; @@ -714,40 +709,58 @@ function IssuesPages({ tasks }: { tasks: TasksControl }): React.JSX.Element { place.kind === ISSUES_PAGE.LIST && peeked !== undefined ? listedIssues?.find((each) => each.id === peeked) : undefined; - // Note that an issue the page no longer draws, archived, filtered out, or of a workspace no longer listed, is peeked no more. - if (peeked !== undefined && listedIssues !== undefined && peekedIssue === undefined) { - setPeeked(undefined); - } + const peeking = sidePanel.open && peekedIssue !== undefined; + // Note that an issue the page no longer draws, archived, filtered out, or of a workspace no longer listed, is peeked no more, the panel showing it sliding out with it. + const gone = + peeked !== undefined && + listedIssues !== undefined && + !listedIssues.some((each) => each.id === peeked); + const { onSetOpen, open: panelOpen } = sidePanel; + // Note that a peek is of the workspace it was opened in, so another workspace listed puts it away for good, though its list held the issue too. + const listedIn = useRef(workspace?.id); + const moved = listedIn.current !== workspace?.id; + // Note that it is shut before the frame is painted, so the panel slides out rather than vanishing for a frame first. + useLayoutEffect(() => { + if (moved) listedIn.current = workspace?.id; + if (!gone && !(moved && peeked !== undefined)) return; + if (panelOpen) onSetOpen(false); + onPeek(undefined); + }, [gone, moved, peeked, workspace?.id, panelOpen, onSetOpen, onPeek]); + // Note that the issue's page draws no panel, so a peek filling the page is brought back beside it first, and leaves no panel full screen for the next page to draw. const openPage = (issue: Issue) => { - setPeeked(undefined); + if (sidePanel.fullScreen) sidePanel.onToggleFullScreen(); + onPeek(undefined); view.onOpen({ kind: ISSUES_PAGE.ISSUE, issueId: issue.id }); }; - // Note that a page with no room for the peek beside a column opens the issue's page, as before there was a peek. + // Note that a page with no room for the panel beside a column opens the issue's page, as before there was a peek. const open = (issue: Issue, opening: IssueOpening) => { - if (opening === ISSUE_OPENING.PAGE || pages === null || !peekFits(pages)) openPage(issue); - else setPeeked(issue.id); + if (opening === ISSUE_OPENING.PAGE || pages === null || !peekFits(pages)) { + openPage(issue); + return; + } + onPeek(issue.id); + onSetOpen(true); }; - /** Puts the peek away, handing its card or row the keyboard where the peek held it, or nothing did. */ + /** Puts the peek away, handing its card or row the keyboard where the panel held it, or nothing did. */ const closePeek = () => { const at = document.activeElement; const keyless = at === null || at === document.body || - (pages?.querySelector(".issue-peek-pane")?.contains(at) ?? false); + (pages?.querySelector(".side-panel")?.contains(at) ?? false); if (peeked !== undefined && pages !== null && keyless) pressOf(pages, peeked)?.focus(); - setPeeked(undefined); + onSetOpen(false); }; // While a peek stands it follows the keyboard: a card or row reached by an arrow, Tab, or a press is the one peeked. - const peeking = peekedIssue !== undefined; useEffect(() => { if (pages === null || !peeking) return; const follow = (event: FocusEvent) => { const issueId = event.target instanceof HTMLElement ? pressIssue(event.target) : undefined; - if (issueId !== undefined) setPeeked(issueId); + if (issueId !== undefined) onPeek(issueId); }; pages.addEventListener("focusin", follow); return () => pages.removeEventListener("focusin", follow); - }, [pages, peeking]); + }, [pages, peeking, onPeek]); // Note that a window narrowed past the peek's room beside a column puts the peek away, as one that never had it opens none. const closeNow = useRef(closePeek); closeNow.current = closePeek; @@ -760,7 +773,7 @@ function IssuesPages({ tasks }: { tasks: TasksControl }): React.JSX.Element { return () => observer.disconnect(); }, [pages, peeking]); return ( -
+
{place.kind === ISSUES_PAGE.LIST ? ( // The workspace's members, read for View options' Created by: their faces, and Me for the reader. @@ -813,9 +826,9 @@ function IssuesPages({ tasks }: { tasks: TasksControl }): React.JSX.Element { {/* The workspace's members, read for the peeked issue's creator: their picture, and You for the reader's own. */} peeked === undefined ? Promise.resolve(false) diff --git a/apps/desktop/src/renderer/desktop/side-panel.tsx b/apps/desktop/src/renderer/desktop/side-panel.tsx index 8140aec68b..00b2fdfa76 100644 --- a/apps/desktop/src/renderer/desktop/side-panel.tsx +++ b/apps/desktop/src/renderer/desktop/side-panel.tsx @@ -219,7 +219,7 @@ function documentReserve(area: HTMLElement): number { } /** How the drawn panel is on its way to the one asked for, if it is: shutting, or leaving full screen. */ -const PANEL_LEAVING = { +export const PANEL_LEAVING = { NONE: "none", CLOSE: "close", FULL_SCREEN: "full-screen", @@ -1769,8 +1769,6 @@ export function SidePanel({ /** The task's banner, where one stands (`task-banner.tsx`), drawn under the bar while the panel fills the window and covers the column that draws it otherwise. */ banner?: React.ReactNode; }): React.JSX.Element { - const aside = useRef(null); - const room = useRef(null); // The one slot the bar keeps for the chosen tab's ⋯, drawn there from the tab (`strip-tabs.tsx`). const [actionsSlot, setActionsSlot] = useState(null); const { tab } = panel; @@ -1873,11 +1871,136 @@ export function SidePanel({ // Something on offer past the fixed kinds, which an empty panel offers in place as a closed kind is. const offersMore = offers.some((entry) => entry.row.section !== undefined); + return ( + onOpenTarget({ kind: TOOL_TARGET.AGENT, agent })} + shownAgent={shownAgent} + /> + } + actions={} + > + {/* A panel with nothing to offer but kinds it has never closed stands + empty, the "+" above offering any; otherwise every tab closed, it + offers in place all that the "+" offers. */} + {tab === undefined ? ( +
+ {panel.closedTabs.length === 0 && !offersMore ? null : } +
+ ) : ( + // The Board is drawn on a canvas, which keeps a find of its own, and + // a page's words stand in a sandboxed frame no find of the window + // reaches, so a page's tab finds in its source alone, itself. + + + + )} +
+ ); +} + +/** + * The frame every side panel stands in, the task's, an agent's page's, the + * Inbox's, and the Issues page's peek: the panel's pane at the window's + * right, its width the window's, over its top row of `tabs`, the chosen + * tab's `actions`, and its full-screen button, with what it shows under the + * row and its resize edge at its left. Full screen, it covers what it stood + * beside rather than taking its place, and an empty room holds the panel's + * own width beside it, so what is beneath keeps its layout and is there to + * be uncovered as the panel goes back. It plays its own exits (`playExit`); + * its arrival is played by the pane motion around it (`pane-motion.tsx`). + */ +export function PanelFrame({ + panel, + leaving, + onLeft, + edge, + label, + tabs, + actions, + banner, + children, +}: Omit & { + panel: Pick; + /** What the pane is called to a reader. */ + label: string; + /** The row's tabs, which take what the row leaves beside the actions and the full-screen button. */ + tabs: React.ReactNode; + /** The chosen tab's own actions, its ⋯, before the full-screen button. */ + actions: React.ReactNode; + /** Drawn under the row while the panel fills the window and covers what draws it otherwise. */ + banner?: React.ReactNode; + children: React.ReactNode; +}): React.JSX.Element { + const aside = useRef(null); + const room = useRef(null); useLayoutEffect(() => { if (leaving === PANEL_LEAVING.NONE || aside.current === null) return; return playExit(aside.current, room.current, leaving, onLeft); }, [leaving, onLeft]); - return ( <> {/* Shut, it gives the document its room back at once, to glide into. */} @@ -1887,7 +2010,7 @@ export function SidePanel({
{panel.fullScreen ? banner : null} - {/* A panel with nothing to offer but kinds it has never closed stands - empty, the "+" above offering any; otherwise every tab closed, it - offers in place all that the "+" offers. */} - {tab === undefined ? ( -
- {panel.closedTabs.length === 0 && !offersMore ? null : } -
- ) : ( - // The Board is drawn on a canvas, which keeps a find of its own, and - // a page's words stand in a sandboxed frame no find of the window - // reaches, so a page's tab finds in its source alone, itself. - - - - )} + {children} {/* Last, so the drag region of the row above does not take the edge's top from it. Full screen has no edge to drag. */} {panel.fullScreen ? null : } diff --git a/apps/desktop/src/renderer/styles/issues.css b/apps/desktop/src/renderer/styles/issues.css index 1aaa083adb..c737e32f4c 100644 --- a/apps/desktop/src/renderer/styles/issues.css +++ b/apps/desktop/src/renderer/styles/issues.css @@ -7,11 +7,15 @@ status and priority marks, its properties' row, and its description's editor. Tokens only. */ -/* The pages, the peek, and the new issue's card share one root, which C - and the arrows are read against: the page beside the peek, which the - page gives its room to at once (`issue-peek.tsx`). Whatever slides past +/* The pages, the side panel beside the list, and the new issue's card share + one root, which C and the arrows are read against: the page beside the + window's own side panel (`side-panel.tsx`'s `PanelFrame`), which the page + gives its room to at once (`issue-peek.tsx`). The panel leaves the board a + column and its insets, as it leaves a plan's document its reserve, which + its edge and its width read from here (PEEK_RESERVE). Whatever slides past its edge is clipped, never scrolled (renderer AGENTS.md, Panel motion). */ .issues-pages { + --document-reserve: 320px; /* off-scale: a column of the board and its insets, PEEK_RESERVE */ position: relative; display: flex; min-width: 0; @@ -20,71 +24,11 @@ overflow: clip; } -/* The peek's pane, its width the developer's (`usePeekWidth`), its left - edge the side panel's own resize edge. A window narrower than the kept - width leaves the board a column and its insets, PEEK_RESERVE, and the - kept width comes back as the window grows. */ -.issue-peek-pane { - position: relative; - display: flex; - min-height: 0; - max-width: max(360px, calc(100% - 320px)); /* off-scale: PEEK_WIDTH.MIN, PEEK_RESERVE */ - flex: none; -} - -/* The peek on its way out has left the layout, the board already standing - in its room, and slides out over the board's edge on its own ground - (`usePaneSlide`). */ -.issue-peek-pane[data-leaving] { - position: absolute; - inset-block: 0; - right: 0; -} - -/* The peek: its head level with the page's toolbar, over the issue's own - document, which scrolls under the head. */ -.issue-peek { - display: flex; - min-width: 0; - flex: 1; - flex-direction: column; - border-left: 1px solid var(--luke-color-border); - background: var(--luke-color-ground); -} - -/* Level with the page's toolbar and, like it and the side panel's own top - row (`.side-panel-bar`), the window's drag handle wherever no control - stands. */ -.issue-peek-head { - display: flex; - flex: 0 0 auto; - align-items: center; - gap: var(--luke-control-gap); - min-height: var(--desktop-bar-height); - padding: var(--luke-space-8) var(--luke-space-8) var(--luke-space-8) var(--column-inset); - border-bottom: 1px solid var(--luke-color-border); - -webkit-app-region: drag; -} - -.issue-peek-identifier { - min-width: 0; - flex: 1; - margin: 0; - overflow: hidden; - color: var(--luke-color-text-muted); - font-size: var(--luke-type-body-size); - font-weight: var(--luke-weight-medium); - font-variant-numeric: tabular-nums; - text-overflow: ellipsis; - white-space: nowrap; -} - -.issue-peek-actions { - display: flex; - flex: none; - align-items: center; - gap: var(--luke-control-gap); - -webkit-app-region: no-drag; +/* With the panel beside it, the page's toolbar ends at the panel's edge + rather than the window's, and the panel's own row leaves the toggle its + room, as a plan's toolbar does beside its panel. */ +.desktop-shell .issues-pages[data-panel-open="true"] .desktop-toolbar { + padding-right: var(--column-inset); } /* The card read in the peek is marked as the sidebar marks the open plan. */ diff --git a/apps/desktop/src/renderer/tasks/use-side-panel.ts b/apps/desktop/src/renderer/tasks/use-side-panel.ts index 6595c388b6..b3e771daa9 100644 --- a/apps/desktop/src/renderer/tasks/use-side-panel.ts +++ b/apps/desktop/src/renderer/tasks/use-side-panel.ts @@ -603,6 +603,12 @@ export interface OpenTask { holds: TaskHolds; /** Whether the panel draws none of the task's fixed tabs, standing beside the window's Inbox rather than the task's page, which draws what they show. */ bare?: true | undefined; + /** + * Whether the page beside the panel draws the panel's one tab itself, as + * the Issues list draws the issue it peeked (`issue-peek.tsx`), so the + * panel stands while open though its strip holds no tab of its own. + */ + drawsOwn?: true | undefined; } /** What the task holds for the tabs: its id and its kinds, its agents whose tabs are open, or nothing while unread, the brain's workers its work holds, by the call that started each, and the files shown on it. */ @@ -1560,7 +1566,8 @@ export function useSidePanel( const strip = useMemo(() => stripOf(state, drawn), [state, drawn]); // Open is the window's, so a plan whose strip is empty hides the panel // without forgetting it was open, and the next plan with a tab shows it. - const shown = state.open && (strip.length > 0 || emptyShownOn === taskId); + const shown = + state.open && (task.drawsOwn === true || strip.length > 0 || emptyShownOn === taskId); // A panel shut, by its toggle or by its last tab closing, or hidden by a // plan with no tab, shows again beside the document and with no ask of // its "+" standing; one shut, or whose plan was left, is shown empty only diff --git a/apps/desktop/src/renderer/tasks/use-tasks-tab.test.ts b/apps/desktop/src/renderer/tasks/use-tasks-tab.test.ts index 5aca8232c7..ba62f0839c 100644 --- a/apps/desktop/src/renderer/tasks/use-tasks-tab.test.ts +++ b/apps/desktop/src/renderer/tasks/use-tasks-tab.test.ts @@ -821,6 +821,18 @@ test("Show panel beside the Inbox, its item's plan's strip holding no tab, shows assert.deepEqual(panelOf(tab), { open: true, tab: undefined }); }); +test("a peek opened beside the Issues list shows the panel, though the plan open behind it holds no tab", () => { + window.localStorage.clear(); + const tab = mount({ shown: true, tasks: { ...TWO_WORKSPACES, activeTaskId: "billing" } }); + act(() => tab.control().issuesView.onOpen()); + act(() => tab.control().issuesView.onPeek("issue-1")); + act(() => tab.control().sidePanel.onSetOpen(true)); + + assert.deepEqual(tab.control().sidePanel.strip, [], "no tab of the plan's"); + assert.equal(tab.control().sidePanel.open, true); + assert.equal(tab.control().issuesView.peeked, "issue-1"); +}); + test("a Luke link to another plan's section opens that plan and asks its page to show the section, each ask its own", () => { const tab = mount( { shown: true, tasks: OPEN }, diff --git a/apps/desktop/src/renderer/tasks/use-tasks-tab.ts b/apps/desktop/src/renderer/tasks/use-tasks-tab.ts index 85fa14637b..b870e06096 100644 --- a/apps/desktop/src/renderer/tasks/use-tasks-tab.ts +++ b/apps/desktop/src/renderer/tasks/use-tasks-tab.ts @@ -425,6 +425,9 @@ export type IssuesPlace = | { readonly kind: typeof ISSUES_PAGE.LIST } | { readonly kind: typeof ISSUES_PAGE.ISSUE; readonly issueId: string }; +/** The side panel beside the Issues pages: no task's, its one tab the issue the list peeked, which the list draws. */ +const ISSUES_PANEL = { id: undefined, holds: {}, bare: true, drawsOwn: true } as const; + /** The list, where the Issues pages open with none asked for. */ const ISSUES_LIST: IssuesPlace = { kind: ISSUES_PAGE.LIST }; @@ -436,6 +439,13 @@ interface IssuesViewControl { /** Opens the page asked for, the list where none is. */ readonly onOpen: (place?: IssuesPlace) => void; readonly onClose: () => void; + /** + * The issue the list last peeked, which the window's side panel draws + * beside it while the panel is open, as the Inbox's chosen item's task's + * panel stands beside the Inbox; none before one is peeked. + */ + readonly peeked: string | undefined; + readonly onPeek: (issueId: string | undefined) => void; } /** Everything the Tasks tab draws and presses, handed to the panel body whole. */ @@ -1049,6 +1059,8 @@ export function useTasksTab(input: { const [historyFor, setHistoryFor] = useState(undefined); /** What stands in the task's place while one does: the window's Inbox and the item chosen in it, or one agent's page. */ // The chat profile opens Chat at launch over its fixture. + // The issue the Issues list last peeked, held for the window's life so the panel's toggle shows it again. + const [issuePeek, setIssuePeek] = useState(undefined); const [standIn, setStandIn] = useState(() => input.run.fixtureMode && input.run.profile === RUN_PROFILE.CHAT ? { kind: STAND_IN.CHAT, place: FOR_YOU } @@ -1422,23 +1434,39 @@ export function useTasksTab(input: { () => (inboxAgent === undefined ? NO_IDS : [inboxAgent]), [inboxAgent], ); + // The Issues list's panel is its peek's, which the list draws itself, beside no task. + const issuesStand = standIn?.kind === STAND_IN.ISSUES; const sidePanel = useSidePanel( fixtureSidePanel(input.run), - inboxView !== undefined - ? { id: inboxTask ?? inboxAgent, holds: NO_HOLDS, bare: true } - : pageAgents === undefined - ? { id: viewed.activeTaskId, holds: taskHolds(viewed, transcript) } - : { id: pageAgent, holds: NO_HOLDS }, - inboxView !== undefined ? inboxAgents : (pageAgents ?? mainTabs.openAgents), - inboxView === undefined && pageAgents === undefined ? workers : NO_IDS, - inboxView === undefined && pageAgents === undefined ? files : NO_IDS, - inboxView !== undefined ? inboxPageIds : pageAgents === undefined ? pages : NO_IDS, + issuesStand + ? ISSUES_PANEL + : inboxView !== undefined + ? { id: inboxTask ?? inboxAgent, holds: NO_HOLDS, bare: true } + : pageAgents === undefined + ? { id: viewed.activeTaskId, holds: taskHolds(viewed, transcript) } + : { id: pageAgent, holds: NO_HOLDS }, + issuesStand + ? NO_IDS + : inboxView !== undefined + ? inboxAgents + : (pageAgents ?? mainTabs.openAgents), + !issuesStand && inboxView === undefined && pageAgents === undefined ? workers : NO_IDS, + !issuesStand && inboxView === undefined && pageAgents === undefined ? files : NO_IDS, + issuesStand + ? NO_IDS + : inboxView !== undefined + ? inboxPageIds + : pageAgents === undefined + ? pages + : NO_IDS, // Note that the task's agents in the Inbox are its own read's (`desktop-inbox.tsx`), which the panel draws a Pull request tab by. - inboxView !== undefined - ? inboxAgent === undefined - ? undefined - : inboxAgents - : (pageAgents ?? agents.agentIds), + issuesStand + ? NO_IDS + : inboxView !== undefined + ? inboxAgent === undefined + ? undefined + : inboxAgents + : (pageAgents ?? agents.agentIds), ); // An agent, or its Pull request, pinned to the sidebar is named there as // its tab is named now, so a pin made before the agent had its title or its @@ -2994,6 +3022,8 @@ export function useTasksTab(input: { setStandIn({ kind: STAND_IN.ISSUES, place }); }), onClose: guard(() => setStandIn(undefined)), + peeked: issuePeek, + onPeek: setIssuePeek, }, callTaskId: callInProgress(reported) ? reported.callTaskId : undefined, onShowFile: showFile, diff --git a/apps/desktop/src/testing/pane-motion.ts b/apps/desktop/src/testing/pane-motion.ts index 86ce83e9ff..ebed43972b 100644 --- a/apps/desktop/src/testing/pane-motion.ts +++ b/apps/desktop/src/testing/pane-motion.ts @@ -36,12 +36,18 @@ export function playMotions() { return animation; }, }); + // Note that a motion recorded is never running, so an exit that stops what runs first finds nothing to stop. + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); return { played, remove: () => { root.removeProperty("--duration-pane"); root.removeProperty("--motion-pane"); Reflect.deleteProperty(Element.prototype, "animate"); + Reflect.deleteProperty(Element.prototype, "getAnimations"); }, }; } diff --git a/apps/desktop/src/testing/tasks-control.ts b/apps/desktop/src/testing/tasks-control.ts index c4f2d754dc..fb2b7650a2 100644 --- a/apps/desktop/src/testing/tasks-control.ts +++ b/apps/desktop/src/testing/tasks-control.ts @@ -315,6 +315,8 @@ export function tasksControl(overrides: Partial = {}): TasksContro place: { kind: ISSUES_PAGE.LIST }, onOpen: ignore, onClose: ignore, + peeked: undefined, + onPeek: ignore, }, callTaskId: undefined, chatView: { diff --git a/packages/panel/src/desktop/pane-motion.tsx b/packages/panel/src/desktop/pane-motion.tsx index 6c2c72e868..3f3f43205a 100644 --- a/packages/panel/src/desktop/pane-motion.tsx +++ b/packages/panel/src/desktop/pane-motion.tsx @@ -12,11 +12,11 @@ * document is uncovered from its old edge to its new one. A pane leaving holds itself drawn * through its own exit (side-panel.tsx), so only arrivals and displacement * are played here. A smaller pane docked at the right of what it stands - * beside, a File tab's tree, a Terminal tab's shells, an automation's - * Runs, or an issue's peek, slides in and out on the same tokens - * (`usePaneSlide`), and an automation's form, or the Issues list, glides as - * the pane beside it comes and goes, as the document does beside the side - * panel. + * beside, a File tab's tree, a Terminal tab's shells, or an automation's + * Runs, slides in and out on the same tokens (`usePaneSlide`), and an + * automation's form glides as its Runs come and go, as the document, or the + * Issues list and board beside the side panel peeking an issue, do beside + * the side panel. * * Only a change to the panes plays anything. A drag between a pane's bounds * changes its width alone, so the edge stays under the pointer frame for @@ -53,7 +53,8 @@ const STILL = 0.5; /** * Which panes are drawn under the root, and how, each by a name of its * owner's: the window's sidebar, side panel, and the plan's pinned summary, - * an automation's Runs, or an issue's peek. A change to any of them is what plays. + * an automation's Runs, or the side panel peeking an issue beside the + * Issues list. A change to any of them is what plays. */ type PaneLayout = Readonly>; @@ -177,7 +178,7 @@ function play(root: HTMLElement, before: Snapshot): void { /** * Plays the pane changes over the box at `root`: the window's shell, an - * automation's page beside its Runs, or the Issues page beside its peek. Note that this is + * automation's page beside its Runs, or the Issues page beside the side panel peeking an issue. Note that this is * a class, because `getSnapshotBeforeUpdate` is the one moment React gives to * read the layout a commit is about to change, and the glide starts from * there; it draws nothing of its own.