From 320a4d0441b9145dfbf7b1f2b3d38ca0c864eb39 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:57:28 +0000 Subject: [PATCH 01/15] feat(desktop): choose a New task's Linear issue from its own chip, not the name field The name field is a plain name again: typing in it lists no Linear issues and searches nothing. Where the workspace the task starts in has Linear connected, a Linear chip stands after the repository chip, and a press on it, or Command-L from the card, opens the shared searchable menu over the workspace's issues: those read as the card opened with nothing typed, the same narrowing, rested Linear search, and key or link lookup as before (moved unchanged into linear-issue-chip.tsx). Choosing an issue starts its task at once, as choosing one did; Escape closes the menu and not the card; a switch of workspace closes it and reads that workspace's issues. SearchableMenu takes an onSearch for an owner that answers the search itself, listing its rows unfiltered. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/src/renderer/searchable-menu.tsx | 17 +- apps/desktop/src/renderer/styles/desktop.css | 31 +- .../src/renderer/tasks/linear-issue-chip.tsx | 283 ++++++++++++++++++ .../renderer/tasks/new-task-dialog.test.ts | 248 ++++++++------- .../src/renderer/tasks/new-task-dialog.tsx | 267 ++++------------- .../renderer/tasks/new-task-workspace.test.ts | 31 +- packages/settings/src/shortcuts.ts | 4 +- 7 files changed, 536 insertions(+), 345 deletions(-) create mode 100644 apps/desktop/src/renderer/tasks/linear-issue-chip.tsx diff --git a/apps/desktop/src/renderer/searchable-menu.tsx b/apps/desktop/src/renderer/searchable-menu.tsx index 2ca41301ca..4c46ea61d3 100644 --- a/apps/desktop/src/renderer/searchable-menu.tsx +++ b/apps/desktop/src/renderer/searchable-menu.tsx @@ -36,7 +36,10 @@ import { Tooltip } from "./tooltip"; * are `@luke/ui`'s `useComboboxField`, the one `Combobox` reads, so keys an * input method is composing with move and pick nothing here either. 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 @@ -285,6 +288,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; @@ -303,7 +308,9 @@ 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. @@ -502,9 +509,11 @@ export function SearchableMenu(props: { onChange={(event) => { const typed = event.currentTarget.value; 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 83a0c79992..addd6fcab7 100644 --- a/apps/desktop/src/renderer/styles/desktop.css +++ b/apps/desktop/src/renderer/styles/desktop.css @@ -2849,6 +2849,29 @@ ul.actor-stack { max-height: min(288px, var(--settings-picker-list-max, 288px)); } +/* 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 { + 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)); +} + +.linear-issue-chip .task-compose-menu-list { + max-height: min(288px, var(--settings-picker-list-max, 288px)); +} + /* 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. */ @@ -6257,15 +6280,17 @@ body:has(.app-stage[data-capture="true"]) /* A file's row keeps its name whole and cuts its folder short instead, so a deep path never hides which file the row opens, and a repository's row - its name rather than its owner (`repository-chip.tsx`); either fades where + its name rather than its owner (`repository-chip.tsx`), and an issue's its + title rather than its key (`linear-issue-chip.tsx`); each fades where it is cut. A row with no folder gives its name the whole width. */ -:is(.tab-add-menu, .side-panel-no-tabs, .repository-chip) +:is(.tab-add-menu, .side-panel-no-tabs, .repository-chip, .linear-issue-chip) .task-compose-menu-name:has(+ .task-compose-menu-detail) { flex: 0 0 auto; max-width: 70%; } -:is(.tab-add-menu, .side-panel-no-tabs, .repository-chip) .task-compose-menu-detail { +:is(.tab-add-menu, .side-panel-no-tabs, .repository-chip, .linear-issue-chip) + .task-compose-menu-detail { min-width: 0; flex: 0 1 auto; overflow: hidden; 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..bf8a46362f --- /dev/null +++ b/apps/desktop/src/renderer/tasks/linear-issue-chip.tsx @@ -0,0 +1,283 @@ +import { useAtom } from "@effect/atom-react"; +import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire"; +import type { LinearIssueSummary } from "@luke/features/linear-wire"; +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 { rendererRuntime } from "../renderer-runtime"; +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(""); + } + + // 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 e15cec9646..de304c00ff 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts @@ -565,10 +565,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 @@ -578,84 +608,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; @@ -676,7 +628,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) => @@ -684,44 +694,45 @@ 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("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 { @@ -729,7 +740,35 @@ 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, and Escape hands focus back to the name", async () => { + const card = await mount(undefined, undefined, {}, ISSUES); + await card.press("l", card.field(), { metaKey: true }); + const opened = document.activeElement === linearSearch(); + await card.press("Escape", linearSearch()); + + assert.equal(opened, true); + assert.equal(document.querySelector(LINEAR_SEARCH), null); + 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", @@ -746,10 +785,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, "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 968e949dfa..d216c209e0 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.tsx @@ -1,41 +1,32 @@ -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 { 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 { taskNameOfIssue, workspaceReaches } from "@luke/ui/tasks-model"; +import { useId, useRef, useState } from "react"; import { WorkspaceTile } from "../desktop/workspace-tile"; -import { rendererRuntime } from "../renderer-runtime"; -import { Highlighted } from "../search-field"; import { PICKER_ALIGN, Picker } from "../settings/picker-row"; import { Shortcut } from "../shortcut"; import { Tooltip } from "../tooltip"; +import { LINEAR_CHORD, LinearIssueChip } from "./linear-issue-chip"; import { useNewTaskPlanning } from "./new-task-planning"; 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. */ @@ -103,17 +87,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: the issue it starts from where there is one, and "in" where the chip stands after it. */ function newTaskHeading(issue: string | undefined, inWorkspace: boolean): string { @@ -127,9 +102,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"; @@ -171,94 +143,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 @@ -273,7 +157,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; @@ -291,23 +175,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(); @@ -334,10 +203,14 @@ 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) => { + if (!searchesLinear || starting || !shortcutMatches(LINEAR_CHORD, event)) return; + event.preventDefault(); + setLinearAsks((asks) => asks + 1); + }; const close = () => { if (!starting) newTask.onClose(); }; @@ -350,7 +223,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. */} +
@@ -360,12 +234,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 the word labels the switch, so a press on it turns the switch too. */} @@ -386,56 +255,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}}
@@ -449,6 +279,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 e4636e4166..0dacf90c89 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,10 @@ 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"]); }); 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; From 8147cd2cf33f9b2ffe3317163a531e6804632bdf Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:48:12 +0000 Subject: [PATCH 02/15] feat(desktop): start a coding agent from New task's prompt-first composer, with Plan mode for a call with Luke New task's name field becomes the agent composer card: words, files, dictation, the model chip, and Start, with the repository and Linear chips and a Plan toggle at its foot. Agent mode starts the task without planning and opens it on a draft agent tab that sends the words at once, so a refused agent keeps them with Retry. A Linear issue picked becomes a chip whose mention leads the agent's prompt. Plan mode starts today's plan and call, named by the words until the brief lands. An Untitled task's tasks are read again as its first agent's title lands. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/src/renderer/styles/desktop.css | 79 ++-- .../src/renderer/tasks/agent-composer.tsx | 99 +++- .../src/renderer/tasks/composer-pickers.tsx | 9 +- .../renderer/tasks/draft-agent-tab.test.ts | 39 ++ .../src/renderer/tasks/draft-agent-tab.tsx | 17 + .../src/renderer/tasks/new-task-composer.tsx | 392 ++++++++++++++++ .../renderer/tasks/new-task-dialog.test.ts | 430 ++++++++++++------ .../src/renderer/tasks/new-task-dialog.tsx | 256 ++--------- .../src/renderer/tasks/new-task-mode.ts | 54 +++ .../src/renderer/tasks/new-task-planning.ts | 41 -- .../renderer/tasks/new-task-workspace.test.ts | 55 ++- .../src/renderer/tasks/use-coding-agents.ts | 53 ++- .../src/renderer/tasks/use-tasks-tab.test.ts | 86 ++++ .../src/renderer/tasks/use-tasks-tab.ts | 71 ++- apps/desktop/src/testing/tasks-control.ts | 5 + packages/ui/src/tasks-model.test.ts | 34 ++ packages/ui/src/tasks-model.ts | 37 ++ 17 files changed, 1288 insertions(+), 469 deletions(-) create mode 100644 apps/desktop/src/renderer/tasks/new-task-composer.tsx create mode 100644 apps/desktop/src/renderer/tasks/new-task-mode.ts delete mode 100644 apps/desktop/src/renderer/tasks/new-task-planning.ts diff --git a/apps/desktop/src/renderer/styles/desktop.css b/apps/desktop/src/renderer/styles/desktop.css index 06567fb912..aacae6c00b 100644 --- a/apps/desktop/src/renderer/styles/desktop.css +++ b/apps/desktop/src/renderer/styles/desktop.css @@ -7265,9 +7265,8 @@ ul.actor-stack { } /* New task (`new-task-dialog.tsx`), inside the shared dialog's wide card: - its head, its name, and its foot led by the repository chip with the - answers at its end, in one column the screen recording leaves out - (`ph-no-capture`). */ + its head and its composer card, in one column the screen recording leaves + out (`ph-no-capture`). */ .new-task { display: flex; min-height: 0; @@ -7275,8 +7274,7 @@ ul.actor-stack { gap: var(--luke-space-12); } -/* New task's head: the title and its workspace chip, then the Plan switch - at the row's end. */ +/* New task's head: the title and its workspace chip. */ .new-task-head { display: flex; min-width: 0; @@ -7293,19 +7291,6 @@ ul.actor-stack { gap: var(--luke-space-6); } -/* New task's Plan switch stands at the title's end with its word, the word - part of its label so a press on either turns it, leaving the foot's room - to the repository chip. */ -.new-task-planning { - display: inline-flex; - flex-shrink: 0; - align-items: center; - gap: var(--luke-space-6); - margin-left: auto; - color: var(--luke-color-text-muted); - user-select: none; -} - /* A picker's menu here is as wide as its widest value, within a bound, since the chip it hangs from grew to name its whole value: an owner's long workspace name reads whole in the menu, not faded at its check. @@ -7327,31 +7312,57 @@ ul.actor-stack { flex-shrink: 1; } -/* New task's foot: the repository chip at its start, the answers at its - end, a common width so a short verb is not a smaller target than Cancel. */ -.new-task-actions { +/* New task's composer (`new-task-composer.tsx`): the agent box's own card + on the dialog's inset rather than a tab's foot, the reason Agent mode's + Start is held under it. */ +.new-task-composer { display: flex; min-width: 0; - align-items: center; - gap: var(--luke-dialog-action-gap); + flex-direction: column; + gap: var(--luke-space-8); +} + +.new-task-composer .agent-composer { + padding: 0; } -.new-task-answers { +/* The Linear issue picked, over the words inside the card: drawn as a + forked draft's chip is (`.draft-fork-chip`), its key, its title cut short, + its state, and the × at its end. */ +.new-task-issue { display: flex; - flex-shrink: 0; - gap: var(--luke-dialog-action-gap); - margin-left: auto; + min-width: 0; + align-items: center; + gap: var(--luke-space-6); + margin: var(--luke-space-8) var(--luke-space-8) 0; + padding: 0 0 0 var(--luke-space-8); + border: 1px solid var(--luke-color-border); + border-radius: var(--luke-radius-8); + color: var(--luke-color-text-muted); + font-size: var(--luke-type-caption-size); } -.new-task-answers > * { - min-width: var(--luke-dialog-action-width); +.new-task-issue-key { + flex: none; + font-family: var(--luke-font-mono); } -/* Create task's chord a glyph's gap after its word, muted against its own - fill rather than the card's. */ -.new-task-answers .shortcut { - --shortcut-ink: var(--luke-color-on-accent-muted); - margin-inline-start: var(--luke-icon-gap); +.new-task-issue-title { + min-width: 0; + overflow: hidden; + color: var(--luke-color-text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.new-task-issue-state { + flex: none; + color: var(--luke-color-text-subtle); +} + +.new-task-issue-drop { + display: flex; + margin-left: auto; } /* Move to workspace (`move-task-dialog.tsx`), inside the shared dialog's diff --git a/apps/desktop/src/renderer/tasks/agent-composer.tsx b/apps/desktop/src/renderer/tasks/agent-composer.tsx index 6fb80473f3..e5ba5ce598 100644 --- a/apps/desktop/src/renderer/tasks/agent-composer.tsx +++ b/apps/desktop/src/renderer/tasks/agent-composer.tsx @@ -160,6 +160,15 @@ import type { AgentModelControl } from "./use-agent-model"; * ⋯ takes in is the stylesheet's, which it offers as it opens, read off the * foot itself; a popover it opens hangs at the foot's right, where its own * hidden press stood. + * + * New task's composer is this same box over a start rather than a message + * (`new-task-composer.tsx`): its owner names what the box asks for and + * calls its round button (`wording`), lays its Linear issue over the words + * (`above`), stands its repository and Linear chips after the "+" (`tools`) + * and its Plan toggle before the model chip (`actions`), holds Start while + * it cannot start (`sendable`), hears the keys the field leaves (`onKey`), + * says what Escape does (`onLeave`), and offers no MCP servers, so the + * box draws no servers' button. */ /** What the box's note says while the editor bundle could not be loaded. */ @@ -171,6 +180,13 @@ const PLACEHOLDER = { FOLLOW_UP: "Send a follow up", } as const; +/** What a box other than an agent's own asks for, calls its field, and calls its round button. */ +export interface ComposerWords { + readonly placeholder: string; + readonly label: string; + readonly send: string; +} + /** The buttons' names, which are their tooltips too. */ const BUTTON = { SEND: "Send", @@ -319,6 +335,14 @@ export function AgentComposer({ replyDoor, sendsEmpty = false, closedRemedy, + wording, + above, + tools, + actions, + sendable = true, + onKey, + onLeave, + focusedByOwner = false, }: { /** The agent the box writes to, which its `@` list does not offer; none for a draft not yet started. */ agentId?: string | undefined; @@ -331,8 +355,8 @@ export function AgentComposer({ readModels: () => void; /** What the agent has cost, said at the chip's menu's foot; nothing before a turn settled. */ usage: CodingAgentUsage | undefined; - /** The MCP servers the agent is handed and their switches. */ - servers: AgentServersControl; + /** The MCP servers the agent is handed and their switches; none where the box offers none, as New task's does. */ + servers: AgentServersControl | undefined; /** What the agent's model's context holds, which the ring draws; nothing before a step ended, which draws no ring. */ context?: CodingAgentContext | undefined; /** What the agent was handed, which names the brief's part of the context. */ @@ -354,6 +378,22 @@ export function AgentComposer({ sendsEmpty?: boolean; /** What mends a box closed for good, drawn after its note: Continue in new agent (`continue-agent.tsx`); none where nothing does. */ closedRemedy?: ReactNode; + /** What the box asks for, calls its field, and calls its round button, where its owner says other than an agent's box does, as New task's does. */ + wording?: ComposerWords | undefined; + /** What stands over the words inside the card, before the files attached: New task's Linear issue. */ + above?: ReactNode; + /** What stands after the "+" at the foot's left: New task's repository and Linear chips. */ + tools?: ReactNode; + /** What stands first among the foot's right-hand presses, before the model chip: New task's Plan toggle. */ + actions?: ReactNode; + /** Whether the round button may send at all, beside the words: New task's is held while Agent mode has no repository. */ + sendable?: boolean; + /** A key pressed in the field that neither its lists nor dictation took; answers whether it took it. */ + onKey?: ((event: KeyboardEvent) => boolean) | undefined; + /** What Escape in the field does where no list or dictation takes it; the field gives up the caret where none is given. */ + onLeave?: (() => void) | undefined; + /** Whether the owner hands the field focus where it stands as the box first draws, as a dialog's card does, so the field takes it itself only where its editor lands later. */ + focusedByOwner?: boolean; }): React.JSX.Element { const editor = useRef(undefined); const card = useRef(null); @@ -384,6 +424,8 @@ export function AgentComposer({ const showsStop = empty && stoppable && !composer.sending; const bundle = useAtomValue(useSideBundles().planEditor); const module = AsyncResult.isSuccess(bundle) ? bundle.value : undefined; + // Whether the editor stood as the box first drew, which an owner that focuses the field focuses. + const [mountedFirst] = useState(module !== undefined); const statuses = useIntegrationStatuses(); const grant = useIntegrationGrant(statuses); // A reconnect that could not begin says so in the note, as Settings' row does. @@ -399,7 +441,7 @@ export function AgentComposer({ const lapsed = lapsedServer({ statusOf: statuses.statusOf, accountsOf: statuses.accountsOf, - disabled: servers.disabled, + disabled: servers?.disabled ?? [], }); const unloaded = AsyncResult.isFailure(bundle) ? EDITOR_LOAD_FAILED : undefined; const words = @@ -440,7 +482,10 @@ export function AgentComposer({ // Note that a remedy finished where Luke cannot watch keeps Retry beside it, since the same send goes once it is done. const remedyWords = note === undefined ? undefined : remedyLabel(note); // Note that a closed box asks for nothing, since it takes nothing. - const placeholder = closed ? "" : followsUp ? PLACEHOLDER.FOLLOW_UP : PLACEHOLDER.FIRST; + const placeholder = closed + ? "" + : (wording?.placeholder ?? (followsUp ? PLACEHOLDER.FOLLOW_UP : PLACEHOLDER.FIRST)); + const sendLabel = wording?.send ?? BUTTON.SEND; // Note that a held box gives up the caret, because a field that takes no // words, and a Send disabled under the press, can hold none; a send made @@ -488,11 +533,12 @@ export function AgentComposer({ servers: { statuses, place: { - disabled: servers.disabled, + disabled: servers?.disabled ?? [], handed: stoppable ? SERVER_HANDED.STEERS : SERVER_HANDED.OPENS, }, }, onNote: setRefusal, + onLeave, }); const { dictation, dictating } = pickers; // A file's lines picked in a File tab on the page (⌥⌘K) are laid in here, where the caret stands. @@ -527,10 +573,18 @@ export function AgentComposer({ onSelect: () => picker.current?.click(), }, }, - { - press: OVERFLOW_PRESS.SERVERS, - action: { label: "MCP servers", icon: , onSelect: () => setServersOpen(true) }, - }, + ...(servers === undefined + ? [] + : [ + { + press: OVERFLOW_PRESS.SERVERS, + action: { + label: "MCP servers", + icon: , + onSelect: () => setServersOpen(true), + }, + }, + ]), ...(context === undefined ? [] : [ @@ -595,6 +649,7 @@ export function AgentComposer({ composer.send(); }} > + {above} pickers.field.onKey(event) || onKey?.(event) === true} onEnter={() => { if (room.current !== null) submitPromptInput(room.current); }} @@ -668,8 +725,10 @@ export function AgentComposer({ doors={doors} narrow={folds(FOLD_WIDTH.PUBLISHED)} /> + {tools}
+ {actions} {model === undefined ? null : ( )} - + {servers === undefined ? null : ( + + )} ) : ( - + 0} + disabled={held || empty || !sendable || reading > 0} /> )} diff --git a/apps/desktop/src/renderer/tasks/composer-pickers.tsx b/apps/desktop/src/renderer/tasks/composer-pickers.tsx index 023300e362..a968ad261a 100644 --- a/apps/desktop/src/renderer/tasks/composer-pickers.tsx +++ b/apps/desktop/src/renderer/tasks/composer-pickers.tsx @@ -25,7 +25,8 @@ import { useKnownNames } from "./use-known-names"; * repository's skills, written in as a skill's chip (`skill-picker.tsx`); * and the microphone records words into the box at the caret * (`use-dictation.ts`). A list that stands answers the keys it takes first, - * then dictation does, and Escape otherwise leaves the box. What a server's + * then dictation does, and Escape otherwise leaves the box, or does what its + * owner says instead (`onLeave`), as New task's closes its card. What a server's * chip says a send does with it is the box's to tell (`servers.place`). */ @@ -58,6 +59,7 @@ export function useComposerPickers({ keywords, servers, onNote, + onLeave, }: { /** The box's editor; nothing until it has mounted. */ editor: RefObject; @@ -75,6 +77,8 @@ export function useComposerPickers({ servers: { readonly statuses: IntegrationStatuses; readonly place: MentionedServersPlace }; /** Says why a dictation left nothing, or clears what the last one said. */ onNote: (note: string | undefined) => void; + /** What Escape does where no list or dictation takes it: New task's box closes its card; the box gives up the caret where none is given. */ + onLeave?: (() => void) | undefined; }): ComposerPickers { const dictation = useDictation({ editor, keywords, onNote }); const openSkill = useSkillOpener(); @@ -112,7 +116,8 @@ export function useComposerPickers({ } if (event.key !== "Escape") return false; event.stopPropagation(); - editor.current?.blur(); + if (onLeave === undefined) editor.current?.blur(); + else onLeave(); return true; }; diff --git a/apps/desktop/src/renderer/tasks/draft-agent-tab.test.ts b/apps/desktop/src/renderer/tasks/draft-agent-tab.test.ts index 2594769117..780fdb9658 100644 --- a/apps/desktop/src/renderer/tasks/draft-agent-tab.test.ts +++ b/apps/desktop/src/renderer/tasks/draft-agent-tab.test.ts @@ -579,3 +579,42 @@ test("a continue's draft names its source alone over the box, stands on a workin await tab.press("Drop the continue"); assert.equal(dropped, 1); }); + +test("a draft New task handed over sends itself once the task can start an agent, and a Start refused leaves its words in the box with why and Retry", async () => { + const handed: AgentDraft = { + ...DRAFT, + prompt: "Make the sidebar keep its folds.", + sendsOnOpen: true, + }; + const marks: unknown[] = []; + const unready = mount([], handed, { + onDraftChange: (change) => marks.push(change), + }); + await settle(); + assert.deepEqual(unready.starts, [], "nothing is sent before the task can start an agent"); + for (const root of roots.splice(0)) act(() => root.unmount()); + document.body.innerHTML = ""; + + const tab = mount( + [startFailureNote({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }, TASK_ID), undefined], + handed, + { + start: { + available: true, + reason: undefined, + busy: false, + note: undefined, + onPress: () => {}, + }, + onDraftChange: (change) => marks.push(change), + }, + ); + await settle(); + + assert.deepEqual(tab.starts, [handed.prompt]); + assert.deepEqual(marks, [{ sendsOnOpen: undefined }], "the mark is let go as it sends"); + assert.match(tab.note() ?? "", /could not be started/u); + assert.equal(tab.draft(), handed.prompt); + await tab.press("Retry"); + assert.deepEqual(tab.starts, [handed.prompt, handed.prompt]); +}); diff --git a/apps/desktop/src/renderer/tasks/draft-agent-tab.tsx b/apps/desktop/src/renderer/tasks/draft-agent-tab.tsx index 4ef0efd42c..8947e22518 100644 --- a/apps/desktop/src/renderer/tasks/draft-agent-tab.tsx +++ b/apps/desktop/src/renderer/tasks/draft-agent-tab.tsx @@ -50,6 +50,13 @@ import { useWorkingNow, WorkingLine } from "./working-time"; * once it lands the tab gives way to the new agent's, and a Start that did * not land leaves the line for the note and Retry under the box, the words * kept. + * + * A draft New task handed over (`sendsOnOpen`, `use-coding-agents.ts`'s + * `onHandDraft`) holds the words, files, and model New task's box held, and + * the tab sends it by itself as soon as the task can start an agent, once: + * the tab gives way to the agent's as it starts, and a Start the service + * refused leaves the developer on the draft with the words in the box, why + * under it, and Retry, as a send of their own would. */ /** A draft has nothing to stop and has published nothing, so its box's Stop and doors answer nothing. */ @@ -145,6 +152,16 @@ export function DraftAgentTab({ }; // A fork runs on its source's model and effort until the developer picks another. const choice = draft.choice ?? (source === undefined ? fallback : forkChoice(source)); + // A draft New task handed over sends itself once the task can start an + // agent; the mark is let go first, so a refusal waits on Retry. + const handed = draft.sendsOnOpen === true && control.start.available; + const sendHanded = useRef(send); + sendHanded.current = send; + useEffect(() => { + if (!handed) return; + latest.current.onDraftChange({ sendsOnOpen: undefined }); + sendHanded.current(); + }, [handed]); if (control.onUnarchive !== undefined) { return (
diff --git a/apps/desktop/src/renderer/tasks/new-task-composer.tsx b/apps/desktop/src/renderer/tasks/new-task-composer.tsx new file mode 100644 index 0000000000..86af0713ea --- /dev/null +++ b/apps/desktop/src/renderer/tasks/new-task-composer.tsx @@ -0,0 +1,392 @@ +import { + CODING_AGENT_STATUS, + type CodingAgentAttachment, + hasWordsOrFiles, +} from "@luke/features/coding-agent-wire"; +import { FAILURE_REASON, type Failure } from "@luke/features/failure-remedy"; +import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire"; +import type { LinearIssueSummary } from "@luke/features/linear-wire"; +import type { ModelChoice } from "@luke/features/models-wire"; +import { SHORTCUT_KEY, type ShortcutChord, shortcutMatches } from "@luke/settings/shortcuts"; +import { + CHIP_LOOK, + CHIP_WIDTH, + Chip, + ChipName, + CloseIcon, + IconButton, + PencilIcon, + ProviderMark, + TEXT_TONE, + TEXT_VARIANT, + Text, +} from "@luke/ui"; +import { + agentPromptOf, + taskNameOfIssue, + taskNameOfWords, + workspaceReaches, +} from "@luke/ui/tasks-model"; +import { useEffect, useRef, useState } from "react"; +import { PICKER_ALIGN } from "../settings/picker-row"; +import { Tooltip } from "../tooltip"; +import { AgentComposer, type ComposerWords } from "./agent-composer"; +import { LINEAR_CHORD, LinearIssueChip } from "./linear-issue-chip"; +import { NEW_TASK_MODE, type NewTaskMode, useNewTaskMode } from "./new-task-mode"; +import { CHIP_PLACE, RepositoryChip } from "./repository-chip"; +import type { AgentComposerControl } from "./use-agent-composer"; +import type { NewTaskIssue, TasksControl } from "./use-tasks-tab"; + +/** + * new-task-composer.tsx -- New task's composer card: the words a coding agent starts on, or Luke plans with, and the chips that say where and how the task starts. + * + * The card is the agent box's own (`agent-composer.tsx`): the words field, + * the "+" that attaches files, with an image pasted or files dropped taken + * too, dictation, the model chip, and the round Start button. Its foot holds + * New task's own chips beside them: after the "+", the repository the task + * is about (`repository-chip.tsx`), which starts on the one the press chose + * (a repository group's +), else the one the newest task in the workspace + * was about, either only where the workspace's GitHub reaches it, and then + * the Linear chip, where the workspace has Linear connected + * (`linear-issue-chip.tsx`, ⌘L from the card); at the right, before the model + * chip, the Plan toggle. + * + * The card is in one of two modes (`new-task-mode.ts`), kept for the next + * New task, and Shift+Tab from the field turns it as the toggle does: + * + * - Agent mode, the default: the words are a coding agent's prompt. Start + * stands once there are words, files, or a Linear issue, and a + * repository, without which it is held and the card says so under it. It + * starts the task with an empty plan and no call, named by the issue where + * one was picked and else Untitled until the agent names it, then opens + * it on a draft agent's tab holding the words, the files, and the model on + * the chip, which starts the agent at once (`draft-agent-tab.tsx`), so an + * agent refused leaves the words there with why and Retry. The model chip + * starts on the account's default, and a pick here is written as the + * default, as Start's menu writes it. + * - Plan mode: the task starts from the plan template and opens its call + * with Luke, as New task always did. Start always stands, an empty box + * starting today's blank plan, and the model chip stands down, since the + * call runs on no model of the developer's. Note that until Luke is + * handed the words as the task's brief, they name the task: their first + * line, cut to a name's bound. + * + * Choosing a Linear issue lays it over the words as a chip, its key, title, + * and state, with the × that lets it go, rather than starting anything; a + * switch of workspace lets it go too. The task is linked to it in either + * mode and named by it, Luke researches it in Plan mode as he always did, + * and in Agent mode the agent is handed a mention of it before the words + * (`agentPromptOf`), so an issue picked and Return starts an agent on it. + * + * Return starts, Shift+Return is a new line, and ⌘Return on an Agent-mode + * box with neither words nor files starts a bare task: an empty plan, no + * call, and no agent. Escape closes a menu or a dictation standing first, + * and then whatever the owner says (`onLeave`), the dialog's card. A start + * that was refused keeps the card and its words, with why under it and + * Retry. Nothing typed here reaches the model as conversation. + * + * The card holds its own words, files, issue, repository, and model, so the + * owner mounts it afresh to start over; the workspace it starts in is the + * owner's, as is a workspace's own issue it was opened from, which names + * the task and offers no Linear chip. + */ + +/** ⌘Return: a bare task from an Agent-mode box with nothing in it. */ +const BARE_CHORD: ShortcutChord = { key: SHORTCUT_KEY.ENTER }; + +/** The field's name in either mode; note that it is one name, because the editor mounts afresh on a new one. */ +const FIELD_LABEL = "Task prompt"; + +/** What the card says in each mode. */ +const MODE_WORDS = { + [NEW_TASK_MODE.AGENT]: { + placeholder: "Describe the change for a coding agent…", + label: FIELD_LABEL, + send: "Start agent", + }, + [NEW_TASK_MODE.PLAN]: { + placeholder: "What should you and Luke plan? (optional)", + label: FIELD_LABEL, + send: "Start planning", + }, +} as const satisfies Record; + +/** The Plan toggle's words, and why Agent mode's Start is held without a repository. */ +const COMPOSER_WORDS = { + PLAN: "Plan", + PLAN_LABEL: "Plan with Luke first", + PLAN_KEYS: "Shift+Tab", + DROP_ISSUE: "Remove issue", + NEEDS_REPOSITORY: "An agent needs a repository to work in. Choose one, or turn on Plan.", +} as const; + +/** Whether a key press is Shift+Tab alone, which turns the mode from the field. */ +function turnsMode(event: KeyboardEvent): boolean { + return event.key === "Tab" && event.shiftKey && !event.metaKey && !event.ctrlKey && !event.altKey; +} + +/** The Linear issue picked, over the words: its key, title, and state, and the × that lets it go. */ +function IssueChip({ + issue, + disabled, + onDrop, +}: { + issue: LinearIssueSummary; + disabled: boolean; + onDrop: () => void; +}): React.JSX.Element { + return ( +
+ + {issue.identifier} + {issue.title} + {issue.state === undefined ? null : ( + {issue.state} + )} + + + } + label={COMPOSER_WORDS.DROP_ISSUE} + disabled={disabled} + onClick={onDrop} + /> + + +
+ ); +} + +/** The account's default model as read, and the chip's pick, which is written back as the default. */ +function useDefaultChoice( + newTask: TasksControl["newTask"], +): readonly [ModelChoice | undefined, (next: ModelChoice) => void] { + const [choice, setChoice] = useState(undefined); + // Note that the control is read through a ref, because the tab hands a + // new one on every render and the default is read once, as the card opens. + const latest = useRef(newTask); + latest.current = newTask; + useEffect(() => { + let live = true; + latest.current.readDefault().then((answer) => { + if (live && !("failure" in answer)) setChoice((picked) => picked ?? answer.choice); + }); + return () => { + live = false; + }; + }, []); + // A pick names the chip at once and is kept as the default, as Start's menu keeps it. + const choose = (next: ModelChoice) => { + setChoice(next); + void latest.current.writeDefault(next); + }; + return [choice, choose]; +} + +export function NewTaskComposer({ + newTask, + workspaceId, + from, + onStarting, + onLeave, + focusedByOwner = false, +}: { + newTask: TasksControl["newTask"]; + /** The workspace the task starts in, the owner's to choose; none before the workspaces are read. */ + workspaceId: string | undefined; + /** The workspace's own issue the card was opened on, which names the task and links it; none otherwise. */ + from: NewTaskIssue | undefined; + /** Hears whether a start is out, during which the owner holds what it draws around the card. */ + onStarting?: ((starting: boolean) => void) | undefined; + /** What Escape in the field does where nothing in the card takes it. */ + onLeave: () => void; + /** Whether the owner hands the words focus as the card first draws, as the dialog does; the words take it themselves otherwise. */ + focusedByOwner?: boolean; +}): React.JSX.Element { + const [mode, setMode] = useNewTaskMode(); + const [words, setWords] = useState(""); + const [attachments, setAttachments] = useState([]); + const [issue, setIssue] = useState(undefined); + const [chosen, setChosen] = useState(newTask.preset?.repository); + const [choice, choose] = useDefaultChoice(newTask); + const [starting, setStarting] = useState(false); + const [note, setNote] = useState(undefined); + // How many times ⌘L asked the Linear chip to open its menu. + const [linearAsks, setLinearAsks] = useState(0); + // 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 repositories = newTask.repositoriesIn(workspaceId); + const workspace = newTask.workspaces.find((each) => each.id === workspaceId); + // A repository or an issue picked in one workspace is none of another's, + // so a switch lets them go, in the render that brings it; the scope first + // resolving keeps a pick made before the workspaces were read. + const [scope, setScope] = useState(repositories.scope); + if (repositories.scope !== scope) { + if (scope !== undefined) { + setChosen(undefined); + setIssue(undefined); + } + setScope(repositories.scope); + } + // Note that a press's repository the workspace's GitHub does not reach is + // not started on, because the service would refuse it. + const pick = + chosen != null && workspace !== undefined && !workspaceReaches(workspace, chosen) + ? null + : chosen; + const repository = pick === undefined ? (repositories.recent[0] ?? null) : pick; + const planning = mode === NEW_TASK_MODE.PLAN; + const said = words.trim(); + const filled = hasWordsOrFiles(said, attachments); + // Agent mode needs a repository to work in, and something to say: words, files, or an issue. + const blocked = !planning && repository === null; + const canStart = planning || (!blocked && (filled || issue !== undefined)); + const issueName = issue === undefined ? from?.title : taskNameOfIssue(issue); + + const begin = (name: string, asPlan: boolean, agent: boolean) => { + if (starting) return; + setStarting(true); + onStarting?.(true); + setNote(undefined); + newTask + .start( + name, + repository, + workspaceId, + asPlan, + { + ...(issue === undefined ? undefined : { linearIssueId: issue.id }), + ...(from === undefined ? undefined : { issueId: from.id }), + }, + agent ? { prompt: agentPromptOf(issue, said), attachments, choice } : undefined, + ) + .then((refused) => { + setNote( + refused === undefined ? undefined : { reason: FAILURE_REASON.OTHER, text: refused }, + ); + }) + .finally(() => { + setStarting(false); + onStarting?.(false); + }); + }; + const start = () => { + if (!canStart) return; + if (planning) begin(issueName ?? taskNameOfWords(said), true, false); + else begin(issueName ?? "", false, true); + }; + // ⌘Return on an Agent-mode box with nothing in it starts a bare task: no call and no agent. + const bare = () => begin(issueName ?? "", false, false); + + const onKey = (event: KeyboardEvent): boolean => { + if (turnsMode(event)) { + // Note that the dialog's card traps Tab, so the turn is the field's alone. + event.preventDefault(); + event.stopPropagation(); + setMode(planning ? NEW_TASK_MODE.AGENT : NEW_TASK_MODE.PLAN); + return true; + } + if (!shortcutMatches(BARE_CHORD, event)) return false; + if (planning || filled) start(); + else bare(); + return true; + }; + // ⌘L from the card opens the Linear chip's menu, where the chip stands. + const onCardKey = (event: React.KeyboardEvent) => { + if (starting || linearIssuesIn(workspaceId) === undefined) return; + if (!shortcutMatches(LINEAR_CHORD, event)) return; + event.preventDefault(); + setLinearAsks((asks) => asks + 1); + }; + + const composer: AgentComposerControl = { + draft: words, + setDraft: (next) => { + setWords(next); + setNote(undefined); + }, + attachments, + setAttachments, + sending: starting, + note, + closed: undefined, + send: start, + retry: start, + sent: [], + }; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: ⌘L is read as it bubbles from the card's own controls, the field first, which are what take the keys. +
+ undefined, copy: () => undefined }} + onStop={() => Promise.resolve()} + followsUp={false} + onKey={onKey} + onLeave={onLeave} + focusedByOwner={focusedByOwner} + above={ + issue === undefined ? undefined : ( + setIssue(undefined)} /> + ) + } + tools={ + <> + setChosen(null)} + /> + { + setIssue(picked); + setNote(undefined); + }} + /> + + } + actions={ + + setMode(planning ? NEW_TASK_MODE.AGENT : NEW_TASK_MODE.PLAN)} + > + + {COMPOSER_WORDS.PLAN} + + + } + /> + {blocked ? ( + + {COMPOSER_WORDS.NEEDS_REPOSITORY} + + ) : 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 5d2a83def8..2b00c5d38c 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts @@ -2,31 +2,68 @@ import assert from "node:assert/strict"; import type { LinearIssueSummary } from "@luke/features/linear-wire"; +import { type CatalogModel, MODEL_PROVIDER, type ModelChoice } from "@luke/features/models-wire"; import { ACT_OUTCOME_STATUS } from "@luke/host/acts"; import { SETTINGS_SECTION, SETTINGS_VIEW, type SettingsLink } from "@luke/settings/settings-views"; import type { LinearIssuesAsk } from "@luke/ui/tasks-model"; +import { Effect } from "effect"; import { act, createElement, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, test, vi } from "vitest"; +import { afterEach, beforeAll, beforeEach, test, vi } from "vitest"; import { fakeAppBridge } from "#testing/app-bridge"; +import { + composerEditor, + editorBundleLanded, + installEditorLayout, + pressInComposer, + typeInComposer, +} from "#testing/composer"; import { installScrollIntoView } from "#testing/scroll-into-view"; import { tasksControl } from "#testing/tasks-control"; import { OpenSettingsProvider } from "../open-settings"; import { NewTaskDialog } from "./new-task-dialog"; import type { RepositoryChooser } from "./repository-chip"; -import type { NewTaskPreset } from "./use-tasks-tab"; +import type { NewTaskAgent, NewTaskPreset } from "./use-tasks-tab"; /** * New task as the developer meets it: a card over the window holding the - * task's name, the repository chip, Cancel, and Create task. Each test + * workspace it starts in and the composer, whose words start a coding agent + * in Agent mode, or the plan and a call with Luke in Plan mode, with the + * repository and Linear chips and the Plan toggle at its foot. Each test * stands the dialog in an owner that opens it and puts it away as the tab * does, so closing is read as the card leaving the window. */ const roots: Root[] = []; +/** The key New task keeps its mode under, which a test sets to stand the card in Plan mode. */ +const MODE_KEY = "luke.new-task-mode"; + +/** The models the composer's chip offers: Opus, and its fast version. */ +const MODELS: readonly CatalogModel[] = [ + { + id: "anthropic/claude-opus-5.5", + name: "Claude Opus 5.5", + provider: MODEL_PROVIDER.ANTHROPIC, + efforts: ["low", "high"], + }, + { + id: "anthropic/claude-opus-5.5-fast", + name: "Claude Opus 5.5 (Fast)", + provider: MODEL_PROVIDER.ANTHROPIC, + efforts: ["low", "high"], + }, +]; + +/** The account's default model, as the composer's chip reads it. */ +const DEFAULT_CHOICE: ModelChoice = { model: "anthropic/claude-opus-5.5", effort: "high" }; + +// Landed before any card renders, as the panel lands it after its first paint. +beforeAll(() => Effect.runPromise(editorBundleLanded)); + beforeEach(() => { installScrollIntoView(); + installEditorLayout(); fakeAppBridge({ act: () => Promise.resolve({ status: ACT_OUTCOME_STATUS.UNKNOWN_ACT }), }); @@ -45,11 +82,16 @@ async function settle(): Promise { }); } +/** The card stood in Plan mode, as New task keeps the mode it was left in. */ +function inPlanMode(): void { + window.localStorage.setItem(MODE_KEY, "plan"); +} + /** * The dialog open in its owner, a start answered by `answer` and each - * start's name and repository written down; `preset` is what the press - * that opened it chose, a repository as a repository group's + does or an - * issue as its Start task… does, `issues` + * start's name and repository written down, with the agent it asked for; + * `preset` is what the press that opened it chose, a repository as a + * repository group's + does or an issue as its Start task… does, `issues` * the workspace's Linear issues where it has Linear connected, and `finds` * what its Linear answers a search or an identifier with. */ @@ -63,6 +105,8 @@ async function mount( const starts: [string, string | null][] = []; const asks: LinearIssuesAsk[] = []; const planned: boolean[] = []; + const agents: (NewTaskAgent | undefined)[] = []; + const defaults: ModelChoice[] = []; const fromIssues: (string | undefined)[] = []; const fromWorkspaceIssues: (string | undefined)[] = []; const startedIn: (string | undefined)[] = []; @@ -81,15 +125,23 @@ async function mount( open, preset, repositoriesIn: () => repositories, + models: MODELS, + readDefault: () => Promise.resolve({ choice: DEFAULT_CHOICE }), + writeDefault: (choice: ModelChoice) => { + defaults.push(choice); + return Promise.resolve({ choice }); + }, start: ( name: string, repository: string | null, workspaceId: string | undefined, planning: boolean, from?: { readonly linearIssueId?: string; readonly issueId?: string }, + agent?: NewTaskAgent, ) => { starts.push([name, repository]); planned.push(planning); + agents.push(agent); fromIssues.push(from?.linearIssueId); fromWorkspaceIssues.push(from?.issueId); startedIn.push(workspaceId); @@ -122,14 +174,10 @@ async function mount( act(() => root.render(createElement(Owner))); await settle(); const dialog = () => document.querySelector('[role="dialog"][aria-modal="true"]'); - const field = () => { - const found = document.querySelector('input[aria-label="Task name"]'); - assert.ok(found, "the name field is drawn"); - return found; - }; + const field = (): HTMLElement => composerEditor(document); const button = (label: string) => { - const found = [...document.querySelectorAll("button")].find((each) => - each.textContent?.startsWith(label), + const found = [...document.querySelectorAll("button")].find( + (each) => each.getAttribute("aria-label") === label || each.textContent?.startsWith(label), ); assert.ok(found, `${label} is drawn`); return found; @@ -152,18 +200,22 @@ async function mount( startedIn, /** Whether each start asked for its planning phase. */ planned, + /** The coding agent each start asked for, where it asked for one. */ + agents, + /** Every model the chip kept as the account's default. */ + defaults, /** Every Settings link the dialog's presses followed. */ links, opener, dialog, field, button, - type: (words: string) => { - const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; - act(() => { - setValue?.call(field(), words); - field().dispatchEvent(new Event("input", { bubbles: true })); - }); + /** Words typed into the composer at its caret. */ + type: (words: string) => typeInComposer(document, words), + /** A key pressed in the composer's field. */ + key: async (key: string, held: KeyboardEventInit = {}) => { + pressInComposer(document, key, held); + await settle(); }, press, /** Stands the dialog again over another chooser, as a workspace switch or the first workspaces read hands it. */ @@ -173,102 +225,179 @@ async function mount( await settle(); }, note: () => document.querySelector('[role="alert"]')?.textContent ?? undefined, - /** The card's controls in order, as a reader meets them. */ + /** What the composer asks for while its box is empty. */ + placeholder: () => document.querySelector(".agent-composer-placeholder")?.textContent, + /** The round Start button, by the name each mode gives it. */ + startButton: () => + document.querySelector( + 'button[aria-label="Start agent"], button[aria-label="Start planning"]', + ) ?? assert.fail("the round Start button stands"), + /** The card's buttons in order, as a reader meets them. */ controls: () => - [...(dialog()?.querySelectorAll("input, button") ?? [])].map( - (each) => each.getAttribute("aria-label") ?? each.textContent, - ), + [...(dialog()?.querySelectorAll("button") ?? [])] + .filter((each) => getComputedStyle(each).display !== "none") + .map((each) => each.getAttribute("aria-label") ?? each.textContent), }; } -test("the dialog holds the Plan switch, the name, then the repository chip leading Cancel and Create plan, with the name taking focus", async () => { +/** The Plan toggle, pressed where `planning` is. */ +function planToggle(): HTMLButtonElement { + return ( + document.querySelector('button[aria-label="Plan with Luke first"]') ?? + assert.fail("the Plan toggle stands") + ); +} + +test("the card holds the composer with its words focused, the repository chip after the +, and the Plan toggle, the model chip on the account's default, and Start at its foot", async () => { const card = await mount(); - assert.deepEqual(card.controls(), [ - "Plan with Luke", - "Task name", - "acme/web", - "Cancel", - "Create task⏎", - ]); + const controls = card.controls(); + assert.equal(document.activeElement, card.field()); + assert.equal(card.placeholder(), "Describe the change for a coding agent…"); + for (const each of ["Attach files", "acme/web", "Plan with Luke first", "Start agent"]) { + assert.ok(controls.includes(each), `${each} stands in the card`); + } + assert.ok( + controls.indexOf("acme/web") < controls.indexOf("Plan with Luke first"), + "the repository chip leads the foot", + ); assert.equal( - card.dialog()?.textContent?.includes("Repository"), - false, - "the chip stands unlabelled", + document.querySelector(".agent-model-chip")?.textContent?.includes("Opus"), + true, + "the model chip names the account's default", ); + assert.equal(planToggle().getAttribute("aria-pressed"), "false", "New task starts in Agent mode"); }); -test("Return creates the plan under the name as typed, trimmed, about the repository last used", async () => { +test("Agent mode: Return starts the task without planning, Untitled, on the repository last used, and an agent on the words with the chip's model", async () => { const card = await mount(); - card.type(" Dark mode toggle "); - await card.press("Enter", card.field()); - assert.deepEqual(card.starts, [["Dark mode toggle", "acme/web"]]); -}); - -test("the name is optional: with none, or only whitespace, Return and Create task each start the plan unnamed", async () => { - const card = await mount("refused"); - assert.equal(card.field().placeholder, "Task name (optional)"); - assert.equal(card.button("Create task").disabled, false); - await card.press("Enter", card.field()); - card.type(" "); - act(() => card.button("Create task").click()); - await settle(); - assert.deepEqual(card.starts, [ - ["", "acme/web"], - ["", "acme/web"], + assert.equal(card.startButton().disabled, true, "an empty box starts nothing"); + card.type(" Make the sidebar keep its folds. "); + await card.key("Enter"); + + assert.deepEqual(card.starts, [["", "acme/web"]]); + assert.deepEqual(card.planned, [false]); + assert.deepEqual(card.agents, [ + { prompt: "Make the sidebar keep its folds.", attachments: [], choice: DEFAULT_CHOICE }, ]); }); -test("Plan starts on, a task started with it off starts without planning, and the next New task offers it as it was left", async () => { - const first = await mount(); - const planSwitch = () => { - const found = document.querySelector('[role="switch"]'); - assert.ok(found, "the Plan switch is drawn"); - return found; - }; - assert.equal(planSwitch().getAttribute("aria-checked"), "true"); - act(() => first.button("Create task").click()); +test("a model picked on the chip is kept as the account's default, and the agent starts on it", async () => { + const card = await mount(); + const chip = + document.querySelector(".agent-model-chip button") ?? + assert.fail("the model chip stands"); + act(() => chip.click()); + const fast = + document.querySelector('[role="switch"]') ?? + assert.fail("the menu offers Fast"); + act(() => fast.click()); await settle(); - const label = document.querySelector(`label[for="${planSwitch().id}"]`); - assert.ok(label, "the switch has a visible label"); - act(() => label.dispatchEvent(new MouseEvent("click", { bubbles: true }))); - assert.equal(planSwitch().getAttribute("aria-checked"), "false", "a press on its word turns it"); - act(() => first.button("Create task").click()); + card.type("Speed up the build"); + await card.key("Enter"); + + const picked = card.defaults.at(-1); + assert.equal(picked?.model, "anthropic/claude-opus-5.5-fast"); + assert.deepEqual(card.agents[0]?.choice, picked); +}); + +test("Shift+Return is a new line, and the agent is handed both lines", async () => { + const card = await mount(); + card.type("Fix the refresh\nand add a test"); + await card.key("Enter"); + + assert.deepEqual(card.starts.length, 1); + assert.equal(card.agents[0]?.prompt, "Fix the refresh\nand add a test"); +}); + +test("Plan mode: Shift+Tab turns it on, the model chip stands down, and Start begins the plan and its call, named by the words' first line", async () => { + const card = await mount(); + await card.key("Tab", { shiftKey: true }); + const placeholder = card.placeholder(); + const modelChip = document.querySelector(".agent-model-chip"); + card.type("Rethink automations\nwhich agent they reuse"); + await card.key("Enter"); + + assert.equal(planToggle().getAttribute("aria-pressed"), "true"); + assert.equal(placeholder, "What should you and Luke plan? (optional)"); + assert.equal(modelChip, null); + assert.deepEqual(card.starts, [["Rethink automations", "acme/web"]]); + assert.deepEqual(card.planned, [true]); + assert.deepEqual(card.agents, [undefined], "no agent is started"); +}); + +test("Plan mode starts with nothing typed and with no repository, as today's blank plan", async () => { + inPlanMode(); + const card = await mount(undefined, undefined, { recent: [] }); + assert.equal(card.startButton().disabled, false); + act(() => card.startButton().click()); await settle(); - assert.deepEqual(first.planned, [true, false]); - for (const root of roots.splice(0)) act(() => root.unmount()); - document.body.innerHTML = ""; - const next = await mount(); - assert.equal(planSwitch().getAttribute("aria-checked"), "false"); - await next.press("Enter", next.field()); - assert.deepEqual(next.planned, [false]); + assert.deepEqual(card.starts, [["", null]]); + assert.deepEqual(card.planned, [true]); }); -test("a repository group's + opens the dialog on its repository rather than the last used", async () => { - const card = await mount(undefined, { repository: "acme/api" }); - card.type("Rate limits"); - act(() => card.button("Create task").click()); +test("Agent mode with no repository holds Start and says why under the card, and turning on Plan lets it go", async () => { + const card = await mount(undefined, undefined, { recent: [] }); + card.type("Write the migration"); + await card.key("Enter"); + const held = { + disabled: card.startButton().disabled, + why: card + .dialog() + ?.textContent?.includes( + "An agent needs a repository to work in. Choose one, or turn on Plan.", + ), + }; + act(() => planToggle().click()); await settle(); - assert.deepEqual(card.starts, [["Rate limits", "acme/api"]]); + const why = card.dialog()?.textContent?.includes("An agent needs a repository"); + await card.key("Enter"); + + assert.deepEqual(held, { disabled: true, why: true }); + assert.equal(why, false); + assert.deepEqual(card.starts, [["Write the migration", null]]); + assert.deepEqual(card.planned, [true]); }); -test("Escape and Cancel each put the dialog away with nothing started, and focus goes back to what opened it", async () => { - const escaped = await mount(); - escaped.type("Dark mode"); - await escaped.press("Escape", escaped.field()); - assert.equal(escaped.dialog(), null); - assert.equal(document.activeElement, escaped.opener); - assert.deepEqual(escaped.starts, []); +test("the mode is kept for the next New task, and Agent mode stands where only the old Plan switch's choice was kept", async () => { + window.localStorage.setItem("luke.new-task-planning", "true"); + const first = await mount(); + const firstMode = planToggle().getAttribute("aria-pressed"); + act(() => planToggle().click()); for (const root of roots.splice(0)) act(() => root.unmount()); document.body.innerHTML = ""; - const cancelled = await mount(); - cancelled.type("Dark mode"); - act(() => cancelled.button("Cancel").click()); - await settle(); - assert.equal(cancelled.dialog(), null); - assert.deepEqual(cancelled.starts, []); + await mount(); + + assert.equal(firstMode, "false"); + assert.equal(planToggle().getAttribute("aria-pressed"), "true"); + assert.ok(first); +}); + +test("Command-Return on an empty Agent-mode box starts a bare task: no planning and no agent", async () => { + const card = await mount(undefined, undefined, { recent: [] }); + await card.key("Enter", { metaKey: true }); + + assert.deepEqual(card.starts, [["", null]]); + assert.deepEqual(card.planned, [false]); + assert.deepEqual(card.agents, [undefined]); +}); + +test("a repository group's + opens the card on its repository rather than the last used", async () => { + const card = await mount(undefined, { repository: "acme/api" }); + card.type("Rate limits"); + await card.key("Enter"); + assert.deepEqual(card.starts, [["", "acme/api"]]); +}); + +test("Escape puts the card away with nothing started, and focus goes back to what opened it", async () => { + const card = await mount(); + card.type("Dark mode"); + await card.key("Escape"); + assert.equal(card.dialog(), null); + assert.equal(document.activeElement, card.opener); + assert.deepEqual(card.starts, []); }); test("Escape in the repository menu closes the menu and leaves the dialog standing", async () => { @@ -284,16 +413,16 @@ test("Escape in the repository menu closes the menu and leaves the dialog standi assert.ok(card.dialog(), "the dialog still stands"); }); -test("a refusal keeps the dialog and the name, says why under the field, and Create plan can be pressed again", async () => { +test("a refusal keeps the card and the words, says why under it, and Retry starts again", async () => { const card = await mount("Luke can't reach acme/web."); card.type("Dark mode toggle"); - act(() => card.button("Create task").click()); - await settle(); - assert.deepEqual(card.starts, [["Dark mode toggle", "acme/web"]]); + await card.key("Enter"); assert.ok(card.dialog()); - assert.equal(card.note(), "Luke can't reach acme/web."); - assert.equal(card.field().value, "Dark mode toggle"); - assert.equal(card.button("Create task").disabled, false); + assert.match(card.note() ?? "", /Luke can't reach acme\/web\./u); + assert.equal(card.field().textContent, "Dark mode toggle"); + act(() => card.button("Retry").click()); + await settle(); + assert.equal(card.starts.length, 2); }); test("a workspace switch lets the pick go for the new workspace's recent repository", async () => { @@ -308,9 +437,9 @@ test("a workspace switch lets the pick go for the new workspace's recent reposit dialog.type("Dark mode"); await dialog.rechoose({ scope: "workspace-acme", recent: ["acme/relay"] }); - await dialog.press("Enter", dialog.field()); + await dialog.key("Enter"); - assert.deepEqual(dialog.starts, [["Dark mode", "acme/relay"]]); + assert.deepEqual(dialog.starts, [["", "acme/relay"]]); }); test("the workspace first resolving keeps a repository picked before the workspaces were read", async () => { @@ -325,9 +454,9 @@ test("the workspace first resolving keeps a repository picked before the workspa dialog.type("Dark mode"); await dialog.rechoose({ scope: "workspace-personal" }); - await dialog.press("Enter", dialog.field()); + await dialog.key("Enter"); - assert.deepEqual(dialog.starts, [["Dark mode", "alice/private"]]); + assert.deepEqual(dialog.starts, [["", "alice/private"]]); }); /** The lines the repository menu says in its rows' place. */ @@ -431,7 +560,8 @@ test("a member of a workspace whose GitHub was removed or suspended on GitHub is }); }); -test("in a workspace with no GitHub connected, a repository its plans still name is neither offered nor chosen, the chip reads No repository, and Create starts the plan with none", async () => { +test("in a workspace with no GitHub connected, a repository its plans still name is neither offered nor chosen, the chip reads No repository, and Plan mode starts the task with none", async () => { + inPlanMode(); const dialog = await mount(undefined, undefined, { scope: "workspace-personal", workspaceName: "Dean Stratakos", @@ -447,7 +577,7 @@ test("in a workspace with no GitHub connected, a repository its plans still name assert.fail("the chip's menu opens"); await dialog.press("Escape", search); dialog.type("Tmp"); - await dialog.press("Enter", dialog.field()); + await dialog.key("Enter"); assert.deepEqual(notes, ["Dean Stratakos has no GitHub connected yet."]); assert.equal( @@ -477,21 +607,23 @@ test("a pick the workspace's GitHub turns out not to list is let go, while one i installationUrl: "https://github.com/apps/luke/installations/new", }, }); + inPlanMode(); const unlisted = await mount(undefined, undefined, { read: listing }); unlisted.type("Dark mode"); - await unlisted.press("Enter", unlisted.field()); + await unlisted.key("Enter"); for (const root of roots.splice(0)) act(() => root.unmount()); document.body.innerHTML = ""; const listed = await mount(undefined, { repository: "acme/api" }, { read: listing }); listed.type("Rate limits"); - await listed.press("Enter", listed.field()); + await listed.key("Enter"); assert.deepEqual(unlisted.starts, [["Dark mode", null]]); assert.deepEqual(listed.starts, [["Rate limits", "acme/api"]]); }); test("the repository menu names each repository first and its owner after it, so two that share an owner tell apart, and finds one by its owner/name", async () => { + inPlanMode(); const owner = "northwind-platform"; const reached = (name: string) => ({ owner, @@ -536,7 +668,7 @@ test("the repository menu names each repository first and its owner after it, so }); await dialog.press("Enter", search); dialog.type("Ledger"); - await dialog.press("Enter", dialog.field()); + await dialog.key("Enter"); assert.deepEqual(dialog.starts, [["Ledger", `${owner}/billing-ledger-and-invoicing-pipeline`]]); }); @@ -628,20 +760,24 @@ const OLDER_ISSUE: LinearIssueSummary = { state: "Backlog", }; -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 () => { +/** The Linear issue laid over the words, as its chip reads, or nothing where none is. */ +function issueChip(): string | undefined { + return document.querySelector("[data-issue-chip]")?.textContent ?? undefined; +} + +test("with Linear connected, typing in the box lists no issues and searches no Linear", 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()); + await card.key("Enter"); - 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]); + assert.equal(card.agents[0]?.prompt, "stack"); } finally { vi.useRealTimers(); } @@ -654,24 +790,24 @@ test("the Linear chip stands after the repository chip only where the workspace 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( + withLinear.indexOf("Choose from Linear"), + withLinear.indexOf("acme/web") + 1, + "the Linear chip follows the repository chip", + ); 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 () => { +test("choosing an issue from the Linear chip lays it over the words as a chip and starts nothing; Return then starts an agent on a mention of it and the words, the task named by it and linked to it", 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); + const chosen = { chip: issueChip(), starts: card.starts.length }; + card.type("Reproduce it first."); + await card.key("Enter"); assert.deepEqual(listed, { rows: [ @@ -682,8 +818,44 @@ test("the Linear chip lists the issues read as the card opened, the arrows move, highlighted: "Restack the deploy queueENG-1 · Todo", }); assert.equal(moved, "Add support for stacked PRsENG-2 · In Progress"); + assert.deepEqual(chosen, { chip: "ENG-2Add support for stacked PRsIn Progress", starts: 0 }); assert.deepEqual(card.starts, [["Add support for stacked PRs", "acme/web"]]); + assert.deepEqual(card.planned, [false]); assert.deepEqual(card.fromIssues, ["issue-2"]); + assert.equal( + card.agents[0]?.prompt, + "[@ENG-2 Add support for stacked PRs](https://linear.app/acme/issue/ENG-2)\n\nReproduce it first.", + ); +}); + +test("an issue picked and Return with no words starts an agent on the issue alone; its × lets it go, and Plan mode hands it to Luke as before", async () => { + const card = await mount(undefined, undefined, {}, ISSUES); + await openLinear(); + await card.press("Enter", linearSearch()); + await card.key("Enter"); + const dropped = await (async () => { + await openLinear(); + await card.press("Enter", linearSearch()); + act(() => card.button("Remove issue").click()); + await settle(); + return issueChip(); + })(); + await openLinear(); + await card.press("Enter", linearSearch()); + act(() => planToggle().click()); + await settle(); + act(() => card.startButton().click()); + await settle(); + + assert.equal( + card.agents[0]?.prompt, + "[@ENG-1 Restack the deploy queue](https://linear.app/acme/issue/ENG-1)", + ); + assert.equal(dropped, undefined); + assert.deepEqual(card.starts.at(-1), ["Restack the deploy queue", "acme/web"]); + assert.deepEqual(card.planned.at(-1), true); + assert.deepEqual(card.fromIssues.at(-1), "issue-1"); + assert.equal(card.agents.at(-1), undefined); }); 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 () => { @@ -719,7 +891,7 @@ test("typing in the Linear search narrows the issues at once, and once it rests } }); -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 () => { +test("a link to an issue pasted in the Linear search asks Linear for that one issue, which is offered first and chosen by Return", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { const card = await mount(undefined, undefined, {}, ISSUES, (ask) => @@ -733,14 +905,14 @@ test("a link to an issue pasted in the Linear search asks Linear for that one is 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"]); + assert.equal(issueChip(), "ENG-301Queue jumps on retryBacklog"); + assert.deepEqual(card.starts, []); } finally { vi.useRealTimers(); } }); -test("Escape closes the Linear search and leaves the card with focus on the chip, and a second Escape puts the card away", async () => { +test("Escape closes the Linear search and leaves the card with focus on the chip, and Escape from the words puts the card away", async () => { const card = await mount(undefined, undefined, {}, ISSUES); const field = await openLinear(); await card.press("Escape", field); @@ -749,16 +921,16 @@ test("Escape closes the Linear search and leaves the card with focus on the chip card: card.dialog() !== null, focus: document.activeElement?.getAttribute("aria-label"), }; - await card.press("Escape", card.field()); + await card.key("Escape"); 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, and Escape hands focus back to the name", async () => { +test("Command-L from the words opens the Linear search, and Escape hands focus back to the words", async () => { const card = await mount(undefined, undefined, {}, ISSUES); - await card.press("l", card.field(), { metaKey: true }); + await card.key("l", { metaKey: true }); const opened = document.activeElement === linearSearch(); await card.press("Escape", linearSearch()); @@ -784,11 +956,11 @@ test("opened on one of the workspace's issues, the card says so, starts named by const card = await mount(undefined, { issue }, {}, [linear]); assert.match(card.dialog()?.querySelector("h2")?.textContent ?? "", /^New task from ACME-7\b/); - assert.equal(card.field().value, "Invites expire"); assert.equal(document.querySelector(LINEAR_CHIP), null, "no Linear chip is offered"); - await card.press("l", card.field(), { metaKey: true }); + await card.key("l", { metaKey: true }); assert.equal(document.querySelector(LINEAR_SEARCH), null, "Command-L opens nothing"); - await card.press("Enter", card.field()); + card.type("Make them last a week"); + await card.key("Enter"); assert.deepEqual(card.starts, [["Invites expire", "acme/web"]]); assert.deepEqual(card.fromWorkspaceIssues, ["issue-1"]); diff --git a/apps/desktop/src/renderer/tasks/new-task-dialog.tsx b/apps/desktop/src/renderer/tasks/new-task-dialog.tsx index d216c209e0..1f5384dd24 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.tsx @@ -1,106 +1,48 @@ -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 { - SHORTCUT_KEY, - type ShortcutChord, - shortcutAria, - shortcutMatches, -} from "@luke/settings/shortcuts"; -import { - BUTTON_VARIANT, - Button, - CHIP_WIDTH, - DIALOG_SIZE, - Dialog, - ErrorState, - Switch, - TEXT_ELEMENT, - TEXT_VARIANT, - Text, - TextField, -} from "@luke/ui"; -import { taskNameOfIssue, workspaceReaches } from "@luke/ui/tasks-model"; -import { useId, useRef, useState } from "react"; +import { CHIP_WIDTH, DIALOG_SIZE, Dialog, TEXT_ELEMENT, TEXT_VARIANT, Text } from "@luke/ui"; +import { type RefObject, useId, useMemo, useRef, useState } from "react"; import { WorkspaceTile } from "../desktop/workspace-tile"; import { PICKER_ALIGN, Picker } from "../settings/picker-row"; -import { Shortcut } from "../shortcut"; -import { Tooltip } from "../tooltip"; -import { LINEAR_CHORD, LinearIssueChip } from "./linear-issue-chip"; -import { useNewTaskPlanning } from "./new-task-planning"; -import { CHIP_PLACE, RepositoryChip } from "./repository-chip"; +import { NewTaskComposer } from "./new-task-composer"; import type { TasksControl } from "./use-tasks-tab"; /** - * new-task-dialog.tsx -- New task as a small modal over the window: the task's name, the GitHub repository it is about, Cancel and Create task. + * new-task-dialog.tsx -- New task as a small modal over the window: where the task starts, and the composer card whose words start a coding agent on it, or a plan with Luke. * * The window asks the way Linear's and Notion's "New …" ask: it dims over - * whatever stood open, and a card holds the one field and the repository - * chip. Its title says where the task will start, "New task in Acme", the - * workspace a chip there that chooses another, as Linear's team chip and - * GitHub's Owner do; the repository chip then lists that workspace's - * repositories. Both chips are the app's one picker, Settings' bordered chip - * and its menu, which hangs from the chip's leading edge here because each - * chip leads its line. The repository chip leads the card's foot with Cancel - * and Create task at its end, as the feedback dialog's screenshot button - * leads its own. The field - * takes focus, Tab stays inside the card, and focus goes back to whatever - * opened it. The name is optional: a plan started with none stands as - * "Untitled" until the developer renames it. The name is one - * line, so Return creates the plan with any modifier held; Escape, Cancel, and a press on the dimmed window put the - * card away with nothing started, its words with it. Nothing typed here - * reaches the model as conversation. The repository starts on the one the - * press chose (a repository group's +), else the one the newest task in the - * workspace was about, either only where the workspace's GitHub reaches it; - * the chip offers the other recent ones, a search over every repository the - * workspace's GitHub reaches, and the page where the developer chooses which - * ones Luke can see. A workspace with no GitHub connected offers none, and - * the task starts with none, to be given one later. A - * refusal keeps the card with the reason under the field, and Create task - * can be pressed again; a plan started closes the card and opens as the - * host's open plan, its call with it. The Plan switch at the title's end starts - * the task without its planning phase where it is turned off: an empty plan - * rather than the template, and no call. The switch is kept as it was left - * for the next New task (`new-task-planning.ts`). + * whatever stood open, and a card holds the title and New task's composer + * (`new-task-composer.tsx`), which takes focus. Its title says where the + * task will start, "New task in Acme", the workspace a chip there that + * chooses another, as Linear's team chip and GitHub's Owner do: the app's + * one picker, Settings' bordered chip and its menu, hung from the chip's + * leading edge. The composer then lists that workspace's repositories and + * its Linear issues, and a switch lets go of a repository or an issue + * picked in the other. * - * 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. + * What the composer starts is its own to say: in Agent mode a task with an + * empty plan and a coding agent on the words, opened on the agent's tab; in + * Plan mode the plan template and a call with Luke. Tab stays inside the + * card, and focus goes back to whatever opened it. Escape, with no menu or + * dictation standing in the card, and a press on the dimmed window put the + * card away with nothing started, its words with it; a start under way + * holds the card. A refusal keeps the card with the reason under the + * composer, and Start can be pressed again; a task started closes the card + * and opens as the host's open task. * * 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 it offers no Linear chip; the - * task it starts is linked to that issue, which the issue's page lists it - * under. + * says so in its title, starts in the issue's workspace and no other, names + * the task by the issue, and offers no Linear chip; the task it starts is + * linked to that issue, which the issue's page lists it under. */ -/** Create task's chord: Return alone, from the name field. */ -const CREATE_CHORD: ShortcutChord = { key: SHORTCUT_KEY.ENTER, bare: true }; - -/** What the name field says before anything is typed. */ -const NAME_PLACEHOLDER = "Task name (optional)"; - /** The card's title, before the workspace's chip: the issue it starts from where there is one, and "in" where the chip stands after it. */ function newTaskHeading(issue: string | undefined, inWorkspace: boolean): string { const heading = issue === undefined ? "New task" : `New task from ${issue}`; return inWorkspace ? `${heading} in` : heading; } -/** What the Plan switch's description says the start will do, by its state. */ -const PLANNING_NOTE = { - ON: "Starts from your plan template and opens a call with Luke.", - OFF: "Starts with an empty plan and no call.", -} as const; +/** The composer's words field, the editor's own textbox, which the card hands focus to. */ +const FIELD_SELECTOR = '[role="textbox"]'; /** 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"; @@ -144,73 +86,30 @@ function WorkspaceChip({ } /** - * The card with its own state, mounted afresh on each opening so a - * cancelled name is never offered again: the name, the repository chosen - * over the press's or the last one used, and the start under way. The - * chosen repository is null where it was let go, and undefined while the - * workspace's last one used stands. + * The card with its own state, mounted afresh on each opening so cancelled + * words are never offered again: the workspace chosen, and whether a start + * is under way, which holds the card. */ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.JSX.Element { // The workspace's own issue the task starts from, where the card was opened on one: it names the task, and its workspace is the one the task starts in. const from = newTask.preset?.issue; - const [name, setName] = useState(from?.title ?? ""); - 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 it offers no Linear chip. - const linearIssuesIn: TasksControl["newTask"]["linearIssuesIn"] = - from === undefined ? newTask.linearIssuesIn : () => undefined; const startsIn = workspaceId ?? newTask.workspaceId; - const repositories = newTask.repositoriesIn(startsIn); - const workspace = newTask.workspaces.find((each) => each.id === startsIn); - // A repository picked in one workspace is none of another's, so a switch - // lets the pick go, in the render that brings it; the scope first - // resolving keeps a pick made before the workspaces were read. - const [scope, setScope] = useState(repositories.scope); - if (repositories.scope !== scope) { - if (scope !== undefined) setChosen(undefined); - setScope(repositories.scope); - } - const [planning, setPlanning] = useNewTaskPlanning(); const [starting, setStarting] = useState(false); - const [note, setNote] = useState(undefined); - const searchesLinear = linearIssuesIn(startsIn) !== undefined; - // 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(); - const planningNoteId = useId(); - const planningNote = planning ? PLANNING_NOTE.ON : PLANNING_NOTE.OFF; - // Note that a press's repository the workspace's GitHub does not reach is - // not started on, because the service would refuse it. - const pick = - chosen != null && workspace !== undefined && !workspaceReaches(workspace, chosen) - ? null - : chosen; - const repository = pick === undefined ? (repositories.recent[0] ?? null) : pick; - const canStart = !starting; - - const begin = (taskName: string, issue?: LinearIssueSummary) => { - if (!canStart) return; - setStarting(true); - setNote(undefined); - newTask - .start(taskName, repository, startsIn, planning, { - ...(issue === undefined ? undefined : { linearIssueId: issue.id }), - ...(from === undefined ? undefined : { issueId: from.id }), - }) - .then(setNote) - .finally(() => setStarting(false)); - }; - const start = () => begin(name.trim()); - const startIssue = (issue: LinearIssueSummary) => begin(taskNameOfIssue(issue), issue); - // ⌘L from the card opens the Linear chip's menu, where the chip stands. - const onCardKey = (event: React.KeyboardEvent) => { - if (!searchesLinear || starting || !shortcutMatches(LINEAR_CHORD, event)) return; - event.preventDefault(); - setLinearAsks((asks) => asks + 1); - }; + const body = useRef(null); + // Note that the field is the editor the composer mounts once its bundle + // has landed, so the card hands focus to it by where it stands rather than + // by a ref of its own; before it lands the field takes the caret itself. + const field = useMemo( + (): RefObject => ({ + get current() { + return body.current?.querySelector(FIELD_SELECTOR) ?? null; + }, + }), + [], + ); const close = () => { if (!starting) newTask.onClose(); }; @@ -223,8 +122,7 @@ 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. */} -
+
@@ -237,71 +135,15 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J onChoose={setWorkspaceId} />
- {/* Note that the word labels the switch, so a press on it turns the switch too. */} - - - - - - -
- - {note === undefined ? null : {note}} -
- setChosen(null)} - /> - -
- - -
-
); diff --git a/apps/desktop/src/renderer/tasks/new-task-mode.ts b/apps/desktop/src/renderer/tasks/new-task-mode.ts new file mode 100644 index 0000000000..e2601827bb --- /dev/null +++ b/apps/desktop/src/renderer/tasks/new-task-mode.ts @@ -0,0 +1,54 @@ +/** + * new-task-mode.ts -- whether New task starts a coding agent on the words or plans them with Luke, kept across launches. + * + * In Agent mode the words in New task's box are a coding agent's prompt: the + * task starts with an empty plan and no call, and an agent starts on it. In + * Plan mode the task starts as the account's plan template and opens its + * call with Luke, as every task did before the agent came first. The choice + * is the window's own, as the sidebar's fold is + * (`../desktop/sidebar-collapse.ts`): main never reads it, so it is kept in + * the renderer's storage rather than crossing to the settings file, and the + * next New task offers it as it was left. Note that it is kept under a key + * of its own, never the old Plan switch's, so a switch turned on long ago + * does not land anyone in Plan mode: until it is changed, New task is in + * Agent mode. + */ + +import { useEffect, useState } from "react"; + +/** What New task starts: a coding agent on the words, or a plan and a call with Luke. */ +export const NEW_TASK_MODE = { + AGENT: "agent", + PLAN: "plan", +} as const; + +export type NewTaskMode = (typeof NEW_TASK_MODE)[keyof typeof NEW_TASK_MODE]; + +/** The storage key the choice is kept under. */ +const NEW_TASK_MODE_KEY = "luke.new-task-mode"; + +/** The kept choice: Plan mode where it was left there, else Agent mode, as where storage refuses the read. */ +function readMode(): NewTaskMode { + try { + return window.localStorage.getItem(NEW_TASK_MODE_KEY) === NEW_TASK_MODE.PLAN + ? NEW_TASK_MODE.PLAN + : NEW_TASK_MODE.AGENT; + } catch { + return NEW_TASK_MODE.AGENT; + } +} + +/** The mode New task offers and a setter that keeps it for the next one. */ +export function useNewTaskMode(): readonly [NewTaskMode, (mode: NewTaskMode) => void] { + const [mode, setMode] = useState(readMode); + // A write that fails costs only the preference, so it is not worth a word + // on screen. + useEffect(() => { + try { + window.localStorage.setItem(NEW_TASK_MODE_KEY, mode); + } catch { + // The card keeps the choice for as long as it stands. + } + }, [mode]); + return [mode, setMode]; +} diff --git a/apps/desktop/src/renderer/tasks/new-task-planning.ts b/apps/desktop/src/renderer/tasks/new-task-planning.ts deleted file mode 100644 index c1a818be83..0000000000 --- a/apps/desktop/src/renderer/tasks/new-task-planning.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * new-task-planning.ts -- whether New task starts a task with its planning phase, kept across launches. - * - * A task started with planning opens as the account's plan template and - * opens its call with Luke; one started without opens an empty plan and no - * call, for work the developer already knows how to say. The choice is the - * window's own, as the sidebar's fold is (`../desktop/sidebar-collapse.ts`): - * main never reads it, so it is kept in the renderer's storage rather than - * crossing to the settings file, and the next New task offers it as it was - * left. Until it is changed, a task starts with planning, as every task did - * before the choice was offered. - */ - -import { useEffect, useState } from "react"; - -/** The storage key the choice is kept under. */ -const NEW_TASK_PLANNING_KEY = "luke.new-task-planning"; - -/** The kept choice: with planning unless it was turned off, or storage refuses the read. */ -function readPlanning(): boolean { - try { - return window.localStorage.getItem(NEW_TASK_PLANNING_KEY) !== "false"; - } catch { - return true; - } -} - -/** The choice New task offers and a setter that keeps it for the next one. */ -export function useNewTaskPlanning(): readonly [boolean, (planning: boolean) => void] { - const [planning, setPlanning] = useState(readPlanning); - // A write that fails costs only the preference, so it is not worth a word - // on screen. - useEffect(() => { - try { - window.localStorage.setItem(NEW_TASK_PLANNING_KEY, String(planning)); - } catch { - // The dialog keeps the choice for as long as it stands. - } - }, [planning]); - return [planning, setPlanning]; -} 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 685569ae4a..add707064f 100644 --- a/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts @@ -4,10 +4,12 @@ import assert from "node:assert/strict"; import type { LinearIssueSummary } from "@luke/features/linear-wire"; import { WORKSPACE_ROLE, type Workspace } from "@luke/features/workspace-wire"; import { ACT_OUTCOME_STATUS } from "@luke/host/acts"; +import { Effect } from "effect"; import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, test } from "vitest"; import { fakeAppBridge } from "#testing/app-bridge"; +import { editorBundleLanded, installEditorLayout } from "#testing/composer"; import { installScrollIntoView } from "#testing/scroll-into-view"; import { tasksControl } from "#testing/tasks-control"; import { NewTaskDialog } from "./new-task-dialog"; @@ -16,9 +18,11 @@ import type { RepositoryChooser } from "./repository-chip"; /** * new-task-workspace.test.ts -- New task says where the task will start, * "New task in Acme", and the workspace chip there chooses another: the - * repository chip then lists that workspace's repositories, and the plan - * starts there. Both chips are the app's one picker, Settings' bordered chip - * and its menu. Synthetic throughout. + * composer's repository chip then lists that workspace's repositories, and + * the task starts there. Both chips are the app's one picker, Settings' + * bordered chip and its menu. Each test stands the card in Plan mode, which + * starts with or without a repository, as New task keeps the mode it was + * left in. Synthetic throughout. */ const PERSONAL: Workspace = { @@ -36,8 +40,13 @@ const ACME: Workspace = { const roots: Root[] = []; +// Landed before any card renders, as the panel lands it after its first paint. +beforeAll(() => Effect.runPromise(editorBundleLanded)); + beforeEach(() => { installScrollIntoView(); + installEditorLayout(); + window.localStorage.setItem("luke.new-task-mode", "plan"); fakeAppBridge({ act: () => Promise.resolve({ status: ACT_OUTCOME_STATUS.UNKNOWN_ACT }), }); @@ -46,6 +55,7 @@ beforeEach(() => { afterEach(() => { for (const root of roots.splice(0)) act(() => root.unmount()); document.body.innerHTML = ""; + window.localStorage.clear(); }); async function settle(): Promise { @@ -97,7 +107,7 @@ function chooserFor(workspaceId: string | undefined): RepositoryChooser { }; } -test("New plan says the workspace it starts in, and the workspace chosen there lists its own repositories and starts the plan", async () => { +test("New task says the workspace it starts in, and the workspace chosen there lists its own repositories and starts the plan", async () => { const starts: (string | undefined)[] = []; const control = tasksControl(); const root = createRoot(document.body.appendChild(document.createElement("div"))); @@ -155,19 +165,15 @@ test("New plan says the workspace it starts in, and the workspace chosen there l "the list is the chosen workspace's repositories", ); - const name = document.querySelector('input[aria-label="Task name"]'); - act(() => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(name, "Usage"); - name?.dispatchEvent(new Event("input", { bubbles: true })); - }); - const create = [...document.querySelectorAll("button")].find((each) => - each.textContent?.startsWith("Create task"), - ); + const create = document.querySelector(START_PLANNING); act(() => create?.click()); await settle(); assert.deepEqual(starts, [ACME.id], "the plan starts in the workspace chosen"); }); +/** Plan mode's Start, the composer's round button. */ +const START_PLANNING = 'button[aria-label="Start planning"]'; + /** The menu's option that names `name`. */ function optionNamed(menu: ParentNode, name: string): HTMLElement { return ( @@ -205,32 +211,25 @@ async function openNewTask(workspaceId: string, preset?: string) { const repositoryChip = () => document.querySelector(".repository-chip .luke-chip") ?? assert.fail("the repository chip stands"); - const create = async (words: string) => { - const name = document.querySelector('input[aria-label="Task name"]'); - act(() => { - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(name, words); - name?.dispatchEvent(new Event("input", { bubbles: true })); - }); - const button = [...document.querySelectorAll("button")].find((each) => - each.textContent?.startsWith("Create task"), - ); + const create = async () => { + const button = document.querySelector(START_PLANNING); act(() => button?.click()); await settle(); }; return { starts, repositoryChip, create }; } -test("a repository group's + whose repository the workspace's GitHub does not reach opens New plan on none", async () => { +test("a repository group's + whose repository the workspace's GitHub does not reach opens New task on none", async () => { const dialog = await openNewTask(PERSONAL.id, "ReviewStage/luke"); const label = dialog.repositoryChip().textContent; - await dialog.create("Tmp"); + await dialog.create(); assert.equal(label, "No repository"); assert.deepEqual(dialog.starts, [[null, PERSONAL.id]]); }); -test("switching New plan to a workspace with no GitHub connected lets the other workspace's repository go and offers none of its own", async () => { +test("switching New task to a workspace with no GitHub connected lets the other workspace's repository go and offers none of its own", async () => { const dialog = await openNewTask(ACME.id); const before = dialog.repositoryChip().textContent; @@ -245,7 +244,7 @@ test("switching New plan to a workspace with no GitHub connected lets the other const offered = [...document.querySelectorAll("button")].map((button) => button.textContent); const note = document.querySelector(".luke-combobox-note p")?.textContent; const after = dialog.repositoryChip().textContent; - await dialog.create("Tmp"); + await dialog.create(); assert.deepEqual([before, after], ["acme-corp/relay", "No repository"]); assert.equal(offered.includes("acme-corp/relay"), false); @@ -275,7 +274,7 @@ test("New task's workspace and repository chips each open their own menu", async assert.deepEqual(drawn, ["Workspace", "Repository"]); }); -test("Tab from the workspace picker's open list moves on to the Plan switch beside the title, and Shift-Tab back to the chip", async () => { +test("Tab from the workspace picker's open list moves on to the composer's words, and Shift-Tab back to the chip", async () => { await openNewTask(ACME.id); const chip = document.querySelector('[aria-label="Workspace: Acme"]') ?? @@ -294,7 +293,7 @@ test("Tab from the workspace picker's open list moves on to the Plan switch besi const forward = await tabFromList(false); const backward = await tabFromList(true); - assert.deepEqual([forward, backward], ["Plan with Luke", "Workspace: Acme"]); + assert.deepEqual([forward, backward], ["Task prompt", "Workspace: Acme"]); }); test("a switch of workspace closes the Linear search, and the chip then lists the issues of the workspace chosen", async () => { diff --git a/apps/desktop/src/renderer/tasks/use-coding-agents.ts b/apps/desktop/src/renderer/tasks/use-coding-agents.ts index 34db6f5a35..44a3c27749 100644 --- a/apps/desktop/src/renderer/tasks/use-coding-agents.ts +++ b/apps/desktop/src/renderer/tasks/use-coding-agents.ts @@ -101,6 +101,12 @@ const NO_KEYWORDS: readonly string[] = []; * outlast a relaunch too, and a plan holding them opens on its draft's tab * again, under the same key. * + * New task's Agent mode starts its agent through a draft too: the task it + * started is handed a draft holding the words, the files, and the model + * New task's chip named (`onHandDraft`), whose tab opens and sends it as + * soon as the task can start an agent, so an agent the service refuses + * leaves the words in that tab's box with why and Retry, as any draft's. + * * An archived task starts no agent and sends none a message: Start is * unavailable and says why, and `onUnarchive` stands, which every place * that would have taken words offers instead. An agent already running @@ -188,10 +194,17 @@ export interface CodingAgentsControl { ) => string; /** Lets the open task's draft's fork go, leaving an ordinary draft with its words and chips. */ onDropFork: () => void; - /** Writes the open task's draft: the words in its box, the choice on its chip, or the MCP servers it switched off. */ + /** Writes the open task's draft: the words in its box, the choice on its chip, the MCP servers it switched off, or that it no longer sends as its tab opens. */ onDraftChange: ( - change: Partial>, + change: Partial>, ) => void; + /** + * Hands a task New task just started a draft agent, under the key given, + * holding the words, the files, and the choice made on New task's chip, + * which its tab sends as soon as the task can start an agent: so an agent + * refused leaves the words in that tab's box with why and Retry. + */ + onHandDraft: (taskId: string, handed: HandedDraft) => void; /** Changes the open task's draft's files from those it holds as the change lands. */ onDraftAttachments: ( next: (held: readonly CodingAgentAttachment[]) => readonly CodingAgentAttachment[], @@ -316,10 +329,12 @@ export interface AgentDraft { readonly choice: ModelChoice | undefined; readonly disabledServers: readonly string[]; readonly fork?: AgentFork | undefined; + /** Set on a draft New task handed over (`onHandDraft`), whose tab sends it as soon as the task can start an agent, once. */ + readonly sendsOnOpen?: true | undefined; } -/** What a draft agent holds beside its words and files: its key, what its chips chose, and the fork it is. */ -type DraftChoices = Pick; +/** What a draft agent holds beside its words and files: its key, what its chips chose, the fork it is, and whether it sends as its tab opens. */ +type DraftChoices = Pick; /** The plan's draft agent: the key and chips held, with the words and files kept for it, or the words kept under their own key where nothing is held, as after a relaunch. */ function taskDraft( @@ -336,9 +351,18 @@ function taskDraft( disabledServers: held?.disabledServers ?? [], // Note that a fork is kept with the words too, so a relaunch keeps the draft a fork. ...forkOf(held === undefined ? kept?.place.fork : held.fork), + ...(held?.sendsOnOpen === true ? { sendsOnOpen: true } : undefined), }; } +/** What New task hands a task it started for its draft agent: the key the agent starts under, its prompt, its files, and the choice its chip made, none for the account's default. */ +export interface HandedDraft { + readonly id: string; + readonly prompt: string; + readonly attachments: readonly CodingAgentAttachment[]; + readonly choice: ModelChoice | undefined; +} + /** A draft's fork as an optional key, absent where it forks nothing. */ function forkOf(fork: AgentFork | undefined): { readonly fork?: AgentFork } { return fork === undefined ? {} : { fork }; @@ -803,7 +827,7 @@ export function useCodingAgents(input: { }; const onDraftChange = ( - change: Partial>, + change: Partial>, ) => { if (taskId === undefined || draft === undefined) return; const { prompt, ...choices } = change; @@ -814,10 +838,28 @@ export function useCodingAgents(input: { choice: draft.choice, disabledServers: draft.disabledServers, ...(draft.fork === undefined ? undefined : { fork: draft.fork }), + ...(draft.sendsOnOpen === true ? { sendsOnOpen: true } : undefined), ...choices, }); }; + // Note that the words are kept as any draft's are, under the task New + // task started and the key handed, so they outlast the tab, and the task + // opens on the draft's tab until the agent starts. + const onHandDraft = (on: string, handed: HandedDraft) => { + writer.update(wordsPlace(on, handed.id, undefined), (was) => ({ + ...was, + text: handed.prompt, + attachments: handed.attachments, + })); + withDraft(on, { + id: handed.id, + choice: handed.choice, + disabledServers: [], + sendsOnOpen: true, + }); + }; + // Note that the files are changed from the draft as it stands when the // change lands, because a file's read lands after the render that began it. const onDraftAttachments = ( @@ -1171,6 +1213,7 @@ export function useCodingAgents(input: { onContinueDraft, onDropFork, onDraftChange, + onHandDraft, onDraftAttachments, onDiscardDraft, startPrompt, 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 88a5b55638..3e8356df9c 100644 --- a/apps/desktop/src/renderer/tasks/use-tasks-tab.test.ts +++ b/apps/desktop/src/renderer/tasks/use-tasks-tab.test.ts @@ -2538,3 +2538,89 @@ test("the task's Chat tab on screen marks the task's plan channel read, as the w ); }); }); + +test("New task's Agent mode starts the task without planning and opens it on a draft agent holding the prompt, files, and model, marked to send itself", async () => { + const choice = { model: "anthropic/claude-opus-5.5", effort: "high" }; + const tab = mount( + { shown: true, tasks: OPEN }, + { + [ACT_KIND.TASKS_START]: () => Promise.resolve({ planId: OTHER_TASK_ID }), + [ACT_KIND.CODING_AGENTS_LIST]: () => Promise.resolve({ agents: [] }), + }, + ); + act(() => tab.control().onNewTask()); + let note: string | undefined = "unanswered"; + await act(async () => { + note = await tab.control().newTask.start( + "", + "acme/web", + undefined, + false, + {}, + { + prompt: "Make the sidebar keep its folds.", + attachments: [], + choice, + }, + ); + }); + tab.stand({ + tasks: { + ...OPEN, + activeTaskId: OTHER_TASK_ID, + document: { + status: TASKS_READ.READY, + task: { ...TASK, id: OTHER_TASK_ID, name: "Untitled", repository: "acme/web" }, + }, + }, + }); + await settle(); + + const draft = tab.control().agents.draft; + assert.equal(note, undefined); + assert.equal(tab.control().newTask.open, false, "the dialog closes"); + assert.deepEqual( + tab.payloads + .filter((asked) => asked.kind === ACT_KIND.TASKS_START) + .map((asked) => Option.getOrUndefined(readPlanningNamed(asked.payload))?.planning), + [false], + ); + assert.ok(draft, "the new task holds a draft agent"); + assert.deepEqual( + { prompt: draft.prompt, choice: draft.choice, sendsOnOpen: draft.sendsOnOpen }, + { prompt: "Make the sidebar keep its folds.", choice, sendsOnOpen: true }, + ); + assert.deepEqual(tab.control().mainTabs.tab, { draft: draft.id }, "the task opens on its tab"); + assert.equal(tab.control().agents.start.available, true, "the draft can send itself"); +}); + +test("an agent's title first landing on a task still Untitled reads the tasks again, so the name it took shows; a titled task asks nothing", async () => { + const untitled: TasksView = { + ...OPEN, + document: { status: TASKS_READ.READY, task: { ...TASK, name: "Untitled" } }, + }; + const refreshes = (tab: ReturnType) => + tab.told.filter((kind) => kind === ACT_KIND.TASKS_REFRESH).length; + const tab = mount( + { shown: true, tasks: untitled }, + { [ACT_KIND.CODING_AGENTS_LIST]: () => Promise.resolve({ agents: [RUNNING_AGENT] }) }, + ); + await settle(); + const before = refreshes(tab); + act(() => tab.control().agents.onTitle(AGENT_ID, "Fix token refresh")); + await settle(); + const landed = refreshes(tab) - before; + document.body.innerHTML = ""; + + const named = mount( + { shown: true, tasks: OPEN }, + { [ACT_KIND.CODING_AGENTS_LIST]: () => Promise.resolve({ agents: [RUNNING_AGENT] }) }, + ); + await settle(); + const namedBefore = refreshes(named); + act(() => named.control().agents.onTitle(AGENT_ID, "Fix token refresh")); + await settle(); + + assert.equal(landed, 1); + assert.equal(refreshes(named) - namedBefore, 0); +}); diff --git a/apps/desktop/src/renderer/tasks/use-tasks-tab.ts b/apps/desktop/src/renderer/tasks/use-tasks-tab.ts index 45128ce036..c3f89d8e53 100644 --- a/apps/desktop/src/renderer/tasks/use-tasks-tab.ts +++ b/apps/desktop/src/renderer/tasks/use-tasks-tab.ts @@ -2,17 +2,19 @@ import type { AccountFailedRun } from "@luke/features/account-inbox-wire"; import type { AutomationPresetName } from "@luke/features/automation-code-review"; import type { Board } from "@luke/features/board-wire"; import { CHANNEL_KIND } from "@luke/features/chat-wire"; -import type { TasklessAgent } from "@luke/features/coding-agent-wire"; +import type { CodingAgentDefaultAnswer } from "@luke/features/coding-agent-view"; +import type { CodingAgentAttachment, TasklessAgent } from "@luke/features/coding-agent-wire"; import { dictationKeywords } from "@luke/features/dictation-keywords"; import type { GitHubConnection } from "@luke/features/github-connection-wire"; import type { LinearIssueSummary } from "@luke/features/linear-wire"; import { LINK_PLACE, type ReadLink } from "@luke/features/luke-link"; import { MENTION_KIND, type PlanMention } from "@luke/features/mention-link"; +import type { CatalogModel, ModelChoice } from "@luke/features/models-wire"; import type { PageLine } from "@luke/features/page-wire"; import type { PlanEditRequest } from "@luke/features/plan-edit"; import { planMarkdown } from "@luke/features/plan-markdown"; import type { TaskSections, TaskSectionWrite } from "@luke/features/task-section-wire"; -import type { CodeRef, Task } from "@luke/features/task-wire"; +import { type CodeRef, type Task, UNTITLED_TASK_NAME } from "@luke/features/task-wire"; import { TASK_CALL_FAILURE, TASKS_READ, @@ -391,6 +393,13 @@ export interface NewTaskIssue { readonly title: string; } +/** The coding agent New task's Agent mode starts on the task it started: its prompt, the files attached, and the model its chip named, none for the account's default. */ +export interface NewTaskAgent { + readonly prompt: string; + readonly attachments: readonly CodingAgentAttachment[]; + readonly choice: ModelChoice | undefined; +} + /** What the press that opened New task chose: a repository (a repository group's +), or the issue the task starts from (an issue's Start task…). */ export interface NewTaskPreset { readonly repository?: string; @@ -649,14 +658,31 @@ export interface TasksControl { workspaceId: string | undefined; /** The repository chip's list for a workspace, as the dialog chooses which the task starts in. */ repositoriesIn: (workspaceId: string | undefined) => RepositoryChooser; - /** Starts the plan on the repository given, or none, in the workspace given, with its planning phase or without it, from the Linear issue given where one was picked, or from the workspace's own issue the dialog was opened on, which closes the dialog and opens the plan; answers why it did not start, or nothing once it has. */ + /** + * Starts the plan on the repository given, or none, in the workspace + * given, with its planning phase or without it, from the Linear issue + * given where one was picked, or from the workspace's own issue the + * dialog was opened on, which closes the dialog and opens the plan, and, + * with an agent given, opens it on a draft agent's tab holding the + * agent's prompt, files, and model, which starts the agent at once; + * answers why the task did not start, or nothing once it has. + */ start: ( name: string, repository: string | null, workspaceId: string | undefined, planning: boolean, from?: { readonly linearIssueId?: string; readonly issueId?: string }, + agent?: NewTaskAgent, ) => Promise; + /** The models the composer's chip offers, as last read; nothing before the first read lands. */ + models: readonly CatalogModel[] | undefined; + /** Reads the models now, for the chip's name and its menu opening. */ + readModels: () => void; + /** Reads the account's default model, which the composer's chip starts on. */ + readDefault: () => Promise; + /** Keeps the composer chip's pick as the account's default, as Start's menu does. */ + writeDefault: (choice: ModelChoice) => Promise; /** Puts the dialog away with nothing started. */ onClose: () => void; /** @@ -1333,6 +1359,28 @@ export function useTasksTab(input: { sidebarSeen.current = { taskId, ids: new Set(sidebarByLuke) }; if (landed) tell(ACT_KIND.TASKS_REFRESH); }, [sidebarByLuke, viewed.activeTaskId, tell]); + // An Untitled task takes its first agent's title on the service, and the + // window hears no push for a task's row, so the tasks are read again as an + // agent of the open task first holds a title while it stands Untitled. + // Note that a list read already holding a title is no title landing, so a + // task opened on titled agents asks nothing. + const agentTitled = + agents.agents === undefined ? undefined : agents.agents.some((agent) => agent.title !== null); + const openName = + tasks.tasks.find((task) => task.id === viewed.activeTaskId)?.name ?? + (openDocument?.id === viewed.activeTaskId ? openDocument?.name : undefined); + const untitled = openName === UNTITLED_TASK_NAME; + const titledSeen = useRef<{ taskId: string | undefined; titled: boolean | undefined }>({ + taskId: undefined, + titled: undefined, + }); + useEffect(() => { + const seen = titledSeen.current; + const taskId = viewed.activeTaskId; + const landed = seen.taskId === taskId && seen.titled === false && agentTitled === true; + titledSeen.current = { taskId, titled: agentTitled }; + if (landed && untitled) tell(ACT_KIND.TASKS_REFRESH); + }, [agentTitled, untitled, viewed.activeTaskId, tell]); // A file's tab is held to the files Luke showed on the task the same way. const files = useMemo( () => shownFiles(viewed.shownCode ?? []).map((ref) => ref.path), @@ -2248,13 +2296,17 @@ export function useTasksTab(input: { // which main leaves for this question to end or keep. A start without // planning opens no call, so it asks nothing about the one standing; it // names its planning only where it is off, so a request with planning is - // the one every service has read. + // the one every service has read. New task's Agent mode hands the task it + // started a draft agent holding the prompt, files, and model, and opens + // the task on that draft's tab, which starts the agent at once, so a + // refused agent leaves the words in that tab's box with why and Retry. const startTask = ( name: string, repository: string | null, workspaceId: string | undefined, planning: boolean, from: { readonly linearIssueId?: string; readonly issueId?: string } = {}, + agent?: NewTaskAgent, ): Promise => { const panelWasOpen = sidePanel.open; sidePanel.onSetOpen(false); @@ -2279,6 +2331,13 @@ export function useTasksTab(input: { askOnOpen.current = answer.planId; setAskedFor(answer.planId); } + // The agent starts from a draft the task is handed, its tab the one + // the task opens on, so a refusal leaves the words there with Retry. + if (agent !== undefined) { + const draftId = crypto.randomUUID(); + agents.onHandDraft(answer.planId, { id: draftId, ...agent }); + mainTabs.onShow(answer.planId, { draft: draftId }); + } return undefined; }, (refused: Error) => refusedWith(refused.message), @@ -3026,6 +3085,10 @@ export function useTasksTab(input: { workspaceId: listedWorkspace?.id, repositoriesIn, start: startTask, + models: agents.models, + readModels: agents.readModels, + readDefault: agents.readDefault, + writeDefault: agents.writeDefault, onClose: () => setNewTaskAsk(undefined), linearIssuesIn, }, diff --git a/apps/desktop/src/testing/tasks-control.ts b/apps/desktop/src/testing/tasks-control.ts index 7bc9b2f261..cb80755b53 100644 --- a/apps/desktop/src/testing/tasks-control.ts +++ b/apps/desktop/src/testing/tasks-control.ts @@ -92,6 +92,7 @@ export function codingAgentsControl( onContinueDraft: () => "draft", onDropFork: ignore, onDraftChange: ignore, + onHandDraft: ignore, onDraftAttachments: ignore, onDiscardDraft: ignore, startPrompt: () => Promise.resolve(undefined), @@ -352,6 +353,10 @@ export function tasksControl(overrides: Partial = {}): TasksContro workspaceId: undefined, repositoriesIn: () => NO_REPOSITORIES, start: () => Promise.resolve(undefined), + models: undefined, + readModels: ignore, + readDefault: () => Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), + writeDefault: () => Promise.resolve({ failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), onClose: ignore, linearIssuesIn: () => undefined, }, diff --git a/packages/ui/src/tasks-model.test.ts b/packages/ui/src/tasks-model.test.ts index 64947125f3..30348abd38 100644 --- a/packages/ui/src/tasks-model.test.ts +++ b/packages/ui/src/tasks-model.test.ts @@ -6,6 +6,7 @@ import { MICROPHONE_STATUS } from "@luke/voice/audio"; import { VOICE_KEYLESS_NOTE } from "@luke/voice/microphone-access"; import { test } from "vitest"; import { + agentPromptOf, ISSUE_MATCH_LIMIT, linearIssuesAskOf, MICROPHONE_PRESS, @@ -16,6 +17,7 @@ import { positionedView, TASK_MARK, taskNameOfIssue, + taskNameOfWords, unsettledPinPositions, } from "./tasks-model.js"; @@ -119,6 +121,38 @@ test("a plan started from a Linear issue is named by its title, cut to a name's assert.equal(taskNameOfIssue({ identifier: "ENG-3", title: " " }), "ENG-3"); }); +test("an agent New task starts is handed a mention of the issue picked, then a blank line and the words; the issue alone with no words, and the words alone with no issue", () => { + const issue = { + identifier: "ENG-1", + title: " Fix [the] refresh ", + url: "https://linear.app/acme/issue/ENG-1/fix-the-refresh", + }; + + assert.equal( + agentPromptOf(issue, " Reproduce it first.\n"), + "[@ENG-1 Fix \\[the\\] refresh](https://linear.app/acme/issue/ENG-1/fix-the-refresh)\n\nReproduce it first.", + ); + assert.equal( + agentPromptOf(issue, " "), + "[@ENG-1 Fix \\[the\\] refresh](https://linear.app/acme/issue/ENG-1/fix-the-refresh)", + ); + assert.equal( + agentPromptOf(undefined, " Make the sidebar keep its folds. "), + "Make the sidebar keep its folds.", + ); +}); + +test("a task started in Plan mode is named by the first line of the words, cut to a name's bound, and by nothing for a blank box", () => { + const long = "x".repeat(TASK_BOUNDS.MAX_NAME_CHARS + 20); + + assert.equal( + taskNameOfWords("\n Rethink automations \nwhich agent they reuse"), + "Rethink automations", + ); + assert.equal(taskNameOfWords(long).length, TASK_BOUNDS.MAX_NAME_CHARS); + assert.equal(taskNameOfWords(" \n "), ""); +}); + test("a pin dragged here stands at its new place until the list carries it, and is let go once it does, the pin is gone, or the service answered", () => { const task = { id: INVITES, diff --git a/packages/ui/src/tasks-model.ts b/packages/ui/src/tasks-model.ts index 9864ef6907..dd615eb9bb 100644 --- a/packages/ui/src/tasks-model.ts +++ b/packages/ui/src/tasks-model.ts @@ -1,9 +1,11 @@ import type { TasklessAgent } from "@luke/features/coding-agent-wire"; +import { INTEGRATION_PROVIDER } from "@luke/features/integration-wire"; import { LINEAR_BOUNDS, type LinearIssueSummary, linearIssueIdentifierIn, } from "@luke/features/linear-wire"; +import { MENTION_KIND, mentionMarkdown } from "@luke/features/mention-link"; import { type RepositoryPin, TASK_BOUNDS, @@ -137,6 +139,41 @@ export function taskNameOfIssue(issue: Pick | undefined, + words: string, +): string { + const said = words.trim(); + if (issue === undefined) return said; + const mention = mentionMarkdown( + { kind: MENTION_KIND.LINK, provider: INTEGRATION_PROVIDER.LINEAR, url: issue.url }, + `${issue.identifier} ${issue.title.trim()}`.trim(), + ISSUE_MENTION_PLACE, + ); + return said === "" ? mention : `${mention}\n\n${said}`; +} + /** How many of a workspace's Linear issues New task offers under its name field at once. */ export const ISSUE_MATCH_LIMIT = 6; From 09088a3976d19364f41b145cf68b79831b8a05b3 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:58:40 +0000 Subject: [PATCH 03/15] fix(desktop): keep the Linear search's highlight on its issue, and its menu shut across a reconnect or a composing key - SearchableMenu holds a moved highlight by its row's id as well as its place, so an owner's search answering after the arrows moved, with an issue sorted ahead, leaves the highlight and Return on the issue chosen. - The Linear chip closes its menu when Linear is let go, so its coming back draws nothing until the chip is pressed. - Command-L opens nothing while an input method is composing. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/src/renderer/searchable-menu.tsx | 13 ++++- .../src/renderer/tasks/linear-issue-chip.tsx | 6 ++ .../renderer/tasks/new-task-dialog.test.ts | 55 ++++++++++++++++++- .../src/renderer/tasks/new-task-dialog.tsx | 2 + .../renderer/tasks/new-task-workspace.test.ts | 46 ++++++++++++++++ 5 files changed, 116 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/searchable-menu.tsx b/apps/desktop/src/renderer/searchable-menu.tsx index eca4cb76f0..a64f373e19 100644 --- a/apps/desktop/src/renderer/searchable-menu.tsx +++ b/apps/desktop/src/renderer/searchable-menu.tsx @@ -333,13 +333,20 @@ export function SearchableMenu(props: { 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, @@ -356,7 +363,7 @@ export function SearchableMenu(props: { setWasLeaving(leaving); if (!leaving) { setQuery(""); - setHighlight(undefined); + setHighlightAt(undefined); setSubmenu(undefined); } } diff --git a/apps/desktop/src/renderer/tasks/linear-issue-chip.tsx b/apps/desktop/src/renderer/tasks/linear-issue-chip.tsx index e877c069e4..1ef88d2cb2 100644 --- a/apps/desktop/src/renderer/tasks/linear-issue-chip.tsx +++ b/apps/desktop/src/renderer/tasks/linear-issue-chip.tsx @@ -204,6 +204,12 @@ export function LinearIssueChip({ 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. 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 5d2a83def8..8cc1883236 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts @@ -719,6 +719,52 @@ test("typing in the Linear search narrows the issues at once, and once it rests } }); +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 { @@ -756,14 +802,17 @@ test("Escape closes the Linear search and leaves the card with focus on the chip assert.deepEqual(card.starts, []); }); -test("Command-L from the name field opens the Linear search, and Escape hands focus back to the name", async () => { +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); + assert.equal(document.querySelector(LINEAR_SEARCH) !== null, false, "the search is closed"); assert.equal(document.activeElement, card.field()); assert.ok(card.dialog()); }); @@ -787,7 +836,7 @@ test("opened on one of the workspace's issues, the card says so, starts named by assert.equal(card.field().value, "Invites expire"); 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, "Command-L opens nothing"); + 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 d216c209e0..fc7153686b 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.tsx @@ -207,6 +207,8 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J const startIssue = (issue: LinearIssueSummary) => begin(taskNameOfIssue(issue), issue); // ⌘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); 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 685569ae4a..aaa2f7a484 100644 --- a/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts @@ -357,3 +357,49 @@ test("a switch of workspace closes the Linear search, and the chip then lists th 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"); +}); From b803c37c133f9889a5834945c27803bfd05e24f6 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:59:24 +0000 Subject: [PATCH 04/15] refactor(desktop): name New task's mode and handed draft as the checks ask Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/tasks/new-task-composer.tsx | 14 +++++----- .../renderer/tasks/new-task-dialog.test.ts | 26 +++++++++---------- .../renderer/tasks/new-task-workspace.test.ts | 6 ++--- .../src/renderer/tasks/use-coding-agents.ts | 2 +- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/renderer/tasks/new-task-composer.tsx b/apps/desktop/src/renderer/tasks/new-task-composer.tsx index 86af0713ea..bde39969af 100644 --- a/apps/desktop/src/renderer/tasks/new-task-composer.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-composer.tsx @@ -114,8 +114,8 @@ const MODE_WORDS = { /** The Plan toggle's words, and why Agent mode's Start is held without a repository. */ const COMPOSER_WORDS = { PLAN: "Plan", - PLAN_LABEL: "Plan with Luke first", - PLAN_KEYS: "Shift+Tab", + TOGGLE_LABEL: "Plan with Luke first", + TOGGLE_KEYS: "Shift+Tab", DROP_ISSUE: "Remove issue", NEEDS_REPOSITORY: "An agent needs a repository to work in. Choose one, or turn on Plan.", } as const; @@ -244,7 +244,7 @@ export function NewTaskComposer({ const canStart = planning || (!blocked && (filled || issue !== undefined)); const issueName = issue === undefined ? from?.title : taskNameOfIssue(issue); - const begin = (name: string, asPlan: boolean, agent: boolean) => { + const begin = (name: string, withCall: boolean, agent: boolean) => { if (starting) return; setStarting(true); onStarting?.(true); @@ -254,7 +254,7 @@ export function NewTaskComposer({ name, repository, workspaceId, - asPlan, + withCall, { ...(issue === undefined ? undefined : { linearIssueId: issue.id }), ...(from === undefined ? undefined : { issueId: from.id }), @@ -367,12 +367,12 @@ export function NewTaskComposer({ } actions={ - + setMode(planning ? NEW_TASK_MODE.AGENT : NEW_TASK_MODE.PLAN)} > 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 2b00c5d38c..125df70706 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts @@ -83,7 +83,7 @@ async function settle(): Promise { } /** The card stood in Plan mode, as New task keeps the mode it was left in. */ -function inPlanMode(): void { +function keptInLukeMode(): void { window.localStorage.setItem(MODE_KEY, "plan"); } @@ -241,7 +241,7 @@ async function mount( } /** The Plan toggle, pressed where `planning` is. */ -function planToggle(): HTMLButtonElement { +function modeToggle(): HTMLButtonElement { return ( document.querySelector('button[aria-label="Plan with Luke first"]') ?? assert.fail("the Plan toggle stands") @@ -266,7 +266,7 @@ test("the card holds the composer with its words focused, the repository chip af true, "the model chip names the account's default", ); - assert.equal(planToggle().getAttribute("aria-pressed"), "false", "New task starts in Agent mode"); + assert.equal(modeToggle().getAttribute("aria-pressed"), "false", "New task starts in Agent mode"); }); test("Agent mode: Return starts the task without planning, Untitled, on the repository last used, and an agent on the words with the chip's model", async () => { @@ -318,7 +318,7 @@ test("Plan mode: Shift+Tab turns it on, the model chip stands down, and Start be card.type("Rethink automations\nwhich agent they reuse"); await card.key("Enter"); - assert.equal(planToggle().getAttribute("aria-pressed"), "true"); + assert.equal(modeToggle().getAttribute("aria-pressed"), "true"); assert.equal(placeholder, "What should you and Luke plan? (optional)"); assert.equal(modelChip, null); assert.deepEqual(card.starts, [["Rethink automations", "acme/web"]]); @@ -327,7 +327,7 @@ test("Plan mode: Shift+Tab turns it on, the model chip stands down, and Start be }); test("Plan mode starts with nothing typed and with no repository, as today's blank plan", async () => { - inPlanMode(); + keptInLukeMode(); const card = await mount(undefined, undefined, { recent: [] }); assert.equal(card.startButton().disabled, false); act(() => card.startButton().click()); @@ -349,7 +349,7 @@ test("Agent mode with no repository holds Start and says why under the card, and "An agent needs a repository to work in. Choose one, or turn on Plan.", ), }; - act(() => planToggle().click()); + act(() => modeToggle().click()); await settle(); const why = card.dialog()?.textContent?.includes("An agent needs a repository"); await card.key("Enter"); @@ -363,15 +363,15 @@ test("Agent mode with no repository holds Start and says why under the card, and test("the mode is kept for the next New task, and Agent mode stands where only the old Plan switch's choice was kept", async () => { window.localStorage.setItem("luke.new-task-planning", "true"); const first = await mount(); - const firstMode = planToggle().getAttribute("aria-pressed"); - act(() => planToggle().click()); + const firstMode = modeToggle().getAttribute("aria-pressed"); + act(() => modeToggle().click()); for (const root of roots.splice(0)) act(() => root.unmount()); document.body.innerHTML = ""; await mount(); assert.equal(firstMode, "false"); - assert.equal(planToggle().getAttribute("aria-pressed"), "true"); + assert.equal(modeToggle().getAttribute("aria-pressed"), "true"); assert.ok(first); }); @@ -561,7 +561,7 @@ test("a member of a workspace whose GitHub was removed or suspended on GitHub is }); test("in a workspace with no GitHub connected, a repository its plans still name is neither offered nor chosen, the chip reads No repository, and Plan mode starts the task with none", async () => { - inPlanMode(); + keptInLukeMode(); const dialog = await mount(undefined, undefined, { scope: "workspace-personal", workspaceName: "Dean Stratakos", @@ -607,7 +607,7 @@ test("a pick the workspace's GitHub turns out not to list is let go, while one i installationUrl: "https://github.com/apps/luke/installations/new", }, }); - inPlanMode(); + keptInLukeMode(); const unlisted = await mount(undefined, undefined, { read: listing }); unlisted.type("Dark mode"); await unlisted.key("Enter"); @@ -623,7 +623,7 @@ test("a pick the workspace's GitHub turns out not to list is let go, while one i }); test("the repository menu names each repository first and its owner after it, so two that share an owner tell apart, and finds one by its owner/name", async () => { - inPlanMode(); + keptInLukeMode(); const owner = "northwind-platform"; const reached = (name: string) => ({ owner, @@ -842,7 +842,7 @@ test("an issue picked and Return with no words starts an agent on the issue alon })(); await openLinear(); await card.press("Enter", linearSearch()); - act(() => planToggle().click()); + act(() => modeToggle().click()); await settle(); act(() => card.startButton().click()); await settle(); 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 add707064f..3c908d0630 100644 --- a/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-workspace.test.ts @@ -165,14 +165,14 @@ test("New task says the workspace it starts in, and the workspace chosen there l "the list is the chosen workspace's repositories", ); - const create = document.querySelector(START_PLANNING); + const create = document.querySelector(START_WITH_LUKE); act(() => create?.click()); await settle(); assert.deepEqual(starts, [ACME.id], "the plan starts in the workspace chosen"); }); /** Plan mode's Start, the composer's round button. */ -const START_PLANNING = 'button[aria-label="Start planning"]'; +const START_WITH_LUKE = 'button[aria-label="Start planning"]'; /** The menu's option that names `name`. */ function optionNamed(menu: ParentNode, name: string): HTMLElement { @@ -212,7 +212,7 @@ async function openNewTask(workspaceId: string, preset?: string) { document.querySelector(".repository-chip .luke-chip") ?? assert.fail("the repository chip stands"); const create = async () => { - const button = document.querySelector(START_PLANNING); + const button = document.querySelector(START_WITH_LUKE); act(() => button?.click()); await settle(); }; diff --git a/apps/desktop/src/renderer/tasks/use-coding-agents.ts b/apps/desktop/src/renderer/tasks/use-coding-agents.ts index 44a3c27749..eb3339f4b6 100644 --- a/apps/desktop/src/renderer/tasks/use-coding-agents.ts +++ b/apps/desktop/src/renderer/tasks/use-coding-agents.ts @@ -356,7 +356,7 @@ function taskDraft( } /** What New task hands a task it started for its draft agent: the key the agent starts under, its prompt, its files, and the choice its chip made, none for the account's default. */ -export interface HandedDraft { +interface HandedDraft { readonly id: string; readonly prompt: string; readonly attachments: readonly CodingAgentAttachment[]; From 4ed5eb27cab814adc6c25db4afa0c19c3122ad0f Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:17:39 +0000 Subject: [PATCH 05/15] fix(desktop): hold New task's focus on its card until the composer's field lands Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- apps/desktop/src/renderer/tasks/new-task-dialog.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/tasks/new-task-dialog.tsx b/apps/desktop/src/renderer/tasks/new-task-dialog.tsx index 1f5384dd24..a9c253e175 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.tsx @@ -44,6 +44,9 @@ function newTaskHeading(issue: string | undefined, inWorkspace: boolean): string /** The composer's words field, the editor's own textbox, which the card hands focus to. */ const FIELD_SELECTOR = '[role="textbox"]'; +/** The dialog's own card, which holds focus while the field has not landed. */ +const CARD_SELECTOR = '[aria-modal="true"]'; + /** 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"; @@ -101,11 +104,13 @@ function NewTaskCard({ newTask }: { newTask: TasksControl["newTask"] }): React.J const body = useRef(null); // Note that the field is the editor the composer mounts once its bundle // has landed, so the card hands focus to it by where it stands rather than - // by a ref of its own; before it lands the field takes the caret itself. + // by a ref of its own; before it lands the card holds focus, and the field + // takes the caret itself as it lands. const field = useMemo( (): RefObject => ({ get current() { - return body.current?.querySelector(FIELD_SELECTOR) ?? null; + const card = body.current?.closest(CARD_SELECTOR) ?? null; + return body.current?.querySelector(FIELD_SELECTOR) ?? card; }, }), [], From df3c97002b63086a8323f483a908c221ed07284a Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:32:32 +0000 Subject: [PATCH 06/15] test(desktop): put New task away with Escape in the shell, and name its Plan mode's files in the task-names allowlist Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-shell.test.ts | 13 ++++++++----- scripts/lib/task-names.mjs | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-shell.test.ts b/apps/desktop/src/renderer/desktop/desktop-shell.test.ts index c2fccf8702..54cab00a8e 100644 --- a/apps/desktop/src/renderer/desktop/desktop-shell.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-shell.test.ts @@ -247,7 +247,7 @@ test("the title bar's buttons, each wrapped in its hint, tell a reader the chord ]); }); -test("the home page welcomes an account with no plan, and its New plan raises the dialog over the window, which Cancel puts away", () => { +test("the home page welcomes an account with no plan, and its New task raises the dialog over the window, which Escape puts away", () => { const page = mount(false); const home = page.querySelector('section[aria-label="Home"]'); assert.ok(home, "the home page stands"); @@ -258,11 +258,14 @@ test("the home page welcomes an account with no plan, and its New plan raises th act(() => newTask.click()); const dialog = document.querySelector('[role="dialog"][aria-modal="true"]'); assert.equal(dialog?.querySelector("h2")?.textContent, "New task"); - const cancel = [...(dialog?.querySelectorAll("button") ?? [])].find( - (button) => button.textContent === "Cancel", + act(() => { + dialog?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + assert.equal( + document.querySelector('[role="dialog"][aria-modal="true"]') === null, + true, + "Escape puts the dialog away", ); - act(() => cancel?.click()); - assert.equal(document.querySelector('[role="dialog"][aria-modal="true"]'), null); }); test("while a launch reopens the plan last open, the column draws no home page to flash before it", () => { diff --git a/scripts/lib/task-names.mjs b/scripts/lib/task-names.mjs index 2074d2c05d..9e3764ceb1 100644 --- a/scripts/lib/task-names.mjs +++ b/scripts/lib/task-names.mjs @@ -207,10 +207,10 @@ const WIRE_NAME = { planClosed: ["apps/desktop/src/renderer/tasks/use-main-tabs.ts"], }; -/** The New task dialog's Plan switch: whether Luke writes a plan for the task it starts. */ +/** New task's Plan mode: whether Luke writes a plan for the task it starts. */ const PLAN_SWITCH_FILES = [ "apps/desktop/src/renderer/tasks/new-task-dialog.tsx", - "apps/desktop/src/renderer/tasks/new-task-planning.ts", + "apps/desktop/src/renderer/tasks/new-task-composer.tsx", "apps/desktop/src/renderer/tasks/use-tasks-tab.test.ts", ]; @@ -351,7 +351,7 @@ export const CANARY = { ["PlanTitle", "apps/desktop/src/renderer/ai-elements/plan.tsx"], ["PLAN_ROW", "apps/desktop/src/renderer/desktop/main-tabs.tsx"], ["planClosed", "apps/desktop/src/renderer/tasks/use-main-tabs.ts"], - ["useNewTaskPlanning", "apps/desktop/src/renderer/tasks/new-task-planning.ts"], + ["useNewTaskPlanning", "apps/desktop/src/renderer/tasks/new-task-composer.tsx"], ], /** The SQL names the schema clause must refuse, and those it must let stand. */ SQL_REFUSED: ["plan", "plan_id", "plan_board", "plan_board2", "plan_pin"], From 7b8066595120172f117065d8c90a2054279fbe0a Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:47:35 +0000 Subject: [PATCH 07/15] fix(desktop): hold New task's keys while a file is read, say when the model is not kept as default, and offer no server reconnect where no servers are handed Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/tasks/agent-composer.tsx | 22 +++-- .../src/renderer/tasks/new-task-composer.tsx | 24 ++++-- .../renderer/tasks/new-task-dialog.test.ts | 86 ++++++++++++++++++- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/tasks/agent-composer.tsx b/apps/desktop/src/renderer/tasks/agent-composer.tsx index 50b4c9fc89..eefd3b86cb 100644 --- a/apps/desktop/src/renderer/tasks/agent-composer.tsx +++ b/apps/desktop/src/renderer/tasks/agent-composer.tsx @@ -388,7 +388,7 @@ export function AgentComposer({ actions?: ReactNode; /** Whether the round button may send at all, beside the words: New task's is held while Agent mode has no repository. */ sendable?: boolean; - /** A key pressed in the field that neither its lists nor dictation took; answers whether it took it. */ + /** A key pressed in the field that neither its lists nor dictation took, and none while a file is still being read; answers whether it took it. */ onKey?: ((event: KeyboardEvent) => boolean) | undefined; /** What Escape in the field does where no list or dictation takes it; the field gives up the caret where none is given. */ onLeave?: (() => void) | undefined; @@ -438,11 +438,15 @@ export function AgentComposer({ } }); }; - const lapsed = lapsedServer({ - statusOf: statuses.statusOf, - accountsOf: statuses.accountsOf, - disabled: servers?.disabled ?? [], - }); + // Note that a box handing no servers, as New task's, offers none to reconnect either. + const lapsed = + servers === undefined + ? undefined + : lapsedServer({ + statusOf: statuses.statusOf, + accountsOf: statuses.accountsOf, + disabled: servers.disabled, + }); const unloaded = AsyncResult.isFailure(bundle) ? EDITOR_LOAD_FAILED : undefined; const words = composer.closed ?? @@ -679,7 +683,11 @@ export function AgentComposer({ maxLength={CODING_AGENT_BOUNDS.MAX_MESSAGE_CHARS} takesFocus={!(focusedByOwner && mountedFirst)} {...pickers.field} - onKey={(event) => pickers.field.onKey(event) || onKey?.(event) === true} + onKey={(event) => + pickers.field.onKey(event) || + // A file still being read holds the owner's keys too, as it holds Send. + (reading === 0 && onKey?.(event) === true) + } onEnter={() => { if (room.current !== null) submitPromptInput(room.current); }} diff --git a/apps/desktop/src/renderer/tasks/new-task-composer.tsx b/apps/desktop/src/renderer/tasks/new-task-composer.tsx index 1245d2e020..2d5b94952b 100644 --- a/apps/desktop/src/renderer/tasks/new-task-composer.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-composer.tsx @@ -97,6 +97,10 @@ import type { NewTaskIssue, NewTaskStart, TasksControl } from "./use-tasks-tab"; * the task and offers no Linear chip. */ +/** What the card says where a model picked on its chip was not kept as the account's default. */ +const DEFAULT_NOT_KEPT = + "The model could not be kept as your default. This agent still starts on it."; + /** ⌘Return: a bare task from an Agent-mode box with nothing in it. */ const BARE_CHORD: ShortcutChord = { key: SHORTCUT_KEY.ENTER }; @@ -163,11 +167,12 @@ function IssueChip({ ); } -/** The account's default model as read, and the chip's pick, which is written back as the default. */ +/** The account's default model as read, the chip's pick, which is written back as the default, and why that write did not take, where it did not. */ function useDefaultChoice( newTask: TasksControl["newTask"], -): readonly [ModelChoice | undefined, (next: ModelChoice) => void] { +): readonly [ModelChoice | undefined, (next: ModelChoice) => void, string | undefined] { const [choice, setChoice] = useState(undefined); + const [refused, setRefused] = useState(undefined); // Note that the control is read through a ref, because the tab hands a // new one on every render and the default is read once, as the card opens. const latest = useRef(newTask); @@ -181,12 +186,17 @@ function useDefaultChoice( live = false; }; }, []); - // A pick names the chip at once and is kept as the default, as Start's menu keeps it. + // A pick names the chip at once and is kept as the default, as Start's menu + // keeps it; a write that did not take says so under the card, and the + // agent still starts on the pick. const choose = (next: ModelChoice) => { setChoice(next); - void latest.current.writeDefault(next); + setRefused(undefined); + latest.current.writeDefault(next).then((answer) => { + setRefused("failure" in answer ? DEFAULT_NOT_KEPT : undefined); + }); }; - return [choice, choose]; + return [choice, choose, refused]; } export function NewTaskComposer({ @@ -217,7 +227,7 @@ export function NewTaskComposer({ const [attachments, setAttachments] = useState([]); const [issue, setIssue] = useState(undefined); const [chosen, setChosen] = useState(newTask.preset?.repository); - const [choice, choose] = useDefaultChoice(newTask); + const [choice, choose, choiceNote] = useDefaultChoice(newTask); const [starting, setStarting] = useState(false); const [note, setNote] = useState(undefined); // How many times ⌘L asked the Linear chip to open its menu. @@ -342,7 +352,7 @@ export function NewTaskComposer({ wording={MODE_WORDS[mode]} sendsEmpty={planning || onIssue} sendable={!blocked} - model={planning || choice === undefined ? undefined : { choice, note: undefined, choose }} + model={planning || choice === undefined ? undefined : { choice, note: choiceNote, choose }} models={newTask.models} readModels={newTask.readModels} usage={undefined} 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 00a9b63df3..a9d34dfd4a 100644 --- a/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts +++ b/apps/desktop/src/renderer/tasks/new-task-dialog.test.ts @@ -1,9 +1,17 @@ // @vitest-environment jsdom import assert from "node:assert/strict"; +import { CODING_AGENT_CALL_FAILURE } from "@luke/features/coding-agent-view"; +import { + INTEGRATION_PROVIDER, + INTEGRATION_PROVIDER_LIST, + INTEGRATION_STATUS, + type IntegrationProvider, + type IntegrationStatus, +} from "@luke/features/integration-wire"; import type { LinearIssueSummary } from "@luke/features/linear-wire"; import { type CatalogModel, MODEL_PROVIDER, type ModelChoice } from "@luke/features/models-wire"; -import { ACT_OUTCOME_STATUS } from "@luke/host/acts"; +import { ACT_KIND, ACT_OUTCOME_STATUS, type Act } from "@luke/host/acts"; import { OpenSettingsProvider } from "@luke/panel/open-settings"; import { fakeAppBridge } from "@luke/panel/testing/app-bridge"; import { installScrollIntoView } from "@luke/panel/testing/scroll-into-view"; @@ -36,6 +44,14 @@ import type { NewTaskAgent, NewTaskPreset, NewTaskStart } from "./use-tasks-tab" const roots: Root[] = []; +// SAFETY: the entries are built from INTEGRATION_PROVIDER_LIST, so every provider is a key. +const UNAVAILABLE = Object.fromEntries( + INTEGRATION_PROVIDER_LIST.map((provider) => [provider, INTEGRATION_STATUS.UNAVAILABLE]), +) as Record; + +/** Whether the account refuses to keep a model as its default, as a service that did not answer refuses it. */ +let refuseDefault = false; + /** The key New task keeps its mode under, which a test sets to stand the card in Plan mode. */ const MODE_KEY = "luke.new-task-mode"; @@ -70,6 +86,7 @@ beforeEach(() => { }); afterEach(() => { + refuseDefault = false; for (const root of roots.splice(0)) act(() => root.unmount()); vi.restoreAllMocks(); window.localStorage.clear(); @@ -130,7 +147,9 @@ async function mount( readDefault: () => Promise.resolve({ choice: DEFAULT_CHOICE }), writeDefault: (choice: ModelChoice) => { defaults.push(choice); - return Promise.resolve({ choice }); + return Promise.resolve( + refuseDefault ? { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } : { choice }, + ); }, start: ( name: string, @@ -304,6 +323,69 @@ test("a model picked on the chip is kept as the account's default, and the agent assert.deepEqual(card.agents[0]?.choice, picked); }); +test("a model the account refuses to keep as its default says so under the card, and the agent still starts on it", async () => { + refuseDefault = true; + const card = await mount(); + const chip = + document.querySelector(".agent-model-chip button") ?? + assert.fail("the model chip stands"); + act(() => chip.click()); + const fast = + document.querySelector('[role="switch"]') ?? + assert.fail("the menu offers Fast"); + act(() => fast.click()); + await settle(); + const said = card.note(); + card.type("Speed up the build"); + await card.key("Enter"); + + assert.match(said ?? "", /could not be kept as your default/u); + assert.equal(card.agents[0]?.choice?.model, "anthropic/claude-opus-5.5-fast"); +}); + +test("a server whose sign-in expired is offered nowhere in the card, which hands an agent no servers", async () => { + fakeAppBridge({ + act: (request: Act) => + Promise.resolve( + request.kind === ACT_KIND.INTEGRATIONS_STATUS + ? { + status: ACT_OUTCOME_STATUS.DONE, + value: { + integrations: { + ...UNAVAILABLE, + [INTEGRATION_PROVIDER.SLACK]: INTEGRATION_STATUS.EXPIRED, + }, + }, + } + : { status: ACT_OUTCOME_STATUS.UNKNOWN_ACT }, + ), + }); + const card = await mount(); + await settle(); + + assert.equal(card.dialog()?.textContent?.includes("sign-in expired"), false); +}); + +test("Command-Return while a file is still being read starts no bare task", async () => { + const card = await mount(); + const picker = + card.dialog()?.querySelector('input[type="file"]') ?? + assert.fail("the + has its file picker"); + const file = new File(["# Notes"], "notes.md", { type: "text/markdown" }); + Object.defineProperty(picker, "files", { configurable: true, value: [file] }); + act(() => { + picker.dispatchEvent(new Event("change", { bubbles: true })); + }); + pressInComposer(document, "Enter", { metaKey: true }); + await settle(); + + assert.deepEqual( + card.agents.filter((agent) => agent === undefined), + [], + "no bare task starts", + ); +}); + test("Shift+Return is a new line, and the agent is handed both lines", async () => { const card = await mount(); card.type("Fix the refresh\nand add a test"); From c6096d372f8818e4ee343ec01698cd500feb04b9 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:51:03 +0000 Subject: [PATCH 08/15] feat(desktop): open Home on New task's composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home mounts New task's composer card under the greeting, in place of its New task button, so the window's resting page opens on a prompt box. It starts the task in the workspace Home lists, takes the caret as the page shows unless a field elsewhere holds it, and keeps its keys from Home's row navigation; Escape lets go of it. The welcome page holds it under Luke's face and line, and the skeleton stands it too, in the same place, so words typed while the list is read survive its landing. The dialog stays for ⌘N and every other New task press. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-home.test.ts | 188 +++++++++++++++--- .../src/renderer/desktop/desktop-home.tsx | 187 ++++++++++------- .../renderer/desktop/desktop-shell.test.ts | 9 +- .../src/renderer/desktop/desktop-tasks.tsx | 2 +- apps/desktop/src/renderer/styles/desktop.css | 25 ++- 5 files changed, 301 insertions(+), 110 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-home.test.ts b/apps/desktop/src/renderer/desktop/desktop-home.test.ts index e978e91ac6..5cfb7ada6f 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-home.test.ts @@ -16,21 +16,31 @@ import { INBOX_WIRE_VERSION, type InboxItem, } from "@luke/features/inbox-wire"; +import type { ModelChoice } from "@luke/features/models-wire"; import type { TaskSummary } from "@luke/features/task-wire"; import { WORKSPACE_ROLE, type WorkspaceMember } from "@luke/features/workspace-wire"; import { ACT_KIND, ACT_OUTCOME_STATUS, type Act } from "@luke/host/acts"; import { EXPERIMENT } from "@luke/settings"; import { APP_COMMAND } from "@luke/settings/shortcuts"; import { experimentsOn } from "@luke/settings/testing"; +import { Effect } from "effect"; import * as AtomRegistry from "effect/reactivity/AtomRegistry"; import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { afterEach, beforeEach, test } from "vitest"; +import { afterEach, beforeAll, beforeEach, test } from "vitest"; import { fakeAppBridge } from "#testing/app-bridge"; import { handState } from "#testing/app-state"; +import { + composerEditor, + editorBundleLanded, + installEditorLayout, + pressInComposer, + typeInComposer, +} from "#testing/composer"; import { onRegistry } from "#testing/copy-feedback"; +import { installScrollIntoView } from "#testing/scroll-into-view"; import { tasksControl, waitingOf } from "#testing/tasks-control"; -import type { InboxChoice, TasksControl } from "../tasks/use-tasks-tab"; +import type { InboxChoice, NewTaskAgent, TasksControl } from "../tasks/use-tasks-tab"; import { commandKeyshortcuts } from "../tooltip"; import { DesktopHome } from "./desktop-home"; import { ListedAutomations } from "./listed-automations"; @@ -155,12 +165,23 @@ const MEMBERS: readonly WorkspaceMember[] = [ }, ]; +/** The workspace Home lists, which its composer starts a task in. */ +const LISTED_WORKSPACE = "workspace-personal"; + +/** The account's default model, which the composer's chip starts on. */ +const DEFAULT_CHOICE: ModelChoice = { model: "anthropic/claude-opus-5.5", effort: "high" }; + let root: Root | undefined; /** Every workspace whose automations were read, in order. */ let automationReads: string[] = []; +// Landed before any page renders, as the panel lands it after its first paint. +beforeAll(() => Effect.runPromise(editorBundleLanded)); + beforeEach(() => { automationReads = []; + installScrollIntoView(); + installEditorLayout(); fakeAppBridge({ act: (request: Act) => { if (request.kind === ACT_KIND.CODING_AGENTS_AGENT_INBOX) { @@ -195,20 +216,46 @@ beforeEach(() => { afterEach(() => { act(() => root?.unmount()); root = undefined; + window.localStorage.clear(); document.body.innerHTML = ""; document.head.innerHTML = ""; }); +/** A task the composer started: its name, repository, workspace, whether it plans, and the agent it asked for. */ +interface Started { + readonly name: string; + readonly repository: string | null; + readonly workspaceId: string | undefined; + readonly planning: boolean; + readonly agent: NewTaskAgent | undefined; +} + /** Every door the page opens, in order. */ interface Opened { readonly tasks: string[]; readonly inbox: (InboxChoice | "inbox")[]; newTasks: number; + readonly started: Started[]; +} + +/** The tab's New task control as Home's composer meets it, each start written to `started`. */ +function newTaskControl(started: Started[]): TasksControl["newTask"] { + const control = tasksControl().newTask; + return { + ...control, + workspaceId: LISTED_WORKSPACE, + repositoriesIn: () => ({ ...tasksControl().repositories, recent: ["acme/relay"] }), + readDefault: () => Promise.resolve({ choice: DEFAULT_CHOICE }), + start: (name, repository, workspaceId, planning, _from, agent) => { + started.push({ name, repository, workspaceId, planning, agent }); + return Promise.resolve(undefined); + }, + }; } /** The home page over `overrides`, every door it opens recorded. */ function mount(overrides: Partial = {}, accountName = "Dean Stratakos") { - const opened: Opened = { tasks: [], inbox: [], newTasks: 0 }; + const opened: Opened = { tasks: [], inbox: [], newTasks: 0, started: [] }; const tasks = tasksControl({ tasks: TASKS, listed: { tasks: TASKS, archived: [], ...HOLDING }, @@ -217,6 +264,7 @@ function mount(overrides: Partial = {}, accountName = "Dean Strata onNewTask: () => { opened.newTasks += 1; }, + newTask: newTaskControl(opened.started), inboxView: { open: false, chosen: undefined, @@ -236,7 +284,7 @@ function mount(overrides: Partial = {}, accountName = "Dean Strata onRegistry(AtomRegistry.make(), createElement(DesktopHome, { tasks, accountName })), ), ); - return { page: container, opened }; + return { page: container, opened, tasks }; } /** Lets every promise settled so far run its continuations. */ @@ -274,10 +322,10 @@ function waits(page: HTMLElement): string[] { ); } -test("while the task list is unread, Home stands in its own shape: New task, and placeholder rows under the Inbox and Recent, saying no greeting", () => { +test("while the task list is unread, Home stands in its own shape: New task's composer, and placeholder rows under the Inbox and Recent, saying no greeting", () => { const { page } = mount({ listRead: false }); - assert.ok(button(page, "New task")); + assert.ok(composerEditor(page), "the composer stands, hanging on no list"); assert.deepEqual(waits(page), ["Reading your inbox…", "Reading your tasks…"]); assert.ok(page.querySelectorAll(".luke-list-row .luke-skeleton").length > 0, "rows of blocks"); assert.doesNotMatch(page.textContent ?? "", /Welcome back|Nothing needs you/u); @@ -339,7 +387,7 @@ test("the home page greets the account, lists what needs it with each item's pla await settle(); assert.equal(page.querySelector("h1")?.textContent, "Welcome back, Dean"); - assert.ok(button(page, "New task")); + assert.ok(composerEditor(page), "New task's composer stands under the greeting"); assert.deepEqual(rows(page, "home-inbox"), [ "Email the invitee when an invite is withdrawn? · Teammate invitations", "Which billing account does the export read? · Billing export", @@ -364,17 +412,15 @@ test("Home offers no line to search every task, though Recent lists fewer than t assert.equal(page.querySelector(`[aria-keyshortcuts="${palette}"]`), null); }); -test("a waiting item's row opens the Inbox on its item, a recent plan opens the plan, and New plan asks for one", async () => { +test("a waiting item's row opens the Inbox on its item, and a recent plan opens the plan", async () => { const { page, opened } = mount(); await settle(); button(page, "Email the invitee").click(); button(page, "Usage alerts").click(); - button(page, "New task").click(); assert.deepEqual(opened.inbox, ["inbox", { taskId: INVITES.id, itemId: EMAIL.id }]); assert.deepEqual(opened.tasks, [TASKS[0]?.id]); - assert.equal(opened.newTasks, 1); }); test("a waiting item's row on Home reads as the Inbox's own: what it asks under its title, its plan and the attention mark before its time, and no unread dot on an item read", async () => { @@ -436,16 +482,26 @@ test("a refused read of the task list says so under Recent, with Retry, which as assert.equal(retries, 1); }); -test("an account with no plan yet is welcomed with New plan, and nothing the page says names the sidebar", () => { - const { page } = mount({ +test("an account with no plan yet is welcomed with New task's composer under Luke's line, and nothing the page says names the sidebar", async () => { + const { page, opened } = mount({ tasks: [], listed: { tasks: [], archived: [], inbox: [], inboxTasks: [] }, inbox: [], inboxTasks: [], }); + await settle(); + typeInComposer(page, "Set up the repository"); + pressInComposer(page, "Enter"); + await settle(); + assert.equal(page.querySelector("h1")?.textContent, "Start your first task"); - assert.ok(button(page, "New task")); + assert.ok(page.querySelector("[aria-label='Welcome']"), "the welcome stands"); + assert.deepEqual( + opened.started.map((each) => each.agent?.prompt), + ["Set up the repository"], + "its composer starts the first task", + ); assert.doesNotMatch(page.textContent ?? "", /sidebar/iu); }); @@ -476,9 +532,11 @@ test("an account with no plan yet whose only waiting question is a teammate's ag assert.equal(page.querySelector("h1")?.textContent, "Start your first task"); }); -test("↑ and ↓ move the focus through the rows from the window, and leave a key pressed elsewhere alone", async () => { - mount(); +test("↑ and ↓ move the focus through the rows from the window, once Escape lets go of the composer, and leave a key pressed elsewhere alone", async () => { + const { page } = mount(); await settle(); + pressInComposer(page, "Escape"); + assert.ok(document.activeElement === document.body, "Escape lets go of the composer's words"); const field = document.createElement("input"); document.body.append(field); const press = (key: string) => @@ -515,17 +573,99 @@ test("a workspace with no plan of its own, in an account with plans elsewhere, w assert.equal(page.querySelector('section[aria-labelledby="home-recent"]'), null); }); -test("↑ and ↓ pressed on New plan leave the focus on it", async () => { +test("Home's composer takes the caret as the page shows, and ↑, ↓, and Return typed in it are the composer's, moving none of Home's rows", async () => { + const { page, opened } = mount(); + await settle(); + const editor = composerEditor(page); + assert.ok(document.activeElement === editor, "the words take the caret"); + + typeInComposer(page, "Fix the refresh"); + const down = pressInComposer(page, "ArrowDown"); + const up = pressInComposer(page, "ArrowUp"); + + assert.ok(document.activeElement === editor, "the caret stays in the words"); + assert.equal(down.defaultPrevented || up.defaultPrevented, false, "Home takes neither arrow"); + pressInComposer(page, "Enter"); + await settle(); + assert.deepEqual(opened.tasks, [], "Return opened no row"); + assert.equal(opened.started.length, 1, "Return started the composer's task"); +}); + +test("Home's composer leaves the caret in a field the developer is typing in as the page shows", async () => { + const field = document.createElement("input"); + document.body.append(field); + field.focus(); const { page } = mount(); await settle(); - const newTask = button(page, "New task"); - newTask.focus(); - act(() => { - newTask.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true, cancelable: true }), - ); - }); - assert.ok(document.activeElement === newTask, "New plan keeps the focus"); + + assert.ok(composerEditor(page)); + assert.ok(document.activeElement === field, "the field keeps the caret"); +}); + +test("Agent mode: Return in Home's composer starts the task in the workspace Home lists, without planning, and an agent on the words with the chip's model", async () => { + const { page, opened } = mount(); + await settle(); + typeInComposer(page, "Make the sidebar keep its folds."); + pressInComposer(page, "Enter"); + await settle(); + + assert.deepEqual(opened.started, [ + { + name: "", + repository: "acme/relay", + workspaceId: LISTED_WORKSPACE, + planning: false, + agent: { + prompt: "Make the sidebar keep its folds.", + attachments: [], + choice: DEFAULT_CHOICE, + }, + }, + ]); + assert.equal(opened.newTasks, 0, "no dialog is asked for"); +}); + +test("Plan mode: Shift+Tab in Home's composer turns it on, and Return starts the plan and its call in the workspace Home lists, with no agent", async () => { + const { page, opened } = mount(); + await settle(); + pressInComposer(page, "Tab", { shiftKey: true }); + typeInComposer(page, "Rethink automations"); + pressInComposer(page, "Enter"); + await settle(); + + assert.deepEqual(opened.started, [ + { + name: "Rethink automations", + repository: "acme/relay", + workspaceId: LISTED_WORKSPACE, + planning: true, + agent: undefined, + }, + ]); +}); + +test("words typed in the composer while the task list is read are still there once it lands", async () => { + const { page, opened, tasks } = mount({ listRead: false }); + await settle(); + typeInComposer(page, "Speed up the build"); + + act(() => + root?.render( + onRegistry( + AtomRegistry.make(), + createElement(DesktopHome, { tasks: { ...tasks, listRead: true }, accountName: "Dean" }), + ), + ), + ); + await settle(); + assert.equal(page.querySelector("h1")?.textContent, "Welcome back, Dean"); + pressInComposer(page, "Enter"); + await settle(); + + assert.deepEqual( + opened.started.map((each) => each.agent?.prompt), + ["Speed up the build"], + ); }); test("Home lists every open item of the listed plans, a teammate's agent's included, though main counts only the account's own as waiting on it", async () => { diff --git a/apps/desktop/src/renderer/desktop/desktop-home.tsx b/apps/desktop/src/renderer/desktop/desktop-home.tsx index 6f929e35ed..bb69d5983a 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.tsx +++ b/apps/desktop/src/renderer/desktop/desktop-home.tsx @@ -1,9 +1,6 @@ import type { TaskSummary } from "@luke/features/task-wire"; import { useMinute } from "@luke/panel/tasks/task-time"; -import { APP_COMMAND, APP_SHORTCUTS } from "@luke/settings/shortcuts"; import { - BUTTON_VARIANT, - Button, ErrorState, FolderIcon, List, @@ -24,7 +21,6 @@ import { useState } from "react"; import { ACTOR_SIZE, ActorMark, CreatorHeard } from "../actor-ref"; import { MemberDirectory, useMemberDirectory } from "../member-directory"; import { RepositoryPicture } from "../repository-picture"; -import { Shortcut } from "../shortcut"; import { InboxItemRow, InboxListSkeleton } from "../tasks/inbox-list"; import { holderName, @@ -34,34 +30,50 @@ import { needsReader, waitsOnReader, } from "../tasks/inbox-model"; +import { NewTaskComposer } from "../tasks/new-task-composer"; import { RelativeTime } from "../tasks/relative-time"; import type { TasksControl } from "../tasks/use-tasks-tab"; import { useWaitingItems } from "../tasks/use-waiting-items"; -import { commandKeyshortcuts } from "../tooltip"; import { TaskStatusMark } from "./task-card"; import { useRowKeys } from "./use-row-keys"; /** - * desktop-home.tsx -- the home page: the column the window rests on with no task open, saying what needs the developer and the tasks saved latest, and offering New task. + * desktop-home.tsx -- the home page: the column the window rests on with no task open, opening on New task's composer and saying what needs the developer and the tasks saved latest. * * It is one column at a reading measure, as a chat app's empty page is: a - * greeting with New task beside it, then Inbox, every open Inbox item of the - * workspace listed, a teammate's agent's among them, as the Inbox's own row draws it, the - * task it is about after its title, a press opening the Inbox on it; then - * Recent, the tasks of the workspace listed saved latest, each a `ListRow` - * with its repository at its end before its time, a press opening it. Nothing waiting says so in the Inbox's own words. An account with no plan - * yet in any workspace is welcomed instead: Luke's face, one line of what he does, and New - * task. Nothing here names the sidebar, which may be folded away. + * greeting, and under it New task's composer card (`new-task-composer.tsx`) + * in place of a New task button, so the page opens on a prompt box the way + * Codex's and ChatGPT's do; then Inbox, every open Inbox item of the + * workspace listed, a teammate's agent's among them, as the Inbox's own row + * draws it, the task it is about after its title, a press opening the Inbox + * on it; then Recent, the tasks of the workspace listed saved latest, each a + * `ListRow` with its repository at its end before its time, a press opening + * it. Nothing waiting says so in the Inbox's own words. An account with no + * plan yet in any workspace is welcomed instead: Luke's face, one line, and + * the composer under them. Nothing here names the sidebar, which may be + * folded away. + * + * The composer starts the task in the workspace Home lists, as the dialog + * would: in Agent mode a task with a coding agent on the words, opened on + * its tab, and in Plan mode the plan and a call with Luke. The dialog stays + * for ⌘N and every other New task press; only Home holds the card inline. + * Its words take the caret as the page shows, unless the developer is + * typing in a field elsewhere, as an agent's box decides; a key pressed in + * the card is the card's, and Escape there with nothing in it to close lets + * go of the words. Note that its words, files, and issue are the card's own + * state, kept in no draft store, so leaving Home lets them go. * * ↑ and ↓ move through its rows, a launcher's way, from the window itself * as well as from a row, and Return opens the row reached, so a launch is a - * key or two from a task; a key pressed anywhere else, the sidebar or a - * field, is left to it. Until the task list is read the page stands in its - * own shape: New task, which the welcome offers too, and blocks where the - * greeting, the sections' headings, and their rows will stand. It says no - * word that hangs on the list, so a new account's welcome replaces it - * without having claimed otherwise. A read refused says so under Recent, - * with Retry, over whatever an earlier read listed. + * key or two from a task; a key pressed anywhere else, the composer, the + * sidebar, or a field, is left to it. Until the task list is read the page + * stands in its own shape: the composer, which hangs on no list, and blocks + * where the greeting, the sections' headings, and their rows will stand. + * The composer stands in the one place through every state, so the list + * landing keeps what was typed meanwhile. It says no word that hangs on the + * list, so a new account's welcome replaces it without having claimed + * otherwise. A read refused says so under Recent, with Retry, over whatever + * an earlier read listed. */ /** How many tasks Recent lists. */ @@ -106,21 +118,6 @@ function recentTasks(tasks: readonly TaskSummary[]): TaskSummary[] { return orderTasks(tasks, TASK_ORDER.UPDATED).slice(0, RECENT_COUNT); } -/** New task, with its chord: the home page's, and the empty Factory's (`desktop-factory.tsx`). */ -function NewTaskButton({ tasks }: { tasks: TasksControl }): React.JSX.Element { - return ( - - ); -} - /** A plan as a `ListRow`: the owner's picture, the name, and at its end the repository, its creator's face where a teammate created it, and its mark and time. */ export function RecentRow({ task, @@ -264,8 +261,36 @@ function HomeRecent({ ); } -/** A new account's welcome: Luke's face and New task. */ -function Welcome({ tasks }: { tasks: TasksControl }): React.JSX.Element { +/** + * New task's composer, inline under the greeting as a chat app's empty page + * opens on its prompt box, starting the task in the workspace Home lists; + * nothing while signed out, where nothing could start. Note that the press + * that opens the dialog chooses its repository for the dialog alone, so + * this card starts on none of it. Escape with nothing in the card to close + * lets go of the words, so ↑ and ↓ reach the rows again. + */ +function HomeComposer({ tasks }: { tasks: TasksControl }): React.JSX.Element | null { + if (!tasks.signedIn) return null; + return ( +
+ +
+ ); +} + +/** Hands the focus back to the page from whatever holds it, as Escape from an agent's box does. */ +function letGoOfFocus(): void { + const active = document.activeElement; + if (active instanceof HTMLElement) active.blur(); +} + +/** A new account's welcome: Luke's face and the line over the composer. */ +function Welcome(): React.JSX.Element { return (
); } @@ -316,31 +340,22 @@ function RecentSkeleton(): React.JSX.Element { } /** - * The page while the task list is first read, in the shape of the page an - * account with tasks lands on: the greeting's block beside New task, then - * the Inbox's and Recent's heading blocks over rows of blocks, so the page - * lands where the blocks stood. + * The sections while the task list is first read, in the shape of the page + * an account with tasks lands on: the Inbox's and Recent's heading blocks + * over rows of blocks, so the page lands where the blocks stood. */ -function HomeSkeleton({ tasks }: { tasks: TasksControl }): React.JSX.Element { +function SectionsSkeleton(): React.JSX.Element { return ( -
-
-
- - - - -
-
- - -
-
- - -
+ <> +
+ +
-
+
+ + +
+ ); } @@ -356,32 +371,52 @@ export function DesktopHome({ const now = useMinute(); const [column, setColumn] = useState(null); useRowKeys(column, tasks.shown); - if (!tasks.listRead && !tasks.listFailed) return ; + const reading = !tasks.listRead && !tasks.listFailed; // Only an account with no task in any workspace, and nothing an agent with // no task waits on it for, is new; a workspace of its own with none still // says what needs it elsewhere, while a teammate's agent's question, which // waits on the teammate and which Home does not list, leaves it new. const fresh = + !reading && + !tasks.listFailed && tasks.tasks.length === 0 && tasks.archived.length === 0 && !tasks.inboxTaskless.some((each) => waitsOnReader(each, tasks.me)); - if (!tasks.listFailed && fresh) return ; + // Note that the composer stands in one place in every state of the page, + // so the list landing, or saying the account is new, keeps the words typed + // while it was read and the caret in them. return ( - // The Inbox's agent cards name who started each agent, and Recent's rows - // who created each teammate's task, read against the listed workspace's members. - -
-
-
- - {greeting(accountName)} - - -
- - +
+
+
+ {reading ? ( +
+ + + +
+ ) : fresh ? ( + + ) : ( +
+ + {greeting(accountName)} + +
+ )} +
+ {reading ? ( + + ) : fresh ? null : ( + // The Inbox's agent cards name who started each agent, and Recent's rows + // who created each teammate's task, read against the listed workspace's members. + + + + + )}
- +
); } diff --git a/apps/desktop/src/renderer/desktop/desktop-shell.test.ts b/apps/desktop/src/renderer/desktop/desktop-shell.test.ts index 54cab00a8e..aa32cb0481 100644 --- a/apps/desktop/src/renderer/desktop/desktop-shell.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-shell.test.ts @@ -247,14 +247,15 @@ test("the title bar's buttons, each wrapped in its hint, tell a reader the chord ]); }); -test("the home page welcomes an account with no plan, and its New task raises the dialog over the window, which Escape puts away", () => { +test("the home page welcomes an account with no plan on New task's composer, and the title bar's New task still raises the dialog over the window, which Escape puts away", () => { const page = mount(false); const home = page.querySelector('section[aria-label="Home"]'); assert.ok(home, "the home page stands"); - const newTask = [...home.querySelectorAll("button")].find((button) => - button.textContent?.startsWith("New task"), + assert.ok(home.querySelector(".new-task-composer"), "the home page holds New task's composer"); + const newTask = page.querySelector( + '.title-bar-controls:not([data-edge]) button[aria-label="New task"]', ); - assert.ok(newTask, "the home page offers New plan"); + assert.ok(newTask, "the title bar offers New task"); act(() => newTask.click()); const dialog = document.querySelector('[role="dialog"][aria-modal="true"]'); assert.equal(dialog?.querySelector("h2")?.textContent, "New task"); diff --git a/apps/desktop/src/renderer/desktop/desktop-tasks.tsx b/apps/desktop/src/renderer/desktop/desktop-tasks.tsx index 2ed6318b72..90553b038f 100644 --- a/apps/desktop/src/renderer/desktop/desktop-tasks.tsx +++ b/apps/desktop/src/renderer/desktop/desktop-tasks.tsx @@ -949,7 +949,7 @@ export function DesktopTasks({ {secondCall} ); - // With no task open the column says so and offers New task, whose + // With no task open the column opens on New task's composer, and ⌘N's // dialog stands over it as it stands over a task. case TASKS_PAGE.HOME: // A launch reopening the task last open draws the window's ground diff --git a/apps/desktop/src/renderer/styles/desktop.css b/apps/desktop/src/renderer/styles/desktop.css index aacae6c00b..29c521062e 100644 --- a/apps/desktop/src/renderer/styles/desktop.css +++ b/apps/desktop/src/renderer/styles/desktop.css @@ -7047,6 +7047,15 @@ ul.actor-stack { margin: clamp(var(--luke-space-0), 7vh, var(--luke-space-64)) auto 0; } +/* The greeting and New task's composer under it (`new-task-composer.tsx`), + closer to each other than the sections below them, the card as wide as + the column, as the rows' own fills are. */ +.home-start { + display: flex; + flex-direction: column; + gap: var(--luke-space-16); +} + .home-head { display: flex; align-items: center; @@ -7223,16 +7232,22 @@ ul.actor-stack { margin: calc(-1 * var(--luke-space-20)) 0 0; } -/* A new account's welcome: Luke's face on a tile, a title, a line, and New plan, centred. */ +/* A new account's welcome: Luke's face on a tile and a title, centred over + the composer, the column standing in the middle of the window. */ +.home-scroll-welcome { + display: flex; + flex-direction: column; +} + +.home-scroll-welcome .home-column { + margin: auto; +} + .home-welcome { display: flex; - min-height: 0; - flex: 1; flex-direction: column; align-items: center; - justify-content: center; gap: var(--luke-space-12); - padding: var(--luke-page-inset); text-align: center; } From 9dd974f80cf36d12e168e1c6addc2dfd9b8a520f Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:15:51 +0000 Subject: [PATCH 09/15] fix(desktop): keep Home's composer on the mode and model the dialog over it chose Home's New task card and the dialog's stand at once, and each read the mode and the default model only as it opened, so turning Plan on or picking a model in the dialog left Home's card on the old one. The mode is now one store every card reads, and a model kept as the default in one card is followed by another whose chip was not picked in it. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-home.test.ts | 103 +++++++++++++++++- .../src/renderer/tasks/new-task-composer.tsx | 22 +++- packages/panel/src/tasks/new-task-mode.ts | 49 ++++++--- 3 files changed, 158 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-home.test.ts b/apps/desktop/src/renderer/desktop/desktop-home.test.ts index 69af22de0c..7ba6f9dda0 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-home.test.ts @@ -16,7 +16,7 @@ import { INBOX_WIRE_VERSION, type InboxItem, } from "@luke/features/inbox-wire"; -import type { ModelChoice } from "@luke/features/models-wire"; +import { type CatalogModel, MODEL_PROVIDER, type ModelChoice } from "@luke/features/models-wire"; import type { TaskSummary } from "@luke/features/task-wire"; import { WORKSPACE_ROLE, type WorkspaceMember } from "@luke/features/workspace-wire"; import { ACT_KIND, ACT_OUTCOME_STATUS, type Act } from "@luke/host/acts"; @@ -29,7 +29,7 @@ import { APP_COMMAND } from "@luke/settings/shortcuts"; import { experimentsOn } from "@luke/settings/testing"; import { Effect } from "effect"; import * as AtomRegistry from "effect/reactivity/AtomRegistry"; -import { act, createElement } from "react"; +import { act, createElement, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeAll, beforeEach, test } from "vitest"; import { @@ -40,6 +40,7 @@ import { typeInComposer, } from "#testing/composer"; import { tasksControl, waitingOf } from "#testing/tasks-control"; +import { NewTaskDialog } from "../tasks/new-task-dialog"; import type { InboxChoice, NewTaskAgent, TasksControl } from "../tasks/use-tasks-tab"; import { commandKeyshortcuts } from "../tooltip"; import { DesktopHome } from "./desktop-home"; @@ -921,3 +922,101 @@ test("a recent task a teammate created wears their face before its time, and the }); assert.deepEqual(faces, [[INVITES.name, CHARLES_PICTURE]]); }); + +/** The models the composers' chips offer: Opus, and its fast version. */ +const MODELS: readonly CatalogModel[] = [ + { + id: "anthropic/claude-opus-5.5", + name: "Claude Opus 5.5", + provider: MODEL_PROVIDER.ANTHROPIC, + efforts: ["low", "high"], + }, + { + id: "anthropic/claude-opus-5.5-fast", + name: "Claude Opus 5.5 (Fast)", + provider: MODEL_PROVIDER.ANTHROPIC, + efforts: ["low", "high"], + }, +]; + +/** Home with New task's dialog open over it, as ⌘N opens it, each start written down; Escape in the dialog's words puts it away. */ +async function mountUnderDialog() { + const started: Started[] = []; + const newTask = { ...newTaskControl(started), models: MODELS }; + const tasks = tasksControl({ + tasks: TASKS, + listed: { tasks: TASKS, archived: [], ...HOLDING }, + ...HOLDING, + }); + function Window() { + const [open, setOpen] = useState(true); + const control = { ...newTask, open, onClose: () => setOpen(false) }; + return createElement( + "div", + null, + createElement(DesktopHome, { + tasks: { ...tasks, newTask: control }, + accountName: "Dean Stratakos", + }), + createElement(NewTaskDialog, { newTask: control }), + ); + } + const container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + act(() => root?.render(onRegistry(AtomRegistry.make(), createElement(Window)))); + await settle(); + const dialog = () => + document.querySelector('[role="dialog"][aria-modal="true"]') ?? + assert.fail("the dialog stands"); + const home = () => + container.querySelector(".home-start") ?? assert.fail("Home's composer stands"); + return { + started, + dialog, + home, + /** Puts the dialog away with nothing started, as Escape in its words does. */ + closeDialog: async () => { + pressInComposer(dialog(), "Escape"); + await settle(); + assert.equal(document.querySelector('[role="dialog"][aria-modal="true"]'), null); + }, + }; +} + +test("Plan turned on in the dialog over Home and the dialog put away, Home's composer is in Plan mode too, and starts the plan and its call", async () => { + const page = await mountUnderDialog(); + pressInComposer(page.dialog(), "Tab", { shiftKey: true }); + await page.closeDialog(); + + const toggle = page.home().querySelector('button[aria-label="Plan with Luke first"]'); + assert.equal(toggle?.getAttribute("aria-pressed"), "true", "Home's card shows Plan mode"); + typeInComposer(page.home(), "Rethink automations"); + pressInComposer(page.home(), "Enter"); + await settle(); + + assert.deepEqual( + page.started.map(({ planning, agent }) => ({ planning, agent })), + [{ planning: true, agent: undefined }], + ); +}); + +test("a model picked as the default in the dialog over Home is the one Home's composer then starts its agent on", async () => { + const page = await mountUnderDialog(); + const chip = + page.dialog().querySelector(".agent-model-chip button") ?? + assert.fail("the dialog's model chip stands"); + act(() => chip.click()); + const fast = + page.dialog().querySelector('[role="switch"]') ?? + assert.fail("the menu offers Fast"); + act(() => fast.click()); + await settle(); + await page.closeDialog(); + + typeInComposer(page.home(), "Speed up the build"); + pressInComposer(page.home(), "Enter"); + await settle(); + + assert.equal(page.started[0]?.agent?.choice?.model, "anthropic/claude-opus-5.5-fast"); +}); diff --git a/apps/desktop/src/renderer/tasks/new-task-composer.tsx b/apps/desktop/src/renderer/tasks/new-task-composer.tsx index 2d5b94952b..7182f43b84 100644 --- a/apps/desktop/src/renderer/tasks/new-task-composer.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-composer.tsx @@ -63,7 +63,8 @@ import type { NewTaskIssue, NewTaskStart, TasksControl } from "./use-tasks-tab"; * the chip, which starts the agent at once (`draft-agent-tab.tsx`), so an * agent refused leaves the words there with why and Retry. The model chip * starts on the account's default, and a pick here is written as the - * default, as Start's menu writes it. + * default, as Start's menu writes it, which another card standing, Home's + * under the dialog, follows where its own chip was not picked. * - Plan mode: the task starts from the plan template and opens its call * with Luke, as New task always did. Start always stands, an empty box * starting today's blank plan, and the model chip stands down, since the @@ -167,6 +168,13 @@ function IssueChip({ ); } +/** + * Every mounted card's hearing of a model picked as the default in another, + * Home's and the dialog's over it standing at once, so a card whose chip was + * not picked in it follows the default the other kept. + */ +const defaultPicks = new Set<(choice: ModelChoice) => void>(); + /** The account's default model as read, the chip's pick, which is written back as the default, and why that write did not take, where it did not. */ function useDefaultChoice( newTask: TasksControl["newTask"], @@ -177,21 +185,33 @@ function useDefaultChoice( // new one on every render and the default is read once, as the card opens. const latest = useRef(newTask); latest.current = newTask; + // Whether the chip was picked in this card, whose pick then stands over one made in another. + const pickedHere = useRef(false); + // This card's hearing of another's pick, left out when it tells the others its own. + const hearing = useRef<((choice: ModelChoice) => void) | undefined>(undefined); useEffect(() => { let live = true; latest.current.readDefault().then((answer) => { if (live && !("failure" in answer)) setChoice((picked) => picked ?? answer.choice); }); + const hear = (next: ModelChoice) => { + if (!pickedHere.current) setChoice(next); + }; + hearing.current = hear; + defaultPicks.add(hear); return () => { live = false; + defaultPicks.delete(hear); }; }, []); // A pick names the chip at once and is kept as the default, as Start's menu // keeps it; a write that did not take says so under the card, and the // agent still starts on the pick. const choose = (next: ModelChoice) => { + pickedHere.current = true; setChoice(next); setRefused(undefined); + for (const hear of defaultPicks) if (hear !== hearing.current) hear(next); latest.current.writeDefault(next).then((answer) => { setRefused("failure" in answer ? DEFAULT_NOT_KEPT : undefined); }); diff --git a/packages/panel/src/tasks/new-task-mode.ts b/packages/panel/src/tasks/new-task-mode.ts index 80154c9013..0381409a8e 100644 --- a/packages/panel/src/tasks/new-task-mode.ts +++ b/packages/panel/src/tasks/new-task-mode.ts @@ -14,7 +14,7 @@ * Agent mode. */ -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; /** What New task starts: a coding agent on the words, or a plan and a call with Luke. */ export const NEW_TASK_MODE = { @@ -38,17 +38,40 @@ function readMode(): NewTaskMode { } } -/** The mode New task offers and a setter that keeps it for the next one. */ -export function useNewTaskMode(): readonly [NewTaskMode, (mode: NewTaskMode) => void] { - const [mode, setMode] = useState(readMode); +/** Every mounted card's hearing of the mode, told each time one of them turns it. */ +const listeners = new Set<() => void>(); + +/** A turn storage refused to keep, which the window holds for as long as it stands. */ +let unkept: NewTaskMode | undefined; + +/** The mode as the window holds it now: storage's, read afresh, unless it refused the last turn. */ +function modeNow(): NewTaskMode { + return unkept ?? readMode(); +} + +function hear(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Turns the mode for every card standing and keeps it for the next one. */ +function turn(mode: NewTaskMode): void { // A write that fails costs only the preference, so it is not worth a word - // on screen. - useEffect(() => { - try { - window.localStorage.setItem(NEW_TASK_MODE_KEY, mode); - } catch { - // The card keeps the choice for as long as it stands. - } - }, [mode]); - return [mode, setMode]; + // on screen; the window keeps the choice for as long as it stands. + try { + window.localStorage.setItem(NEW_TASK_MODE_KEY, mode); + unkept = undefined; + } catch { + unkept = mode; + } + for (const listener of listeners) listener(); +} + +/** + * The mode New task offers and a setter that keeps it for the next one. Note + * that the cards standing at once, Home's and the dialog's over it, read one + * mode, so a turn in one is the other's too. + */ +export function useNewTaskMode(): readonly [NewTaskMode, (mode: NewTaskMode) => void] { + return [useSyncExternalStore(hear, modeNow), turn]; } From 2f20a13d6e323e6484f17af067e67eb7d8a835c0 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:18:46 +0000 Subject: [PATCH 10/15] fix(desktop): tell other New task cards only a default model that was kept A model picked in one card moved the other cards' chips before its write landed, so a refused write, or one overtaken by a later pick, left them on a model that was never the default. The others now follow the latest pick alone, once its write answers that it was kept. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-home.test.ts | 41 +++++++++++++++---- .../src/renderer/tasks/new-task-composer.tsx | 11 ++++- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-home.test.ts b/apps/desktop/src/renderer/desktop/desktop-home.test.ts index 7ba6f9dda0..6b48027392 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-home.test.ts @@ -8,7 +8,10 @@ import { AUTOMATION_TRIGGER, type AutomationSummary, } from "@luke/features/automation-wire"; -import type { CodingAgentInboxAnswerView } from "@luke/features/coding-agent-view"; +import { + CODING_AGENT_CALL_FAILURE, + type CodingAgentInboxAnswerView, +} from "@luke/features/coding-agent-view"; import { CODING_AGENT_STATUS } from "@luke/features/coding-agent-wire"; import { INBOX_RESPONSE, @@ -939,10 +942,15 @@ const MODELS: readonly CatalogModel[] = [ }, ]; -/** Home with New task's dialog open over it, as ⌘N opens it, each start written down; Escape in the dialog's words puts it away. */ -async function mountUnderDialog() { +/** Home with New task's dialog open over it, as ⌘N opens it, each start written down, a model kept as the default unless `keeps` says otherwise; Escape in the dialog's words puts it away. */ +async function mountUnderDialog(keeps = true) { const started: Started[] = []; - const newTask = { ...newTaskControl(started), models: MODELS }; + const newTask: TasksControl["newTask"] = { + ...newTaskControl(started), + models: MODELS, + writeDefault: (choice) => + Promise.resolve(keeps ? { choice } : { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), + }; const tasks = tasksControl({ tasks: TASKS, listed: { tasks: TASKS, archived: [], ...HOLDING }, @@ -1001,17 +1009,22 @@ test("Plan turned on in the dialog over Home and the dialog put away, Home's com ); }); -test("a model picked as the default in the dialog over Home is the one Home's composer then starts its agent on", async () => { - const page = await mountUnderDialog(); +/** Picks Fast on the dialog's model chip, which asks for it to be kept as the account's default. */ +async function pickFastInDialog(dialog: HTMLElement): Promise { const chip = - page.dialog().querySelector(".agent-model-chip button") ?? + dialog.querySelector(".agent-model-chip button") ?? assert.fail("the dialog's model chip stands"); act(() => chip.click()); const fast = - page.dialog().querySelector('[role="switch"]') ?? + dialog.querySelector('[role="switch"]') ?? assert.fail("the menu offers Fast"); act(() => fast.click()); await settle(); +} + +test("a model picked as the default in the dialog over Home is the one Home's composer then starts its agent on", async () => { + const page = await mountUnderDialog(); + await pickFastInDialog(page.dialog()); await page.closeDialog(); typeInComposer(page.home(), "Speed up the build"); @@ -1020,3 +1033,15 @@ test("a model picked as the default in the dialog over Home is the one Home's co assert.equal(page.started[0]?.agent?.choice?.model, "anthropic/claude-opus-5.5-fast"); }); + +test("a model picked in the dialog over Home that the account's default refused leaves Home's composer on the default it read", async () => { + const page = await mountUnderDialog(false); + await pickFastInDialog(page.dialog()); + await page.closeDialog(); + + typeInComposer(page.home(), "Speed up the build"); + pressInComposer(page.home(), "Enter"); + await settle(); + + assert.deepEqual(page.started[0]?.agent?.choice, DEFAULT_CHOICE); +}); diff --git a/apps/desktop/src/renderer/tasks/new-task-composer.tsx b/apps/desktop/src/renderer/tasks/new-task-composer.tsx index 7182f43b84..56cacf31c1 100644 --- a/apps/desktop/src/renderer/tasks/new-task-composer.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-composer.tsx @@ -175,6 +175,9 @@ function IssueChip({ */ const defaultPicks = new Set<(choice: ModelChoice) => void>(); +/** How many default writes any card has asked for, so only the latest one kept is told to the others. */ +let defaultWrites = 0; + /** The account's default model as read, the chip's pick, which is written back as the default, and why that write did not take, where it did not. */ function useDefaultChoice( newTask: TasksControl["newTask"], @@ -211,9 +214,15 @@ function useDefaultChoice( pickedHere.current = true; setChoice(next); setRefused(undefined); - for (const hear of defaultPicks) if (hear !== hearing.current) hear(next); + defaultWrites += 1; + const write = defaultWrites; latest.current.writeDefault(next).then((answer) => { setRefused("failure" in answer ? DEFAULT_NOT_KEPT : undefined); + // Note that the others follow only a default that was kept, and only + // the one asked for last, so a write refused, or one overtaken by a + // later pick, moves no other card's chip. + if ("failure" in answer || write !== defaultWrites) return; + for (const hear of defaultPicks) if (hear !== hearing.current) hear(answer.choice); }); }; return [choice, choose, refused]; From 830a1d3c409da20da0ae9d93940fd8f946b407b2 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:22:20 +0000 Subject: [PATCH 11/15] fix(desktop): have other New task cards read the default the account kept Following one card's answered write could leave the others on the wrong model when writes answered out of order, a refused later pick discarding an earlier one that was kept. Once every write has answered, the other cards whose chip was not picked read the default again and take what the account stored. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-home.test.ts | 70 +++++++++++++++++-- .../src/renderer/tasks/new-task-composer.tsx | 51 ++++++++------ 2 files changed, 93 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-home.test.ts b/apps/desktop/src/renderer/desktop/desktop-home.test.ts index 6b48027392..64f946d8c5 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-home.test.ts @@ -942,14 +942,47 @@ const MODELS: readonly CatalogModel[] = [ }, ]; -/** Home with New task's dialog open over it, as ⌘N opens it, each start written down, a model kept as the default unless `keeps` says otherwise; Escape in the dialog's words puts it away. */ -async function mountUnderDialog(keeps = true) { +/** How the account answers a model kept as its default: keeps it, refuses it, or holds the answer for the test to give. */ +const WRITE = { KEEP: "keep", REFUSE: "refuse", HOLD: "hold" } as const; + +/** A write of the default held unanswered, and its two answers. */ +interface HeldWrite { + readonly choice: ModelChoice; + readonly keep: () => void; + readonly refuse: () => void; +} + +/** + * Home with New task's dialog open over it, as ⌘N opens it, each start + * written down, and the account's default as a store: read as it stands, + * and written as `write` says. Escape in the dialog's words puts it away. + */ +async function mountUnderDialog(write: (typeof WRITE)[keyof typeof WRITE] = WRITE.KEEP) { const started: Started[] = []; + const held: HeldWrite[] = []; + let stored = DEFAULT_CHOICE; + const refusal = { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED } as const; const newTask: TasksControl["newTask"] = { ...newTaskControl(started), models: MODELS, - writeDefault: (choice) => - Promise.resolve(keeps ? { choice } : { failure: CODING_AGENT_CALL_FAILURE.UNANSWERED }), + readDefault: () => Promise.resolve({ choice: stored }), + writeDefault: (choice) => { + if (write === WRITE.REFUSE) return Promise.resolve(refusal); + if (write === WRITE.KEEP) { + stored = choice; + return Promise.resolve({ choice }); + } + return new Promise((resolve) => + held.push({ + choice, + keep: () => { + stored = choice; + resolve({ choice }); + }, + refuse: () => resolve(refusal), + }), + ); + }, }; const tasks = tasksControl({ tasks: TASKS, @@ -981,6 +1014,8 @@ async function mountUnderDialog(keeps = true) { container.querySelector(".home-start") ?? assert.fail("Home's composer stands"); return { started, + /** The writes of the default still unanswered, the first asked first. */ + held, dialog, home, /** Puts the dialog away with nothing started, as Escape in its words does. */ @@ -1009,12 +1044,13 @@ test("Plan turned on in the dialog over Home and the dialog put away, Home's com ); }); -/** Picks Fast on the dialog's model chip, which asks for it to be kept as the account's default. */ +/** Turns Fast on the dialog's model chip, on or off, which asks for the model to be kept as the account's default. */ async function pickFastInDialog(dialog: HTMLElement): Promise { const chip = dialog.querySelector(".agent-model-chip button") ?? assert.fail("the dialog's model chip stands"); - act(() => chip.click()); + // The menu stays open after a turn of Fast, so a second turn finds it standing. + if (dialog.querySelector('[role="switch"]') === null) act(() => chip.click()); const fast = dialog.querySelector('[role="switch"]') ?? assert.fail("the menu offers Fast"); @@ -1035,7 +1071,7 @@ test("a model picked as the default in the dialog over Home is the one Home's co }); test("a model picked in the dialog over Home that the account's default refused leaves Home's composer on the default it read", async () => { - const page = await mountUnderDialog(false); + const page = await mountUnderDialog(WRITE.REFUSE); await pickFastInDialog(page.dialog()); await page.closeDialog(); @@ -1045,3 +1081,23 @@ test("a model picked in the dialog over Home that the account's default refused assert.deepEqual(page.started[0]?.agent?.choice, DEFAULT_CHOICE); }); + +test("a pick in the dialog the account kept is the one Home's composer starts on, though a later pick answered first was refused", async () => { + const page = await mountUnderDialog(WRITE.HOLD); + await pickFastInDialog(page.dialog()); + await pickFastInDialog(page.dialog()); + const [first, second] = page.held; + assert.equal(first?.choice.model, "anthropic/claude-opus-5.5-fast", "Fast was asked first"); + assert.equal(second?.choice.model, "anthropic/claude-opus-5.5", "Opus was asked after it"); + act(() => second?.refuse()); + await settle(); + act(() => first?.keep()); + await settle(); + await page.closeDialog(); + + typeInComposer(page.home(), "Speed up the build"); + pressInComposer(page.home(), "Enter"); + await settle(); + + assert.equal(page.started[0]?.agent?.choice?.model, "anthropic/claude-opus-5.5-fast"); +}); diff --git a/apps/desktop/src/renderer/tasks/new-task-composer.tsx b/apps/desktop/src/renderer/tasks/new-task-composer.tsx index 56cacf31c1..8c66115b0d 100644 --- a/apps/desktop/src/renderer/tasks/new-task-composer.tsx +++ b/apps/desktop/src/renderer/tasks/new-task-composer.tsx @@ -169,14 +169,14 @@ function IssueChip({ } /** - * Every mounted card's hearing of a model picked as the default in another, - * Home's and the dialog's over it standing at once, so a card whose chip was - * not picked in it follows the default the other kept. + * Every mounted card's hearing that the default may have moved, Home's and + * the dialog's over it standing at once, so a card whose chip was not picked + * in it reads the default again and follows what the account kept. */ -const defaultPicks = new Set<(choice: ModelChoice) => void>(); +const defaultMoves = new Set<() => void>(); -/** How many default writes any card has asked for, so only the latest one kept is told to the others. */ -let defaultWrites = 0; +/** How many default writes any card is waiting on, the others told only once none is. */ +let pendingWrites = 0; /** The account's default model as read, the chip's pick, which is written back as the default, and why that write did not take, where it did not. */ function useDefaultChoice( @@ -185,26 +185,35 @@ function useDefaultChoice( const [choice, setChoice] = useState(undefined); const [refused, setRefused] = useState(undefined); // Note that the control is read through a ref, because the tab hands a - // new one on every render and the default is read once, as the card opens. + // new one on every render; the default is read as the card opens, and + // again where another card's writes have settled. const latest = useRef(newTask); latest.current = newTask; - // Whether the chip was picked in this card, whose pick then stands over one made in another. + // Whether the chip was picked in this card, whose pick then stands over the default. const pickedHere = useRef(false); - // This card's hearing of another's pick, left out when it tells the others its own. - const hearing = useRef<((choice: ModelChoice) => void) | undefined>(undefined); + // This card's hearing of another's writes, left out when it tells the others of its own. + const hearing = useRef<(() => void) | undefined>(undefined); useEffect(() => { let live = true; latest.current.readDefault().then((answer) => { if (live && !("failure" in answer)) setChoice((picked) => picked ?? answer.choice); }); - const hear = (next: ModelChoice) => { - if (!pickedHere.current) setChoice(next); + // How many reads of the default this card asked for, so only the latest one's answer stands. + let reads = 0; + const hear = () => { + if (pickedHere.current) return; + reads += 1; + const read = reads; + latest.current.readDefault().then((answer) => { + if (!live || pickedHere.current || read !== reads || "failure" in answer) return; + setChoice(answer.choice); + }); }; hearing.current = hear; - defaultPicks.add(hear); + defaultMoves.add(hear); return () => { live = false; - defaultPicks.delete(hear); + defaultMoves.delete(hear); }; }, []); // A pick names the chip at once and is kept as the default, as Start's menu @@ -214,15 +223,15 @@ function useDefaultChoice( pickedHere.current = true; setChoice(next); setRefused(undefined); - defaultWrites += 1; - const write = defaultWrites; + pendingWrites += 1; latest.current.writeDefault(next).then((answer) => { setRefused("failure" in answer ? DEFAULT_NOT_KEPT : undefined); - // Note that the others follow only a default that was kept, and only - // the one asked for last, so a write refused, or one overtaken by a - // later pick, moves no other card's chip. - if ("failure" in answer || write !== defaultWrites) return; - for (const hear of defaultPicks) if (hear !== hearing.current) hear(answer.choice); + pendingWrites -= 1; + // Note that the others read the default the account kept rather than + // take this answer, and only once every write has answered, so writes + // refused or answered out of order leave them on what was stored. + if (pendingWrites > 0) return; + for (const hear of defaultMoves) if (hear !== hearing.current) hear(); }); }; return [choice, choose, refused]; From 9185a6ef008e763014271e891ce5e71177b43d63 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:49:16 +0000 Subject: [PATCH 12/15] fix(desktop): keep the words typed on Home while the list lands as a new account's welcome The welcome and the page are two trees, so the list landing as a new account's welcome mounted a second composer and let go of what was typed while it was read. Home now draws one composer into a box of its own, which whichever state stands takes into its place. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-home.test.ts | 35 +++++++ .../src/renderer/desktop/desktop-home.tsx | 94 +++++++++++++------ apps/desktop/src/renderer/styles/desktop.css | 11 +++ 3 files changed, 112 insertions(+), 28 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-home.test.ts b/apps/desktop/src/renderer/desktop/desktop-home.test.ts index 94c4f04c89..baff525498 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-home.test.ts @@ -669,6 +669,41 @@ test("words typed in the composer while the task list is read are still there on ); }); +test("words typed in the composer while the task list is read are still there once it lands as a new account's welcome", async () => { + const none = { tasks: [], archived: [], inbox: [], inboxTasks: [] }; + const { page, opened, tasks } = mount({ + listRead: false, + tasks: [], + listed: none, + inbox: [], + inboxTasks: [], + }); + await settle(); + typeInComposer(page, "Create my first app"); + + act(() => + root?.render( + onRegistry( + AtomRegistry.make(), + createElement(DesktopHome, { tasks: { ...tasks, listRead: true }, accountName: "Dean" }), + ), + ), + ); + await settle(); + assert.equal(page.querySelector("h1")?.textContent, "Start your first task"); + assert.ok( + page.querySelector("[aria-label='Welcome'] .new-task-composer"), + "the composer stands under Luke's line", + ); + pressInComposer(page, "Enter"); + await settle(); + + assert.deepEqual( + opened.started.map((each) => each.agent?.prompt), + ["Create my first app"], + ); +}); + test("Home lists every open item of the listed plans, a teammate's agent's included, though main counts only the account's own as waiting on it", async () => { // Billing's question is a teammate's agent's: main's account read holds the task, but waits the account on Invitations' alone. const { page } = mount({ diff --git a/apps/desktop/src/renderer/desktop/desktop-home.tsx b/apps/desktop/src/renderer/desktop/desktop-home.tsx index de2099906b..c270c2b581 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.tsx +++ b/apps/desktop/src/renderer/desktop/desktop-home.tsx @@ -22,7 +22,8 @@ import { import { teammateCreator } from "@luke/ui/actor-model"; import { TAB_WORDS } from "@luke/ui/tab-strip-model"; import { orderTasks, TASK_ORDER, taskTime } from "@luke/ui/task-list-model"; -import { useState } from "react"; +import { type ReactNode, useCallback, useState } from "react"; +import { createPortal } from "react-dom"; import { ACTOR_SIZE, ActorMark, CreatorHeard } from "../actor-ref"; import { InboxItemRow, InboxListSkeleton } from "../tasks/inbox-list"; import { @@ -274,15 +275,39 @@ function HomeRecent({ function HomeComposer({ tasks }: { tasks: TasksControl }): React.JSX.Element | null { if (!tasks.signedIn) return null; return ( -
- -
+ + ); +} + +/** Where the page lays the composer: a box of its own, which the composer's one box is moved into. */ +type ComposerSlot = (slot: HTMLElement | null) => void; + +/** + * One composer for every state of the page, drawn into a box of the page's + * own that whichever state stands, the page or the welcome, takes into its + * place, so the list landing as a new account's welcome moves the composer + * rather than mounting it afresh, and the words typed while it was read + * stand. Note that the composer is drawn after the page, so its place is + * taken before its words decide whether to take the caret. + */ +function useComposerSlot(composer: ReactNode): readonly [ReactNode, ComposerSlot] { + const [box] = useState(() => { + const made = document.createElement("div"); + made.className = "home-composer ph-no-capture"; + return made; + }); + const place = useCallback( + (slot) => { + if (slot !== null && box.parentNode !== slot) slot.append(box); + }, + [box], ); + return [createPortal(composer, box), place]; } /** Hands the focus back to the page from whatever holds it, as Escape from an agent's box does. */ @@ -292,7 +317,7 @@ function letGoOfFocus(): void { } /** A new account's welcome: Luke's face and the line over the composer. */ -function Welcome({ tasks }: { tasks: TasksControl }): React.JSX.Element { +function Welcome({ place }: { place: ComposerSlot }): React.JSX.Element { return (
); } @@ -383,26 +408,39 @@ export function DesktopHome({ tasks.tasks.length === 0 && tasks.archived.length === 0 && !tasks.inboxTaskless.some((each) => waitsOnReader(each, tasks.me)); - if (!reading && !tasks.listFailed && fresh) return ; + const [composer, place] = useComposerSlot(); + if (!reading && !tasks.listFailed && fresh) { + return ( + <> + + {composer} + + ); + } // Note that the composer stands in one place while the list is read and // once it lands, so the list landing keeps the words typed while it was // read and the caret in them. return ( - // Note that the rows the arrow keys move through are found inside this box, which lays nothing out. -
- : greeting(accountName)}> - - {reading ? ( - - ) : ( - // The Inbox's agent cards name who started each agent, and Recent's rows - // who created each teammate's task, read against the listed workspace's members. - - - - - )} - -
+ <> + {/* Note that the rows the arrow keys move through are found inside this box, which lays nothing out. */} +
+ : greeting(accountName)} + > +
+ {reading ? ( + + ) : ( + // The Inbox's agent cards name who started each agent, and Recent's rows + // who created each teammate's task, read against the listed workspace's members. + + + + + )} + +
+ {composer} + ); } diff --git a/apps/desktop/src/renderer/styles/desktop.css b/apps/desktop/src/renderer/styles/desktop.css index e716fc65b6..193d47c84e 100644 --- a/apps/desktop/src/renderer/styles/desktop.css +++ b/apps/desktop/src/renderer/styles/desktop.css @@ -6845,6 +6845,17 @@ polygon.factory-campus-edge[data-edge="selected"] { margin-bottom: var(--luke-space-32); } +/* Signed out, the page draws no composer, and its box takes no room. */ +.home-composer:empty { + display: none; +} + +/* Where the page or the welcome lays the composer's one box + (`desktop-home.tsx`'s `useComposerSlot`), which lays nothing out itself. */ +.home-composer-slot { + display: contents; +} + .home-section { display: flex; flex-direction: column; From 9e8c8122682da13f21e9b47e036d03d85867b88b Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:56:42 +0000 Subject: [PATCH 13/15] fix(desktop): give the caret back to Home's composer as it moves to the welcome Moving the composer's box out of the page as the welcome replaced it dropped the focus with it, so the next key missed the words. The box now notes what held the focus inside it as its place goes, and focuses it again in the new place where nothing else took the focus meanwhile. Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-home.test.ts | 1 + .../src/renderer/desktop/desktop-home.tsx | 20 ++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-home.test.ts b/apps/desktop/src/renderer/desktop/desktop-home.test.ts index baff525498..979ed877b0 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-home.test.ts @@ -695,6 +695,7 @@ test("words typed in the composer while the task list is read are still there on page.querySelector("[aria-label='Welcome'] .new-task-composer"), "the composer stands under Luke's line", ); + assert.ok(document.activeElement === composerEditor(page), "the caret stays in the words"); pressInComposer(page, "Enter"); await settle(); diff --git a/apps/desktop/src/renderer/desktop/desktop-home.tsx b/apps/desktop/src/renderer/desktop/desktop-home.tsx index c270c2b581..9d11a1c0fc 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.tsx +++ b/apps/desktop/src/renderer/desktop/desktop-home.tsx @@ -22,7 +22,7 @@ import { import { teammateCreator } from "@luke/ui/actor-model"; import { TAB_WORDS } from "@luke/ui/tab-strip-model"; import { orderTasks, TASK_ORDER, taskTime } from "@luke/ui/task-list-model"; -import { type ReactNode, useCallback, useState } from "react"; +import { type ReactNode, useCallback, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ACTOR_SIZE, ActorMark, CreatorHeard } from "../actor-ref"; import { InboxItemRow, InboxListSkeleton } from "../tasks/inbox-list"; @@ -292,7 +292,7 @@ type ComposerSlot = (slot: HTMLElement | null) => void; * own that whichever state stands, the page or the welcome, takes into its * place, so the list landing as a new account's welcome moves the composer * rather than mounting it afresh, and the words typed while it was read - * stand. Note that the composer is drawn after the page, so its place is + * stand, the caret in them where it was. Note that the composer is drawn after the page, so its place is * taken before its words decide whether to take the caret. */ function useComposerSlot(composer: ReactNode): readonly [ReactNode, ComposerSlot] { @@ -301,9 +301,23 @@ function useComposerSlot(composer: ReactNode): readonly [ReactNode, ComposerSlot made.className = "home-composer ph-no-capture"; return made; }); + // What held the focus inside the box as its place went, given it back in the new one. + const held = useRef(null); const place = useCallback( (slot) => { - if (slot !== null && box.parentNode !== slot) slot.append(box); + // Note that a place going is told before it leaves the page, while + // what holds the focus still holds it; the page drops the focus as the + // box leaves with it. + if (slot === null) { + const active = document.activeElement; + held.current = active instanceof HTMLElement && box.contains(active) ? active : null; + return; + } + if (box.parentNode !== slot) slot.append(box); + const was = held.current; + held.current = null; + const unfocused = document.activeElement === null || document.activeElement === document.body; + if (was !== null && unfocused && box.contains(was)) was.focus(); }, [box], ); From 9389868c11ca72733531a33b3805ecc70bdb282a Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:05:36 +0000 Subject: [PATCH 14/15] test(desktop): read the composer's test helpers from @luke/panel, where main moved them Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-home.test.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-home.test.ts b/apps/desktop/src/renderer/desktop/desktop-home.test.ts index 979ed877b0..e846df13b9 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-home.test.ts @@ -22,6 +22,13 @@ import { WORKSPACE_ROLE, type WorkspaceMember } from "@luke/features/workspace-w import { ACT_KIND, ACT_OUTCOME_STATUS, type Act } from "@luke/host/acts"; import { fakeAppBridge } from "@luke/panel/testing/app-bridge"; import { handState } from "@luke/panel/testing/app-state"; +import { + composerEditor, + editorBundleLanded, + installEditorLayout, + pressInComposer, + typeInComposer, +} from "@luke/panel/testing/composer"; import { onRegistry } from "@luke/panel/testing/copy-feedback"; import { installScrollIntoView } from "@luke/panel/testing/scroll-into-view"; import { commandKeyshortcuts } from "@luke/panel/tooltip"; @@ -33,13 +40,6 @@ import * as AtomRegistry from "effect/reactivity/AtomRegistry"; import { act, createElement, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeAll, beforeEach, test } from "vitest"; -import { - composerEditor, - editorBundleLanded, - installEditorLayout, - pressInComposer, - typeInComposer, -} from "#testing/composer"; import { tasksControl, waitingOf } from "#testing/tasks-control"; import { NewTaskDialog } from "../tasks/new-task-dialog"; import type { InboxChoice, NewTaskAgent, TasksControl } from "../tasks/use-tasks-tab"; From d729aab1d1128134d2fd3f3e2195205ab8848ea9 Mon Sep 17 00:00:00 2001 From: Dean Stratakos <29683763+dastratakos@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:46:48 +0000 Subject: [PATCH 15/15] test(desktop): hold Home's composer tests to main's one-call start and Plan-mode brief Co-authored-by: luke-agent[bot] <339870089+luke-agent[bot]@users.noreply.github.com> --- .../src/renderer/desktop/desktop-home.test.ts | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/desktop/desktop-home.test.ts b/apps/desktop/src/renderer/desktop/desktop-home.test.ts index e846df13b9..7723c50d77 100644 --- a/apps/desktop/src/renderer/desktop/desktop-home.test.ts +++ b/apps/desktop/src/renderer/desktop/desktop-home.test.ts @@ -222,13 +222,14 @@ afterEach(() => { document.head.innerHTML = ""; }); -/** A task the composer started: its name, repository, workspace, whether it plans, and the agent it asked for. */ +/** A task the composer started: its name, repository, workspace, whether it plans, the agent it asked for, and the brief it handed Luke. */ interface Started { readonly name: string; readonly repository: string | null; readonly workspaceId: string | undefined; readonly planning: boolean; readonly agent: NewTaskAgent | undefined; + readonly brief: string | undefined; } /** Every door the page opens, in order. */ @@ -248,7 +249,14 @@ function newTaskControl(started: Started[]): TasksControl["newTask"] { repositoriesIn: () => ({ ...tasksControl().repositories, recent: ["acme/relay"] }), readDefault: () => Promise.resolve({ choice: DEFAULT_CHOICE }), start: (name, repository, workspaceId, planning, from) => { - started.push({ name, repository, workspaceId, planning, agent: from?.handed }); + started.push({ + name, + repository, + workspaceId, + planning, + agent: from?.handed, + brief: from?.brief, + }); return Promise.resolve(undefined); }, }; @@ -610,23 +618,26 @@ test("Agent mode: Return in Home's composer starts the task in the workspace Hom pressInComposer(page, "Enter"); await settle(); - assert.deepEqual(opened.started, [ - { - name: "", - repository: "acme/relay", - workspaceId: LISTED_WORKSPACE, - planning: false, - agent: { - prompt: "Make the sidebar keep its folds.", - attachments: [], - choice: DEFAULT_CHOICE, - }, - }, - ]); + const [{ agent, ...task } = assert.fail("a task started")] = opened.started; + const { idempotencyKey, ...handed } = agent ?? assert.fail("an agent was asked for"); + assert.deepEqual(task, { + name: "", + repository: "acme/relay", + workspaceId: LISTED_WORKSPACE, + planning: false, + brief: undefined, + }); + assert.deepEqual(handed, { + prompt: "Make the sidebar keep its folds.", + attachments: [], + choice: DEFAULT_CHOICE, + }); + assert.ok(idempotencyKey, "the agent's start carries the card's key"); + assert.equal(opened.started.length, 1); assert.equal(opened.newTasks, 0, "no dialog is asked for"); }); -test("Plan mode: Shift+Tab in Home's composer turns it on, and Return starts the plan and its call in the workspace Home lists, with no agent", async () => { +test("Plan mode: Shift+Tab in Home's composer turns it on, and Return starts the plan and its call in the workspace Home lists, Untitled, handing Luke the words as its brief, with no agent", async () => { const { page, opened } = mount(); await settle(); pressInComposer(page, "Tab", { shiftKey: true }); @@ -636,11 +647,12 @@ test("Plan mode: Shift+Tab in Home's composer turns it on, and Return starts the assert.deepEqual(opened.started, [ { - name: "Rethink automations", + name: "", repository: "acme/relay", workspaceId: LISTED_WORKSPACE, planning: true, agent: undefined, + brief: "Rethink automations", }, ]); });