diff --git a/apps/desktop/src/renderer/searchable-menu.tsx b/apps/desktop/src/renderer/searchable-menu.tsx index 0745e58fcd..715ee6d6fc 100644 --- a/apps/desktop/src/renderer/searchable-menu.tsx +++ b/apps/desktop/src/renderer/searchable-menu.tsx @@ -54,7 +54,10 @@ import { Tooltip } from "./tooltip"; * field is its `SearchField`, the lists its `ComboboxList`, and the pinned * rows its `MenuItem`s, steered from the field. A * list too short to search is drawn with no field, the list itself - * holding focus, naming the highlight, and reading the same keys. The owner says + * holding focus, naming the highlight, and reading the same keys. An owner + * that answers a search itself, such as one a service answers as the typing + * rests, hears each query as it is typed and hands the rows that answer it, + * which the menu lists as they come rather than filtering them. The owner says * what to list, what to say in the list's place while there is nothing to * list, and what a pick does; the menu decides nothing about either. * Its owner holds it drawn through its exit (`useDisclosure`), handing it @@ -304,6 +307,8 @@ export function SearchableMenu(props: { note?: React.ReactNode; /** Said when a query matches nothing. */ noMatch: string; + /** Present, the owner answers the search: it hears each query as typed, and the rows it hands are listed as they are, unfiltered. */ + onSearch?: ((query: string) => void) | undefined; onPick: (id: string) => void; /** Escape was pressed: the owner closes the menu and hands focus back to its trigger. */ onClose: () => void; @@ -322,17 +327,26 @@ export function SearchableMenu(props: { const { rows, value, onPick } = props; const leaving = props.leaving === true; const [query, setQuery] = useState(""); - const matches = matchingRows(rows, query); + // Note that rows an owner searched for are its answer to the query already, so the menu filters nothing. + const answered = props.onSearch !== undefined; + const matches = answered ? rows : matchingRows(rows, query); const foot = props.foot ?? []; // Until the arrows or the pointer move it, the highlight follows the chosen // row, which may arrive after the menu opened, so Enter keeps what stands. - const [highlight, setHighlight] = useState(undefined); + // The row moved to is held by its id as well as its place, so rows an + // owner hands anew, such as a search answering after the arrows moved, + // keep the highlight on that row rather than on whatever took its place. + const [highlight, setHighlightAt] = useState<{ index: number; id: string | undefined }>(); + const setHighlight = (index: number | undefined) => + setHighlightAt(index === undefined ? undefined : { index, id: matches[index]?.id }); + const movedRow = + highlight?.id === undefined ? -1 : matches.findIndex((row) => row.id === highlight.id); const chosen = matches.findIndex((row) => row.id === value); // A row that cannot be picked takes no highlight, from the arrows or from a moment ago. const blocked = (index: number) => (matches[index] ?? foot[index - matches.length])?.disabled !== undefined; const at = highlightOver({ - moved: highlight, + moved: movedRow >= 0 ? movedRow : highlight?.index, chosen, first: firstOpen(matches), count: matches.length + foot.length, @@ -349,7 +363,7 @@ export function SearchableMenu(props: { setWasLeaving(leaving); if (!leaving) { setQuery(""); - setHighlight(undefined); + setHighlightAt(undefined); setSubmenu(undefined); } } @@ -520,9 +534,11 @@ export function SearchableMenu(props: { combobox={keys} onChange={(typed) => { setQuery(typed); + props.onSearch?.(typed); // The first match that may be picked takes the highlight; with - // none, nothing does, so Enter picks nothing. - const first = firstOpen(matchingRows(rows, typed)); + // none, nothing does, so Enter picks nothing. The owner's own + // answer is not in yet, so its first row takes it as it comes. + const first = answered ? NOWHERE : firstOpen(matchingRows(rows, typed)); moveTo(first === NOWHERE ? undefined : first); }} /> diff --git a/apps/desktop/src/renderer/styles/desktop.css b/apps/desktop/src/renderer/styles/desktop.css index de89ecc4cf..f93ee479a6 100644 --- a/apps/desktop/src/renderer/styles/desktop.css +++ b/apps/desktop/src/renderer/styles/desktop.css @@ -2548,6 +2548,26 @@ ul.actor-stack { --luke-combobox-list-max: min(280px, var(--settings-picker-list-max, 280px)); } +/* New task's Linear chip (`linear-issue-chip.tsx`) stands after the + repository chip in the card's foot and hangs its menu from its leading + edge, placed as a Settings picker's is, wide enough for an issue's title + with its key and state after it. */ +.linear-issue-chip { + --luke-combobox-list-max: min(280px, var(--settings-picker-list-max, 280px)); + position: relative; + min-width: 0; +} + +.linear-issue-chip .searchable-menu { + width: 420px; + max-width: var(--settings-picker-width-max, 420px); +} + +.linear-issue-chip[data-side="above"] .searchable-menu { + top: auto; + bottom: calc(100% + var(--luke-space-4)); +} + /* A harness's mark keeps its box in a row however tight the row is, drawn as an image (`harness-marks.tsx`) as the traced provider marks (`@luke/ui`'s `ProviderMark`) keep theirs. */ diff --git a/apps/desktop/src/renderer/tasks/linear-issue-chip.tsx b/apps/desktop/src/renderer/tasks/linear-issue-chip.tsx new file mode 100644 index 0000000000..1ef88d2cb2 --- /dev/null +++ b/apps/desktop/src/renderer/tasks/linear-issue-chip.tsx @@ -0,0 +1,289 @@ +import { useAtom } from "@effect/atom-react"; +import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire"; +import type { LinearIssueSummary } from "@luke/features/linear-wire"; +import { rendererRuntime } from "@luke/panel/renderer-runtime"; +import { SHORTCUT_KEY, type ShortcutChord, shortcutAria } from "@luke/settings/shortcuts"; +import { CHIP_LOOK, CHIP_WIDTH, Chip, ChipName, ProviderMark } from "@luke/ui"; +import { type LinearIssuesAsk, linearIssuesAskOf, offeredIssues } from "@luke/ui/tasks-model"; +import { useDisclosure } from "@luke/ui/use-disclosure"; +import { Duration, Effect } from "effect"; +import * as AsyncResult from "effect/reactivity/AsyncResult"; +import { useEffect, useId, useRef, useState } from "react"; +import { type MenuRow, SearchableMenu } from "../searchable-menu"; +import { PICKER_ALIGN, usePickerPlacement } from "../settings/picker-row"; +import { Tooltip } from "../tooltip"; +import type { TasksControl } from "./use-tasks-tab"; + +/** + * linear-issue-chip.tsx -- New task's Linear chip: a press away from a search over the Linear issues of the workspace the task starts in. + * + * Drawn only where that workspace has Linear connected, as Linear's mark + * and its name, the chip opens the app's one searchable menu, hung from its + * leading edge and placed as a Settings picker's is. With nothing typed in + * the menu's field it lists the issues read as the card opened, as Linear + * listed them, newest changed first; as soon as anything is typed, the + * issues whose title or key holds every word stand nearest first, and once + * the typing rests Linear itself is searched for the words, so an issue + * older than those read is found too; a key typed whole or a link to an + * issue pasted asks Linear for that one issue instead. ↑ and ↓ move the + * highlight, Return chooses the highlighted issue, and Escape closes the + * menu, not the card, handing focus back to what opened it: the chip, or the + * field whose ⌘L asked for it (`openRequest`). The issues are read as the + * chip first stands and again for another workspace, which closes the menu. + * Nothing typed in the menu's field names the task; choosing an issue is + * the owner's to act on. + */ + +/** The chord that opens the menu from the card's own field. */ +export const LINEAR_CHORD: ShortcutChord = { key: SHORTCUT_KEY.L }; + +/** What the chip and its menu say. */ +const LINEAR_WORDS = { + CHIP: "Linear", + LABEL: "Choose from Linear", + MENU: "Linear issues", + SEARCH: "Search issues, or paste a key or link", + NONE: "No issues found", +} as const; + +/** How long the typing rests before Linear is searched for the words as typed. */ +const SEARCH_PAUSE = Duration.millis(250); + +/** + * The Linear issues of the workspace the task starts in, read as the card + * opens and again for another workspace; none while they are read, where + * that workspace has no Linear connected, or where the read was refused. + */ +function useLinearIssues( + readIn: TasksControl["newTask"]["linearIssuesIn"], + workspaceId: string | undefined, +): readonly LinearIssueSummary[] { + const [issues, setIssues] = useState([]); + // Note that the reader is held through a ref, because the tab hands a new + // one on every render and a read is owed to a workspace, not a render. + const latest = useRef(readIn); + latest.current = readIn; + const connected = readIn(workspaceId) !== undefined; + useEffect(() => { + setIssues([]); + const reading = connected ? latest.current(workspaceId) : undefined; + if (reading === undefined) return; + let current = true; + reading().then( + (listed) => { + if (current) setIssues(listed); + }, + () => undefined, + ); + return () => { + current = false; + }; + }, [connected, workspaceId]); + return issues; +} + +/** One search of a workspace's Linear for the words as typed, and the read that asks it. */ +interface IssueSearch { + readonly workspaceId: string | undefined; + readonly typed: string; + readonly ask: LinearIssuesAsk; + readonly read: (ask: LinearIssuesAsk) => Promise; +} + +/** What one search found, and the workspace and the words it was for. */ +interface IssueSearchAnswer { + readonly workspaceId: string | undefined; + readonly typed: string; + readonly issues: readonly LinearIssueSummary[]; +} + +/** + * A search once the typing has rested, as an atom whose work is the wait and + * then the read: setting it again starts the wait over and lets go of the + * read still out, so only the newest words are answered. A refused read + * finds nothing. + */ +const issueSearchAtom = () => + rendererRuntime.fn((search: IssueSearch) => + Effect.gen(function* () { + yield* Effect.sleep(SEARCH_PAUSE); + const issues = yield* Effect.tryPromise(() => search.read(search.ask)).pipe( + Effect.orElseSucceed((): readonly LinearIssueSummary[] => []), + ); + return { workspaceId: search.workspaceId, typed: search.typed, issues }; + }), + ); + +/** + * Linear searched for the words as typed in the workspace given, once the + * typing rests: the last answer, whichever words and workspace it was for, + * and the search to ask as the words or the workspace change. + */ +function useLinearSearch( + readIn: TasksControl["newTask"]["linearIssuesIn"], +): readonly [ + IssueSearchAnswer | undefined, + (typed: string, workspaceId: string | undefined) => void, +] { + const [atom] = useState(issueSearchAtom); + const [answer, search] = useAtom(atom); + const ask = (typed: string, workspaceId: string | undefined) => { + const read = readIn(workspaceId); + const asked = linearIssuesAskOf(typed); + if (read === undefined || asked === undefined) return; + search({ workspaceId, typed: typed.trim(), ask: asked, read }); + }; + return [AsyncResult.isSuccess(answer) ? answer.value : undefined, ask]; +} + +/** + * The issues the menu lists for the words typed so far: every issue read + * while nothing is typed; else those read that hold the words and what + * Linear found for them, nearest first. What Linear found for the words as + * they now stand is offered whatever it holds; what it found for earlier + * words stands with the issues read, offered only where it holds the words, + * until the newer search answers. + */ +function listedIssues( + issues: readonly LinearIssueSummary[], + searched: IssueSearchAnswer | undefined, + workspaceId: string | undefined, + typed: string, +): readonly LinearIssueSummary[] { + if (typed.trim() === "") return issues; + const answered = searched?.workspaceId === workspaceId ? searched : undefined; + const current = answered !== undefined && answered.typed === typed.trim(); + const found = current ? answered.issues : []; + const known = current || answered === undefined ? issues : [...issues, ...answered.issues]; + return offeredIssues(known, typed, found); +} + +/** One issue as a row: its title, then its key and the state it stands in. */ +function issueRow(issue: LinearIssueSummary): MenuRow { + return { + id: issue.id, + label: issue.title, + icon: , + detail: issue.state === undefined ? issue.identifier : `${issue.identifier} · ${issue.state}`, + }; +} + +export function LinearIssueChip({ + readIn, + workspaceId, + disabled, + openRequest, + onChoose, +}: { + /** The read of a workspace's Linear issues, none where it has no Linear connected (`TasksControl`'s). */ + readIn: TasksControl["newTask"]["linearIssuesIn"]; + /** The workspace the task starts in, whose issues are listed. */ + workspaceId: string | undefined; + disabled: boolean; + /** Counts the asks from elsewhere to open the menu, such as the field's ⌘L, each opening it. */ + openRequest?: number | undefined; + onChoose: (issue: LinearIssueSummary) => void; +}): React.JSX.Element | null { + const connected = readIn(workspaceId) !== undefined; + const issues = useLinearIssues(readIn, workspaceId); + const [searched, searchLinear] = useLinearSearch(readIn); + const [typed, setTyped] = useState(""); + const [open, setOpen] = useState(false); + const drawn = useDisclosure(open); + const menuId = useId(); + const box = useRef(null); + const trigger = useRef(null); + const placement = usePickerPlacement(open, trigger, PICKER_ALIGN.START); + /** What held focus as the menu opened, which closing hands it back to. */ + const opener = useRef(null); + // A switch of workspace closes the menu, in the render that brings it, + // since what it listed and what was typed in it were the other's. + const [listedFor, setListedFor] = useState(workspaceId); + if (listedFor !== workspaceId) { + setListedFor(workspaceId); + setOpen(false); + setTyped(""); + } + // Linear let go while the menu stood closes it too, so its coming back + // draws nothing until the chip is pressed again. + if (!connected && open) { + setOpen(false); + setTyped(""); + } + + // Note that whether the menu stands is read through a ref, because an ask + // is owed to its count alone and one made while it stands changes nothing. + const standing = useRef(open); + standing.current = open; + + // Each ask from elsewhere opens the menu afresh. + useEffect(() => { + if (openRequest === undefined || openRequest === 0 || standing.current) return; + opener.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + setTyped(""); + setOpen(true); + }, [openRequest]); + + if (!connected) return null; + + const listed = listedIssues(issues, searched, workspaceId, typed); + // Note that we hand focus back to where it came from, the chip or the + // field, as a native menu returns it to its trigger, because closing takes + // the focused search away with it. + const close = () => { + setOpen(false); + opener.current?.focus(); + }; + const press = () => { + opener.current = trigger.current; + setTyped(""); + setOpen(!open); + }; + + return ( +
+ + + + {LINEAR_WORDS.CHIP} + + + {drawn.mounted ? ( + { + setTyped(words); + searchLinear(words, workspaceId); + }} + onPick={(id) => { + const issue = listed.find((each) => each.id === id); + close(); + if (issue !== undefined) onChoose(issue); + }} + onClose={close} + onLeave={(left) => { + if (!(left instanceof Node && box.current?.contains(left))) setOpen(false); + }} + /> + ) : null} +
+ ); +} diff --git a/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts b/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts index 37f5389cb4..f0b1b115e9 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts @@ -569,10 +569,40 @@ const ISSUES: readonly LinearIssueSummary[] = [ }, ]; -/** The rows standing under the name field, each as it reads, and which one is highlighted. */ -function matchRows() { +/** What the Linear chip is named, and its search field's. */ +const LINEAR_CHIP = '[aria-label="Choose from Linear"]'; +const LINEAR_SEARCH = 'input[aria-label="Search issues, or paste a key or link"]'; + +/** The Linear chip's search, open. */ +function linearSearch(): HTMLInputElement { + return ( + document.querySelector(LINEAR_SEARCH) ?? + assert.fail("the Linear chip's search is open") + ); +} + +/** The Linear chip pressed, and its search as it opens. */ +async function openLinear(): Promise { + const chip = + document.querySelector(LINEAR_CHIP) ?? assert.fail("the Linear chip stands"); + act(() => chip.click()); + await settle(); + return linearSearch(); +} + +/** Words typed into the Linear chip's search. */ +function search(words: string): void { + const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + act(() => { + setValue?.call(linearSearch(), words); + linearSearch().dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +/** The rows any open list draws, each as it reads, and which one is highlighted. */ +function issueRows() { const options = [...document.querySelectorAll('[role="listbox"] [role="option"]')]; - const words = (row: Element) => row.textContent?.replace("⌘⏎", "").trim() ?? ""; + const words = (row: Element) => row.textContent?.trim() ?? ""; return { rows: options.map(words), highlighted: options @@ -582,84 +612,6 @@ function matchRows() { }; } -test("with Linear connected, what is typed lists the matching issues under the field, the nearest highlighted, and Return starts that issue's task at once on the card's repository", async () => { - const card = await mount(undefined, undefined, {}, ISSUES); - assert.equal(card.field().placeholder, "Task name, or search Linear issues"); - - card.type("stack"); - await settle(); - const listed = matchRows(); - const label = card.button("Start").textContent; - await card.press("Enter", card.field()); - - assert.deepEqual(listed, { - rows: [ - "Add support for stacked PRsENG-2 · In Progress", - "Restack the deploy queueENG-1 · Todo", - "Create task “stack”", - ], - highlighted: "Add support for stacked PRsENG-2 · In Progress", - }); - assert.equal(label, "Start ENG-2⏎"); - assert.deepEqual(card.starts, [["Add support for stacked PRs", "acme/web"]]); - assert.deepEqual(card.fromIssues, ["issue-2"]); -}); - -test("the arrows move on through the issues to the name as typed, and Command-Return creates the name as typed whatever is highlighted", async () => { - const card = await mount(undefined, undefined, {}, ISSUES); - card.type("deploy"); - await settle(); - - await card.press("ArrowDown", card.field()); - const second = matchRows().highlighted; - await card.press("ArrowDown", card.field()); - const typed = matchRows().highlighted; - await card.press("Enter", card.field()); - await card.press("ArrowDown", card.field()); - await card.press("Enter", card.field(), { metaKey: true }); - - assert.equal(second, "Rotate the deploy keyOPS-7"); - assert.equal(typed, "Create task “deploy”"); - assert.deepEqual(card.starts, [ - ["deploy", "acme/web"], - ["deploy", "acme/web"], - ]); - assert.deepEqual(card.fromIssues, [undefined, undefined]); -}); - -test("Escape puts the issues away and leaves the card, a second puts the card away, and typing brings the issues back", async () => { - const card = await mount(undefined, undefined, {}, ISSUES); - card.type("stack"); - await settle(); - - await card.press("Escape", card.field()); - const afterFirst = { rows: matchRows().rows.length, card: card.dialog() !== null }; - card.type("stacked"); - await settle(); - const retyped = matchRows().rows.length; - await card.press("Escape", card.field()); - await card.press("Escape", card.field()); - - assert.deepEqual(afterFirst, { rows: 0, card: true }); - assert.equal(retyped, 2); - assert.equal(card.dialog(), null); - assert.deepEqual(card.starts, []); -}); - -test("a name no issue matches is the plain field it always was: nothing listed, and Return creates the task under it", async () => { - const card = await mount(undefined, undefined, {}, ISSUES); - card.type("Refactor billing"); - await settle(); - - const listed = document.querySelector('[role="listbox"]'); - await card.press("Enter", card.field()); - - assert.equal(listed, null); - assert.equal(card.field().getAttribute("aria-expanded"), "false"); - assert.deepEqual(card.starts, [["Refactor billing", "acme/web"]]); - assert.deepEqual(card.fromIssues, [undefined]); -}); - /** Longer than the typing rests before Linear is searched. */ const PAST_SEARCH_PAUSE = 300; @@ -680,7 +632,65 @@ const OLDER_ISSUE: LinearIssueSummary = { state: "Backlog", }; -test("once the typing rests, Linear itself is searched for the words, and an issue it finds beyond those read stands after the ones read", async () => { +test("with Linear connected, typing in the name lists no issues and searches no Linear: Return creates the task under the name as typed", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const card = await mount(undefined, undefined, {}, ISSUES); + card.type("stack"); + await rest(); + const listed = document.querySelector('[role="listbox"]'); + await card.press("Enter", card.field()); + + assert.equal(card.field().placeholder, "Task name (optional)"); + assert.equal(listed, null); + assert.deepEqual(card.asks, []); + assert.deepEqual(card.starts, [["stack", "acme/web"]]); + assert.deepEqual(card.fromIssues, [undefined]); + } finally { + vi.useRealTimers(); + } +}); + +test("the Linear chip stands after the repository chip only where the workspace has Linear connected", async () => { + const connected = await mount(undefined, undefined, {}, ISSUES); + const withLinear = connected.controls(); + for (const root of roots.splice(0)) act(() => root.unmount()); + document.body.innerHTML = ""; + const plain = await mount(); + + assert.deepEqual(withLinear, [ + "Plan with Luke", + "Task name", + "acme/web", + "Choose from Linear", + "Cancel", + "Create task⏎", + ]); + assert.equal(plain.controls().includes("Choose from Linear"), false); +}); + +test("the Linear chip lists the issues read as the card opened, the arrows move, and Return starts that issue's task at once on the card's repository", async () => { + const card = await mount(undefined, undefined, {}, ISSUES); + const field = await openLinear(); + const listed = issueRows(); + await card.press("ArrowDown", field); + const moved = issueRows().highlighted; + await card.press("Enter", field); + + assert.deepEqual(listed, { + rows: [ + "Restack the deploy queueENG-1 · Todo", + "Add support for stacked PRsENG-2 · In Progress", + "Rotate the deploy keyOPS-7", + ], + highlighted: "Restack the deploy queueENG-1 · Todo", + }); + assert.equal(moved, "Add support for stacked PRsENG-2 · In Progress"); + assert.deepEqual(card.starts, [["Add support for stacked PRs", "acme/web"]]); + assert.deepEqual(card.fromIssues, ["issue-2"]); +}); + +test("typing in the Linear search narrows the issues at once, and once it rests Linear itself is searched, an issue it finds beyond those read standing after them", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { const card = await mount(undefined, undefined, {}, ISSUES, (ask) => @@ -688,44 +698,91 @@ test("once the typing rests, Linear itself is searched for the words, and an iss ? [OLDER_ISSUE, ISSUES[0] ?? assert.fail("the first issue")] : [], ); - card.type("deploy"); + await openLinear(); + search("deploy"); await settle(); - card.type("deploy queue"); + const narrowed = issueRows().rows; + search("deploy queue"); await settle(); - const before = { rows: matchRows().rows, asks: card.asks.length }; + const before = { rows: issueRows().rows, asks: card.asks.length }; await rest(); - const after = matchRows().rows; - assert.deepEqual(before, { - rows: ["Restack the deploy queueENG-1 · Todo", "Create task “deploy queue”"], - asks: 0, - }); + assert.deepEqual(narrowed, [ + "Restack the deploy queueENG-1 · Todo", + "Rotate the deploy keyOPS-7", + ]); + assert.deepEqual(before, { rows: ["Restack the deploy queueENG-1 · Todo"], asks: 0 }); assert.deepEqual(card.asks, [{ query: "deploy queue" }]); - assert.deepEqual(after, [ + assert.deepEqual(issueRows().rows, [ "Restack the deploy queueENG-1 · Todo", "Queue jumps on retryENG-301 · Backlog", - "Create task “deploy queue”", ]); + assert.deepEqual(card.starts, []); } finally { vi.useRealTimers(); } }); -test("a pasted link to an issue, or its key typed alone, asks Linear for that one issue, which is offered first and started by Return", async () => { +test("an issue Linear finds that sorts ahead of the highlighted one leaves the highlight, and Return, on the issue the arrows chose", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const redeploy: LinearIssueSummary = { + id: "issue-9", + identifier: "ENG-9", + title: "Redeploy the docs", + url: "https://linear.app/acme/issue/ENG-9", + }; + const dashboard: LinearIssueSummary = { + id: "issue-400", + identifier: "ENG-400", + title: "Deploy dashboard", + url: "https://linear.app/acme/issue/ENG-400", + }; + const card = await mount( + undefined, + undefined, + {}, + [ISSUES[0] ?? assert.fail("the first issue"), redeploy], + (ask) => (ask.query === "deploy" ? [dashboard] : []), + ); + await openLinear(); + search("deploy"); + await settle(); + await card.press("ArrowDown", linearSearch()); + const chosen = issueRows().highlighted; + await rest(); + const answered = issueRows(); + await card.press("Enter", linearSearch()); + + assert.equal(chosen, "Redeploy the docsENG-9"); + assert.deepEqual(answered, { + rows: [ + "Restack the deploy queueENG-1 · Todo", + "Deploy dashboardENG-400", + "Redeploy the docsENG-9", + ], + highlighted: "Redeploy the docsENG-9", + }); + assert.deepEqual(card.fromIssues, ["issue-9"]); + } finally { + vi.useRealTimers(); + } +}); + +test("a link to an issue pasted in the Linear search asks Linear for that one issue, which is offered first and started by Return", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { const card = await mount(undefined, undefined, {}, ISSUES, (ask) => ask.identifier === "ENG-301" ? [OLDER_ISSUE] : [], ); - card.type("https://linear.app/acme/issue/ENG-301/queue-jumps-on-retry"); - await rest(); - const listed = matchRows(); - await card.press("Enter", card.field()); - card.type("eng-301"); + await openLinear(); + search("https://linear.app/acme/issue/ENG-301/queue-jumps-on-retry"); await rest(); + const listed = issueRows(); + await card.press("Enter", linearSearch()); - assert.deepEqual(card.asks, [{ identifier: "ENG-301" }, { identifier: "ENG-301" }]); - assert.deepEqual(listed.highlighted, "Queue jumps on retryENG-301 · Backlog"); + assert.deepEqual(card.asks, [{ identifier: "ENG-301" }]); + assert.equal(listed.highlighted, "Queue jumps on retryENG-301 · Backlog"); assert.deepEqual(card.starts, [["Queue jumps on retry", "acme/web"]]); assert.deepEqual(card.fromIssues, ["issue-301"]); } finally { @@ -733,7 +790,38 @@ test("a pasted link to an issue, or its key typed alone, asks Linear for that on } }); -test("opened on one of the workspace's issues, the card says so, starts named by the issue in its workspace, searches no Linear, and links the task to the issue", async () => { +test("Escape closes the Linear search and leaves the card with focus on the chip, and a second Escape puts the card away", async () => { + const card = await mount(undefined, undefined, {}, ISSUES); + const field = await openLinear(); + await card.press("Escape", field); + const afterFirst = { + search: document.querySelector(LINEAR_SEARCH) !== null, + card: card.dialog() !== null, + focus: document.activeElement?.getAttribute("aria-label"), + }; + await card.press("Escape", card.field()); + + assert.deepEqual(afterFirst, { search: false, card: true, focus: "Choose from Linear" }); + assert.equal(card.dialog(), null); + assert.deepEqual(card.starts, []); +}); + +test("Command-L from the name field opens the Linear search, but not while an input method composes, and Escape hands focus back to the name", async () => { + const card = await mount(undefined, undefined, {}, ISSUES); + await card.press("l", card.field(), { metaKey: true, isComposing: true }); + const whileComposing = document.querySelector(LINEAR_SEARCH); + await card.press("l", card.field(), { metaKey: true }); + const opened = document.activeElement === linearSearch(); + await card.press("Escape", linearSearch()); + + assert.equal(whileComposing !== null, false, "a composing key opens nothing"); + assert.equal(opened, true); + assert.equal(document.querySelector(LINEAR_SEARCH) !== null, false, "the search is closed"); + assert.equal(document.activeElement, card.field()); + assert.ok(card.dialog()); +}); + +test("opened on one of the workspace's issues, the card says so, starts named by the issue in its workspace, offers no Linear chip, and links the task to the issue", async () => { const issue = { id: "issue-1", workspaceId: "workspace-2", @@ -750,10 +838,9 @@ test("opened on one of the workspace's issues, the card says so, starts named by assert.match(card.dialog()?.querySelector("h2")?.textContent ?? "", /^New task from ACME-7\b/); assert.equal(card.field().value, "Invites expire"); - assert.equal(card.field().placeholder, "Task name (optional)"); - card.type("Invites expire"); - await settle(); - assert.equal(document.querySelector('[role="option"]'), null, "no Linear issue is offered"); + assert.equal(document.querySelector(LINEAR_CHIP), null, "no Linear chip is offered"); + await card.press("l", card.field(), { metaKey: true }); + assert.equal(document.querySelector(LINEAR_SEARCH) !== null, false, "Command-L opens nothing"); await card.press("Enter", card.field()); assert.deepEqual(card.starts, [["Invites expire", "acme/web"]]); diff --git a/apps/desktop/src/renderer/tasks/new-task-dialog.tsx b/apps/desktop/src/renderer/tasks/new-task-dialog.tsx index a848bd8e12..492bfa16a5 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.tsx @@ -1,42 +1,33 @@ -import { useAtom } from "@effect/atom-react"; -import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire"; import type { LinearIssueSummary } from "@luke/features/linear-wire"; import { TASK_BOUNDS } from "@luke/features/task-wire"; import type { Workspace } from "@luke/features/workspace-wire"; import { WorkspaceTile } from "@luke/panel/desktop/workspace-tile"; -import { rendererRuntime } from "@luke/panel/renderer-runtime"; import { Shortcut } from "@luke/panel/shortcut"; import { useNewTaskPlanning } from "@luke/panel/tasks/new-task-planning"; -import { SHORTCUT_KEY, type ShortcutChord, shortcutAria } from "@luke/settings/shortcuts"; +import { + SHORTCUT_KEY, + type ShortcutChord, + shortcutAria, + shortcutMatches, +} from "@luke/settings/shortcuts"; import { BUTTON_VARIANT, Button, CHIP_WIDTH, - Combobox, - ComposeIcon, DIALOG_SIZE, Dialog, ErrorState, - ProviderMark, Switch, TEXT_ELEMENT, TEXT_VARIANT, Text, + TextField, } from "@luke/ui"; -import { searchTokens } from "@luke/ui/search-tokens"; -import { - type LinearIssuesAsk, - linearIssuesAskOf, - offeredIssues, - taskNameOfIssue, - workspaceReaches, -} from "@luke/ui/tasks-model"; -import { Duration, Effect } from "effect"; -import * as AsyncResult from "effect/reactivity/AsyncResult"; -import { useEffect, useId, useRef, useState } from "react"; -import { Highlighted } from "../search-field"; +import { taskNameOfIssue, workspaceReaches } from "@luke/ui/tasks-model"; +import { useId, useRef, useState } from "react"; import { PICKER_ALIGN, Picker } from "../settings/picker-row"; import { Tooltip } from "../tooltip"; +import { LINEAR_CHORD, LinearIssueChip } from "./linear-issue-chip"; import { CHIP_PLACE, RepositoryChip } from "./repository-chip"; import type { TasksControl } from "./use-tasks-tab"; @@ -72,30 +63,23 @@ import type { TasksControl } from "./use-tasks-tab"; * rather than the template, and no call. The switch is kept as it was left * for the next New task (`new-task-planning.ts`). * - * Where the workspace the task starts in has Linear connected, the name - * field is also the search over its issues, read as the card opens and - * again on a switch of workspace. As soon as anything is typed, the issues - * whose title or key holds every word stand under the field, and once the - * typing rests Linear itself is searched for the words, so an issue older - * than those read is found too; - * a key typed whole or a link to an issue pasted asks Linear for that one - * issue instead. They stand nearest first, the top one highlighted, and the - * arrows move the highlight on through them to the last row, which creates - * the task under the name as typed. - * Return, or Create, does what the highlight says: an issue starts its task - * at once, named by its title, on the repository, in the workspace, and with - * the planning the card shows, and Luke is handed the issue to research. - * Command-Return creates the task under the name as typed whatever is - * highlighted, and Escape puts the issues away before it puts the card away. - * The field and its issues are `@luke/ui`'s `Combobox`, which reads those - * keys; with no issues standing, Return creates the task with any modifier - * held, since the name has no second line to begin. - * With no issue matching, or no Linear connected, or its issues not read, - * the field is the plain name it always was. + * The name field is only ever the name: typing in it searches nothing. + * Where the workspace the task starts in has Linear connected, a Linear + * chip stands after the repository chip in the card's foot + * (`linear-issue-chip.tsx`), and a press on it, or ⌘L from the card, opens + * a search over that workspace's issues, read as the card opens and again + * on a switch of workspace, which closes the search. With nothing typed it + * lists the issues read; typing narrows them at once, and once the typing + * rests Linear itself is searched, so an issue older than those read is + * found too, and a key or a link to an issue pasted asks Linear for that one + * issue. The arrows move through them, Return chooses, and Escape closes the + * search and leaves the card. Choosing an issue starts its task at once, + * named by its title, on the repository, in the workspace, and with the + * planning the card shows, and Luke is handed the issue to research. * * Opened from one of the workspace's own issues (its Start task…), the card * says so in its title, its name starts as the issue's title, it starts in - * the issue's workspace and no other, and its name searches no Linear; the + * the issue's workspace and no other, and it offers no Linear chip; the * task it starts is linked to that issue, which the issue's page lists it * under. Opened from the issue's Start agent…, the card is a new agent's: * it offers no Plan switch, since the task starts without its planning @@ -106,17 +90,8 @@ import type { TasksControl } from "./use-tasks-tab"; /** Create task's chord: Return alone, from the name field. */ const CREATE_CHORD: ShortcutChord = { key: SHORTCUT_KEY.ENTER, bare: true }; -/** The chord that creates the task under the name as typed while Linear's issues stand under the field. */ -const TYPED_CHORD: ShortcutChord = { key: SHORTCUT_KEY.ENTER }; - -/** The key of the last option under the field, which creates the task under the name as typed. */ -const TYPED_OPTION = "typed"; - -/** What the name field says before anything is typed, by whether it also searches Linear. */ -const NAME_PLACEHOLDER = { - PLAIN: "Task name (optional)", - LINEAR: "Task name, or search Linear issues", -} as const; +/** What the name field says before anything is typed. */ +const NAME_PLACEHOLDER = "Task name (optional)"; /** The card's title, before the workspace's chip: an agent's where one starts at once, the issue it starts from where there is one, and "in" where the chip stands after it. */ function newTaskHeading(issue: string | undefined, inWorkspace: boolean, agent: boolean): string { @@ -134,9 +109,6 @@ const PLANNING_NOTE = { OFF: "Starts with an empty plan and no call.", } as const; -/** How long the typing rests before Linear is searched for the name as typed. */ -const SEARCH_PAUSE = Duration.millis(250); - /** What the workspace picker's menu says where no workspace matches; its list is short enough to read whole, so it is never searched. */ const NO_WORKSPACE_MATCH = "No workspaces match"; @@ -178,94 +150,6 @@ function WorkspaceChip({ ); } -/** - * The Linear issues of the workspace the task starts in, read as the card - * opens and again for another workspace; none while they are read, where - * that workspace has no Linear connected, or where the read was refused, - * which leaves the name a plain field rather than a search that failed. - */ -function useLinearIssues( - readIn: TasksControl["newTask"]["linearIssuesIn"], - workspaceId: string | undefined, -): readonly LinearIssueSummary[] { - const [issues, setIssues] = useState([]); - // Note that the reader is held through a ref, because the tab hands a new - // one on every render and a read is owed to a workspace, not a render. - const latest = useRef(readIn); - latest.current = readIn; - const connected = readIn(workspaceId) !== undefined; - useEffect(() => { - setIssues([]); - const reading = connected ? latest.current(workspaceId) : undefined; - if (reading === undefined) return; - let current = true; - reading().then( - (listed) => { - if (current) setIssues(listed); - }, - () => undefined, - ); - return () => { - current = false; - }; - }, [connected, workspaceId]); - return issues; -} - -/** One search of a workspace's Linear for the name as typed, and the read that asks it. */ -interface IssueSearch { - readonly workspaceId: string | undefined; - readonly typed: string; - readonly ask: LinearIssuesAsk; - readonly read: (ask: LinearIssuesAsk) => Promise; -} - -/** What one search found, and the workspace and the name it was for. */ -interface IssueSearchAnswer { - readonly workspaceId: string | undefined; - readonly typed: string; - readonly issues: readonly LinearIssueSummary[]; -} - -/** - * A search once the typing has rested, as an atom whose work is the wait and - * then the read: setting it again starts the wait over and lets go of the - * read still out, so only the newest name is answered. A refused read - * finds nothing. - */ -const issueSearchAtom = () => - rendererRuntime.fn((search: IssueSearch) => - Effect.gen(function* () { - yield* Effect.sleep(SEARCH_PAUSE); - const issues = yield* Effect.tryPromise(() => search.read(search.ask)).pipe( - Effect.orElseSucceed((): readonly LinearIssueSummary[] => []), - ); - return { workspaceId: search.workspaceId, typed: search.typed, issues }; - }), - ); - -/** - * Linear searched for the name as typed in the workspace given, once the - * typing rests: the last answer, whichever name and workspace it was for, - * and the search to ask as the name or the workspace changes. - */ -function useLinearSearch( - readIn: TasksControl["newTask"]["linearIssuesIn"], -): readonly [ - IssueSearchAnswer | undefined, - (typed: string, workspaceId: string | undefined) => void, -] { - const [atom] = useState(issueSearchAtom); - const [answer, search] = useAtom(atom); - const ask = (typed: string, workspaceId: string | undefined) => { - const read = readIn(workspaceId); - const asked = linearIssuesAskOf(typed); - if (read === undefined || asked === undefined) return; - search({ workspaceId, typed: typed.trim(), ask: asked, read }); - }; - return [AsyncResult.isSuccess(answer) ? answer.value : undefined, ask]; -} - /** * The card with its own state, mounted afresh on each opening so a * cancelled name is never offered again: the name, the repository chosen @@ -282,7 +166,7 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J const [chosen, setChosen] = useState(newTask.preset?.repository); // The workspace the task starts in: the one listed, or the issue's, until the title's chip chooses another. const [workspaceId, setWorkspaceId] = useState(from?.workspaceId ?? newTask.workspaceId); - // Note that a task from one of the workspace's issues starts from that issue alone, so its name searches no Linear. + // Note that a task from one of the workspace's issues starts from that issue alone, so it offers no Linear chip. const linearIssuesIn: TasksControl["newTask"]["linearIssuesIn"] = from === undefined ? newTask.linearIssuesIn : () => undefined; const startsIn = workspaceId ?? newTask.workspaceId; @@ -300,23 +184,8 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J const [starting, setStarting] = useState(false); const [note, setNote] = useState(undefined); const searchesLinear = linearIssuesIn(startsIn) !== undefined; - const issues = useLinearIssues(linearIssuesIn, startsIn); - const [searched, searchLinear] = useLinearSearch(linearIssuesIn); - // What Linear found for the name as it now stands is offered whatever it - // holds; what it found for an earlier name stands with the issues read, - // offered only where it holds the words, until the newer search answers. - const answered = searched?.workspaceId === startsIn ? searched : undefined; - const current = answered !== undefined && answered.typed === name.trim(); - const found = current ? answered.issues : []; - const known = current || answered === undefined ? issues : [...issues, ...answered.issues]; - // The row the arrows stand on among the issues and, after them, the name as typed; and whether Escape put the issues away until the next keystroke. - const [highlight, setHighlight] = useState(0); - const [dismissed, setDismissed] = useState(false); - const matches = dismissed ? [] : offeredIssues(known, name, found); - const matching = matches.length > 0; - const at = Math.min(highlight, matches.length); - const picked = matching ? matches[at] : undefined; - const tokens = searchTokens(name); + // How many times ⌘L asked the Linear chip to open its menu. + const [linearAsks, setLinearAsks] = useState(0); const field = useRef(null); const titleId = useId(); const planningId = useId(); @@ -349,10 +218,16 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J .then(setNote) .finally(() => setStarting(false)); }; - const startTyped = () => begin(name.trim()); + const start = () => begin(name.trim()); const startIssue = (issue: LinearIssueSummary) => begin(taskNameOfIssue(issue), issue); - // Create does what Return does: the highlighted issue, else the name as typed. - const start = () => (picked === undefined ? startTyped() : startIssue(picked)); + // ⌘L from the card opens the Linear chip's menu, where the chip stands. + const onCardKey = (event: React.KeyboardEvent) => { + // Note that a key an input method is composing with is its own, Safari's last one (229) among them. + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (!searchesLinear || starting || !shortcutMatches(LINEAR_CHORD, event)) return; + event.preventDefault(); + setLinearAsks((asks) => asks + 1); + }; const close = () => { if (!starting) newTask.onClose(); }; @@ -365,7 +240,8 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J initialFocus={field} onDismiss={close} > -
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: ⌘L is read as it bubbles from the card's own controls, the name field first, which are what take the keys. */} +
@@ -375,12 +251,7 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J workspaces={newTask.workspaces} chosen={startsIn} disabled={starting || from !== undefined} - onChoose={(chosenId) => { - setWorkspaceId(chosenId); - setHighlight(0); - setDismissed(false); - searchLinear(name, chosenId); - }} + onChoose={setWorkspaceId} />
{/* Note that an agent started at once starts without planning, so its card offers no Plan switch. */} @@ -406,56 +277,17 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J )}
- { - setName(typed); - setHighlight(0); - setDismissed(false); - searchLinear(typed, startsIn); - }} - groups={[ - { - label: "Linear issues", - options: matches.map((issue) => ({ - id: issue.id, - label: , - icon: , - detail: - issue.state === undefined - ? issue.identifier - : `${issue.identifier} · ${issue.state}`, - })), - }, - { - options: [ - { - id: TYPED_OPTION, - label: `Create task “${name.trim()}”`, - icon: , - hint: , - }, - ], - }, - ]} - open={matching} - active={at} - onActiveChange={setHighlight} - onChoose={(index) => { - const issue = matches[index]; - if (issue === undefined) startTyped(); - else startIssue(issue); - }} - onEnter={startTyped} - onEscape={() => setDismissed(true)} + onChange={setName} + onEnter={start} /> {note === undefined ? null : {note}}
@@ -469,6 +301,13 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J onChoose={setChosen} onUnreached={() => setChosen(null)} /> +
diff --git a/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts b/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts index dc804a6460..4c9d14b90a 100644 --- a/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts @@ -297,7 +297,7 @@ test("Tab from the workspace picker's open list moves on to the Plan switch besi assert.deepEqual([forward, backward], ["Plan with Luke", "Workspace: Acme"]); }); -test("issues put away with Escape come back for the workspace chosen next, its own issues matched against the name as typed", async () => { +test("a switch of workspace closes the Linear search, and the chip then lists the issues of the workspace chosen", async () => { const issuesOf = (workspaceId: string): readonly LinearIssueSummary[] => [ { id: `${workspaceId}-1`, @@ -325,25 +325,19 @@ test("issues put away with Escape come back for the workspace chosen next, its o ), ); await settle(); - const name = - document.querySelector('input[aria-label="Task name"]') ?? - assert.fail("the name field stands"); - act(() => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(name, "deploy"); - name.dispatchEvent(new Event("input", { bubbles: true })); - }); - await settle(); + const openLinear = async () => { + const chip = + document.querySelector('[aria-label="Choose from Linear"]') ?? + assert.fail("the Linear chip stands"); + act(() => chip.click()); + await settle(); + }; const listed = () => [ - ...document.querySelectorAll( - '[role="listbox"] [role="group"][aria-label="Linear issues"] [role="option"]', - ), + ...document.querySelectorAll('[role="dialog"][aria-label="Linear issues"] [role="option"]'), ].map((option) => option.textContent); + await openLinear(); const before = listed(); - act(() => { - name.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); - }); - const dismissed = listed(); const workspaceChip = document.querySelector('[aria-label="Workspace: Personal"]') ?? @@ -356,7 +350,56 @@ test("issues put away with Escape come back for the workspace chosen next, its o act(() => optionNamed(menu, "Acme").click()); await settle(); + const afterSwitch = listed(); + await openLinear(); + assert.deepEqual(before, ["Rotate the deploy keyME-1"]); - assert.deepEqual(dismissed, []); + assert.deepEqual(afterSwitch, []); assert.deepEqual(listed(), ["Rotate the deploy keyACME-1"]); }); + +test("Linear let go while its search stands closes the search, and its coming back draws none until the chip is pressed", async () => { + const issues: readonly LinearIssueSummary[] = [ + { + id: "acme-1", + identifier: "ACME-1", + title: "Rotate the deploy key", + url: "https://linear.app/x/issue/1", + }, + ]; + const control = tasksControl(); + const root = createRoot(document.body.appendChild(document.createElement("div"))); + roots.push(root); + const draw = (connected: boolean) => + act(() => + root.render( + createElement(NewTaskDialog, { + newTask: { + ...control.newTask, + open: true, + workspaces: [ACME], + workspaceId: ACME.id, + repositoriesIn: chooserFor, + linearIssuesIn: () => (connected ? () => Promise.resolve(issues) : undefined), + }, + }), + ), + ); + const search = () => document.querySelector('[role="dialog"][aria-label="Linear issues"]'); + draw(true); + await settle(); + const chip = + document.querySelector('[aria-label="Choose from Linear"]') ?? + assert.fail("the Linear chip stands"); + act(() => chip.click()); + await settle(); + const opened = search() !== null; + + draw(false); + await settle(); + draw(true); + await settle(); + + assert.equal(opened, true); + assert.equal(search() !== null, false, "no search is drawn"); +}); diff --git a/packages/settings/src/shortcuts.ts b/packages/settings/src/shortcuts.ts index 1d09a5f5e7..25562fa94e 100644 --- a/packages/settings/src/shortcuts.ts +++ b/packages/settings/src/shortcuts.ts @@ -179,6 +179,8 @@ export const SHORTCUT_KEY = { J: { accelerator: "J", glyph: "J", key: "j", code: "KeyJ" }, I: { accelerator: "I", glyph: "I", key: "i", code: "KeyI" }, U: { accelerator: "U", glyph: "U", key: "u", code: "KeyU" }, + // ⌘L is New task's own, opening its Linear chip from the name field; no app command holds it. + L: { accelerator: "L", glyph: "L", key: "l", code: "KeyL" }, ZERO: { accelerator: "0", glyph: "0", key: "0", code: "Digit0" }, // ⌘+ answers ⌘= as well, so zooming in never needs Shift, as in Chrome and // VS Code. @@ -695,7 +697,7 @@ interface ShortcutKeyPress { * either character, Shift held or not, because which of the two needs Shift * is the keyboard layout's to say. */ -function shortcutMatches(chord: ShortcutChord, event: ShortcutKeyPress): boolean { +export function shortcutMatches(chord: ShortcutChord, event: ShortcutKeyPress): boolean { if (event.metaKey === commandless(chord)) return false; if (event.ctrlKey !== (chord.control === true)) return false; const shiftFree = chord.key.unshifted !== undefined;