Default the Plan issue dialog to Forgejo issue, with an open-issue picker #246

Closed
opened 2026-11-10 18:34:01 +01:00 by omp-deck · 0 comments
Collaborator

Problem

The "Plan issue" dialog (apps/web/src/components/PlanIssueModal.tsx) opens on
the "Inbox entry" source, so planning an existing Forgejo issue costs a radio
click before a number can be typed. "Forgejo issue" should be the default, with
the caret already in the issue field on open.

While the issue field is the default, it should also offer the repository's open
issues the way the inbox field offers inbox entries: a combobox fed by a
prefetch, so an issue can be picked by title instead of by remembering its
number. Measured against this repository through the same ForgejoClient the
server uses, one page of 50 open issues returns in 57–69 ms, so the list fills
while the dialog is still being read; the dialog renders immediately and fills
when results land.

Constraints that shape the picker, verified in the repository:

  • startPlanIssue rejects a closed issue (#N is closed, 422) and a pull
    request (#N is a pull request, not an issue, 422) for issue seeds
    (apps/server/src/forgejo/plan-issue.ts:126-127). Anything offered that is
    not an open issue is a dead entry, so the picker only ever lists open issues.
  • The web UI has no route that can list issues: /api/forgejo serves config,
    test, deliveries, implement and plan-issue only
    (apps/server/src/routes-forgejo.ts:4-11).
  • ForgejoClient never throws; it resolves failures and a 20 s timeout as
    {ok:false,status,message} with no retry
    (apps/server/src/forgejo/client.ts:5-7,51). A hung instance must not make
    the dialog unusable, so typing a number always works independently of the
    list.

Plan

1. Protocol: issue picker types

In packages/protocol/src/index.ts, directly after PlanIssueResponse (ends
line 2167), add:

/** One open issue of a workspace's repository, as offered by the plan-issue picker; never a pull request. */
export interface ForgejoIssueSummary {
  number: number;
  title: string;
  /** ISO-8601 last update; empty when the instance did not report one. */
  updatedAt: string;
}

export interface ListForgejoIssuesResponse {
  issues: ForgejoIssueSummary[];
}

DispatchApi (apps/server/src/forgejo/dispatch-api.ts:15-83) has readIssue
but no search; forgejo_search_issues exists only as an agent tool
(apps/server/src/forgejo/tools/issues.ts:87-120) and returns formatted text,
not data. Add the data-returning method to DispatchApi — the seam the route
tests already fake.

In dispatch-api.ts:

  • Export the row type beside IssueSummary (line 109):
/** An open issue as offered to a picker: enough to show and to seed a plan-issue session. */
export interface IssuePick {
  number: number;
  title: string;
  updatedAt: string;
}
  • Add to the DispatchApi interface, after readIssue (line 31):
  /**
   * Open issues of `owner/repo`, newest-updated first, pull requests excluded.
   * `query` is Forgejo's free-text search over titles and bodies; null lists
   * the most recently updated issues. At most `limit` rows, first page only.
   */
  searchOpenIssues(
    owner: string,
    repo: string,
    query: string | null,
    limit: number,
    signal?: AbortSignal,
  ): Promise<ApiResult<IssuePick[]>>;
  • Add updated_at?: string; to interface RawIssue (line 194-202), which
    currently lacks it.

  • Implement in createDispatchApi's returned object, next to readIssue
    (line 342):

    async searchOpenIssues(owner, repo, query, limit, signal) {
      const res = await client.json<RawIssue[]>("GET", repoApiPath(owner, repo, "issues"), {
        query: { q: query ?? undefined, state: "open", type: "issues", page: 1, limit },
        signal,
      });
      if (!res.ok) return res;
      const items = Array.isArray(res.data) ? res.data : [];
      const data = items
        .filter((raw): raw is RawIssue & { number: number } => raw.pull_request == null && typeof raw.number === "number")
        .map((raw) => ({ number: raw.number, title: raw.title ?? "", updatedAt: raw.updated_at ?? "" }))
        .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
      return { ok: true, meta: res.meta, data };
    },

type: "issues" already excludes pull requests server-side; the pull_request
filter is the local guard that keeps the contract true regardless. Ordering is
done here rather than through a sort query parameter so no unverified Forgejo
sort value is relied on.

3. Route: GET /api/forgejo/issues

In apps/server/src/routes-forgejo.ts:

  • Extend the route-doc header block (lines 4-11) with the new line.
  • Import createDispatchApi, type DispatchApi from ./forgejo/dispatch-api.ts
    and type ListForgejoIssuesResponse from the protocol package.
  • Add a module constant: const ISSUE_PICKER_LIMIT = 50; (the instance-side cap
    used elsewhere is MAX_PAGE_LIMIT = 50, apps/server/src/forgejo/tools/shared.ts:189).
  • Widen the router options (line 89-91) to
    opts: { startImplementer?: ForgejoDispatcher["startImplementer"]; startPlanIssue?: StartPlanIssue; forgejoApi?: () => DispatchApi | null } = {}
    and read it as const forgejoApi = opts.forgejoApi ?? createDispatchApi;.
    No change at the index.ts:104-128 call site — the default covers production.
  • Register the handler next to the plan-issue handler:
	app.get("/forgejo/issues", async (c) => {
		const workspaceId = c.req.query("workspaceId");
		if (!workspaceId) return c.json({ error: "workspaceId is required" }, 400);
		const workspace = listWorkspaces().find((w) => w.id === workspaceId);
		if (!workspace) return c.json({ error: "workspace not found" }, 404);
		const cfg = readForgejoConfig();
		if (!cfg) return c.json({ error: "Forgejo is not configured" }, 422);
		const ref = await resolveRepoFromCwd(workspace.path, cfg.url);
		if (!ref) {
			return c.json({ error: "workspace origin is not a repository on the configured Forgejo instance" }, 422);
		}
		const api = forgejoApi();
		if (!api) return c.json({ error: "Forgejo is not configured" }, 422);
		const q = c.req.query("q")?.trim() || null;
		const res = await api.searchOpenIssues(ref.owner, ref.repo, q, ISSUE_PICKER_LIMIT);
		if (!res.ok) return c.json({ error: `could not list issues: ${res.status} ${res.message}` }, 502);
		const body: ListForgejoIssuesResponse = { issues: res.data };
		return c.json(body);
	});

The workspace/config/repo resolution is the same sequence, with the same two 422
messages, as the plan-issue handler at lines 250-257; reuse it verbatim so a
workspace that cannot plan an issue also cannot list issues, with one wording.

4. Web API client

In apps/web/src/lib/forgejo-api.ts, beside planIssue (line 76), add:

	/** Open issues of the workspace's repository; `query` is Forgejo free text over titles and bodies. */
	listIssues(workspaceId: string, query?: string): Promise<ListForgejoIssuesResponse> {
		const params = new URLSearchParams({ workspaceId });
		if (query) params.set("q", query);
		return request<ListForgejoIssuesResponse>(`/forgejo/issues?${params}`);
	},

Import ListForgejoIssuesResponse with the other protocol types at the top of
the file. Nothing is added to store.ts: the modal calls forgejoApi directly,
as it already does for the inbox list through inboxApi.

5. Issue suggestion matcher

New file apps/web/src/lib/issue-suggest.ts, deliberately mirroring
apps/web/src/lib/inbox-suggest.ts (its suggestInboxItems is typed on
InboxItem and scores over title/body/id, so it cannot be reused directly):

import type { ForgejoIssueSummary } from "@omp-deck/protocol";

const EMPTY_QUERY_LIMIT = 10;
const MATCH_LIMIT = 50;

/**
 * Keyword suggestions for picking an open issue.
 *
 * An all-digit query matches on the issue number, so typing a number narrows
 * the list to it instead of scoring prose. Otherwise the query is split into
 * whitespace-separated words and an issue matches when any word is a
 * case-insensitive substring of `#number` or the title, ranked by how many
 * distinct words hit, then newest-updated first, capped at 50. An empty query
 * returns the 10 most recently updated issues.
 */
export function suggestIssues(issues: ForgejoIssueSummary[], query: string): ForgejoIssueSummary[] {
	const byRecency = (a: ForgejoIssueSummary, b: ForgejoIssueSummary) => b.updatedAt.localeCompare(a.updatedAt);
	const q = query.trim().toLowerCase();
	if (q === "") return [...issues].sort(byRecency).slice(0, EMPTY_QUERY_LIMIT);
	if (/^\d+$/.test(q)) {
		return issues
			.filter((i) => String(i.number).includes(q))
			.sort((a, b) => {
				const aStarts = String(a.number).startsWith(q);
				const bStarts = String(b.number).startsWith(q);
				return aStarts === bStarts ? b.number - a.number : aStarts ? -1 : 1;
			})
			.slice(0, EMPTY_QUERY_LIMIT);
	}
	const words = [...new Set(q.split(/\s+/).filter(Boolean))];
	const scored: { item: ForgejoIssueSummary; score: number }[] = [];
	for (const item of issues) {
		const haystack = `#${item.number}\n${item.title}`.toLowerCase();
		const score = words.filter((w) => haystack.includes(w)).length;
		if (score > 0) scored.push({ item, score });
	}
	scored.sort((a, b) => b.score - a.score || byRecency(a.item, b.item));
	return scored.slice(0, MATCH_LIMIT).map((s) => s.item);
}

6. Modal: default source and radio order

In apps/web/src/components/PlanIssueModal.tsx:

  • Reorder SEED_OPTIONS (lines 22-26) to issue, inbox, free so the
    default sits in the leading radio slot. Labels unchanged.
  • useState<SeedKind>("inbox") (line 39) becomes useState<SeedKind>("issue").
  • setSeedKind("inbox") in the open effect (line 58) becomes
    setSeedKind("issue"). The reset stays unconditional: every open starts on
    the issue field, never remembering the last-used source.

7. Modal: issue combobox

Same file. The issue branch becomes a combobox built from the inbox one, so the
two share the dropdown plumbing (comboFocused, comboDismissed,
activeIndex, listRef, inputEl, AnchoredPortal, onComboKey).

State changes:

  • Delete the number state (line 49). The shared query state holds the issue
    input too, so one combobox implementation serves both sources.
  • Add issues: ForgejoIssueSummary[] (prefetch, default []),
    issueHits: ForgejoIssueSummary[] | null (server search results; null means
    "not searching"), issuesLoading: boolean, issuesError: string | undefined.

Fetching:

  • In the existing open effect (lines 56-81), keep the eager inbox fetch
    unchanged and add an eager issue prefetch guarded on a workspace being
    selected: reset issues/issueHits/issuesError, set issuesLoading, call
    forgejoApi.listIssues(activeWorkspaceId), and on resolve (when not
    cancelled) store res.issues; on reject store the error message in
    issuesError. Clear issuesLoading in both paths. The effect's existing
    cancelled flag and [open, activeWorkspaceId] deps cover the new call.
  • Add a debounced search effect with deps [open, seedKind, query], copying the
    140 ms pattern of apps/web/src/views/KbCommandPalette.tsx:41-62: return
    early unless open && seedKind === "issue"; let q = query.trim(); if q
    is empty or matches /^\d+$/, setIssueHits(null) and return — a typed
    number never costs a request, because Forgejo's q= searches prose, not issue
    numbers, and the number is already a valid seed on its own. Otherwise
    setTimeout(…, 140) calling forgejoApi.listIssues(workspaceId, q), storing
    res.issues in issueHits and resetting activeIndex to 0; on failure set
    issueHits to [] and issuesError to the message. Clean up with
    clearTimeout plus a cancelled flag so a stale response cannot overwrite a
    newer one.
  • Add an effect on [seedKind] that clears query, issueHits, activeIndex
    and sets comboDismissed to false. Required because query is now shared:
    switching sources must not carry an inbox id into the issue field.

Suggestions and seed:

  • const issueSuggestions = issueHits ?? suggestIssues(issues, query); — server
    hits are already filtered and ordered by relevance, so they are shown as
    returned; only the prefetched list is matched locally.
  • const suggestions = seedKind === "inbox" ? suggestInboxItems(suggestible, query) : issueSuggestions;
    and dropdownOpen keeps its existing shape against this combined list, so
    dismissOnEscape={!dropdownOpen} and the arrow/Enter/Escape handler work
    unchanged for both sources.
  • pick sets query to item.id for inbox entries and to String(issue.number)
    for issues, then dismisses the dropdown. Picking an issue therefore produces
    exactly the state a typed number produces.
  • Seed computation replaces the Number(number) path (lines 135-145):
	const issueNumber = /^\d+$/.test(trimmedQuery) ? Number(trimmedQuery) : NaN;

with the issue branch yielding { kind: "issue", number: issueNumber } when
Number.isInteger(issueNumber) && issueNumber >= 1, else null. The inbox and
free branches are unchanged.

Rendering the issue branch (replacing lines 286-298):

  • A type="text" input (not type="number", which cannot hold a title query)
    with autoFocus, role="combobox", autoComplete="off",
    placeholder="Issue number, or search open issues", wired to the same
    onChange/onFocus/onBlur/onKeyDown handlers and the same setInputEl
    ref as the inbox input. autoFocus with the new default is what makes a
    number typeable the moment the dialog opens.
  • The AnchoredPortal dropdown is the existing one; issue rows render
    #{number} {title} as the primary line and
    formatRelativeAgo(issue.updatedAt) (apps/web/src/lib/time.ts:75, already
    unit-tested) as the secondary line, in the same two-line markup and
    active/hover classes as the inbox rows.
  • Below the input, in priority order:
    • issuesError → Could not load issues: {message} — type an issue number to plan it, rendered in the muted text-ink-3 style, not the red error box: the number path still works, so this is a degraded list, not a failed action.
    • a valid issueNumber matching a loaded issue (search issueHits ?? issues) and no open dropdown → #{number} {title}, mirroring the inbox confirmation line. A number with no match renders nothing: there is no per-number lookup request, because submit already validates and returns #N is closed, #N is a pull request, not an issue or issue #N not found into the existing error box.
    • issuesLoading and empty query → Loading open issues….
    • loaded, empty, and empty query → No open issues.

Nothing changes in the inbox branch, the model select, the footer, or submit.

Verification

Prerequisites: run from the repository root; a configured Forgejo instance whose
lucas/omp-deck repository is the active workspace's origin.

  1. bun run typecheck — passes across all workspaces.
  2. bun test apps/server/src/routes-forgejo.test.ts — extend the file (its
    harness boots an isolated SQLite and drives buildForgejoRouter via
    app.request, lines 25-42) with a describe("GET /forgejo/issues") covering:
    • missing workspaceId → 400;
    • unknown workspaceId → 404;
    • a stubbed forgejoApi returning
      {ok:true,data:[{number:240,title:"Graceful shutdown",updatedAt:"2026-11-09T11:26:22Z"}]}
      → 200 with body {"issues":[{"number":240,"title":"Graceful shutdown","updatedAt":"2026-11-09T11:26:22Z"}]},
      asserting the q query parameter reached searchOpenIssues as null when
      absent and as the trimmed string when present;
    • a stubbed forgejoApi returning {ok:false,status:0,message:"request timed out after 20s"}
      → 502 with an error containing could not list issues.
      These need Forgejo config present in the test DB; if the config rows make the
      422 branch fire first, assert the 422 messages for the unconfigured case and
      write the happy-path and 502 cases against a test DB seeded with a config row
      the same way routes-forgejo.test.ts seeds deliveries.
  3. New apps/web/src/lib/issue-suggest.test.ts (bun test, mirroring the
    existing apps/web/src/lib/time.test.ts style): empty query returns the 10
    most recently updated issues; "240" returns only issues whose number
    contains 240 with a number-prefix match first; "graceful shut" ranks an
    issue titled Graceful shutdown above one matching a single word; a query
    matching nothing returns [].
  4. End-to-end, manually, with bun run dev: open the Plan issue dialog.
    • The "Forgejo issue" radio is selected and leftmost, and the caret is in the
      issue input — typing 240 immediately puts 240 in the field with no
      clicks, and the line under the input reads #240 Graceful shutdown.
    • Clearing the field shows the open issues (7 in this repository at the time
      of writing, including #245 Provider login gets lost sometimes and
      #240 Graceful shutdown), each with a relative updated time.
    • Typing graceful narrows the list to #240 Graceful shutdown; pressing
      Enter fills 240 into the field.
    • Pressing "Start planning" opens a session named
      Plan issue: lucas/omp-deck#240 whose first visible turn is
      Plan issue #240.
    • Typing a closed issue number (e.g. 243) and submitting leaves the dialog
      open with #243 is closed in the red error box.
    • Switching to "Inbox entry" clears the field and restores the inbox
      combobox behaviour unchanged.

Assumptions & contingencies

  • The Forgejo issues endpoint is assumed to honour type=issues together with
    state=open and q, as forgejo_search_issues already relies on
    (apps/server/src/forgejo/tools/issues.ts:103-113). If an instance ignores
    type and returns pull requests, the local pull_request == null filter in
    searchOpenIssues already drops them; no further change.
  • updated_at is assumed present on issue payloads (it is declared on ApiIssue
    at apps/server/src/forgejo/tools/api-types.ts:32). If an instance omits it,
    updatedAt is "", formatRelativeAgo("") returns "", and rows render
    without a time line — acceptable, no fallback needed.
## Problem The "Plan issue" dialog (`apps/web/src/components/PlanIssueModal.tsx`) opens on the "Inbox entry" source, so planning an existing Forgejo issue costs a radio click before a number can be typed. "Forgejo issue" should be the default, with the caret already in the issue field on open. While the issue field is the default, it should also offer the repository's open issues the way the inbox field offers inbox entries: a combobox fed by a prefetch, so an issue can be picked by title instead of by remembering its number. Measured against this repository through the same `ForgejoClient` the server uses, one page of 50 open issues returns in 57–69 ms, so the list fills while the dialog is still being read; the dialog renders immediately and fills when results land. Constraints that shape the picker, verified in the repository: - `startPlanIssue` rejects a closed issue (`#N is closed`, 422) and a pull request (`#N is a pull request, not an issue`, 422) for issue seeds (`apps/server/src/forgejo/plan-issue.ts:126-127`). Anything offered that is not an open issue is a dead entry, so the picker only ever lists open issues. - The web UI has no route that can list issues: `/api/forgejo` serves config, test, deliveries, implement and plan-issue only (`apps/server/src/routes-forgejo.ts:4-11`). - `ForgejoClient` never throws; it resolves failures and a 20 s timeout as `{ok:false,status,message}` with no retry (`apps/server/src/forgejo/client.ts:5-7,51`). A hung instance must not make the dialog unusable, so typing a number always works independently of the list. ## Plan ### 1. Protocol: issue picker types In `packages/protocol/src/index.ts`, directly after `PlanIssueResponse` (ends line 2167), add: ```ts /** One open issue of a workspace's repository, as offered by the plan-issue picker; never a pull request. */ export interface ForgejoIssueSummary { number: number; title: string; /** ISO-8601 last update; empty when the instance did not report one. */ updatedAt: string; } export interface ListForgejoIssuesResponse { issues: ForgejoIssueSummary[]; } ``` ### 2. DispatchApi: open-issue search `DispatchApi` (`apps/server/src/forgejo/dispatch-api.ts:15-83`) has `readIssue` but no search; `forgejo_search_issues` exists only as an agent tool (`apps/server/src/forgejo/tools/issues.ts:87-120`) and returns formatted text, not data. Add the data-returning method to `DispatchApi` — the seam the route tests already fake. In `dispatch-api.ts`: - Export the row type beside `IssueSummary` (line 109): ```ts /** An open issue as offered to a picker: enough to show and to seed a plan-issue session. */ export interface IssuePick { number: number; title: string; updatedAt: string; } ``` - Add to the `DispatchApi` interface, after `readIssue` (line 31): ```ts /** * Open issues of `owner/repo`, newest-updated first, pull requests excluded. * `query` is Forgejo's free-text search over titles and bodies; null lists * the most recently updated issues. At most `limit` rows, first page only. */ searchOpenIssues( owner: string, repo: string, query: string | null, limit: number, signal?: AbortSignal, ): Promise<ApiResult<IssuePick[]>>; ``` - Add `updated_at?: string;` to `interface RawIssue` (line 194-202), which currently lacks it. - Implement in `createDispatchApi`'s returned object, next to `readIssue` (line 342): ```ts async searchOpenIssues(owner, repo, query, limit, signal) { const res = await client.json<RawIssue[]>("GET", repoApiPath(owner, repo, "issues"), { query: { q: query ?? undefined, state: "open", type: "issues", page: 1, limit }, signal, }); if (!res.ok) return res; const items = Array.isArray(res.data) ? res.data : []; const data = items .filter((raw): raw is RawIssue & { number: number } => raw.pull_request == null && typeof raw.number === "number") .map((raw) => ({ number: raw.number, title: raw.title ?? "", updatedAt: raw.updated_at ?? "" })) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return { ok: true, meta: res.meta, data }; }, ``` `type: "issues"` already excludes pull requests server-side; the `pull_request` filter is the local guard that keeps the contract true regardless. Ordering is done here rather than through a `sort` query parameter so no unverified Forgejo sort value is relied on. ### 3. Route: `GET /api/forgejo/issues` In `apps/server/src/routes-forgejo.ts`: - Extend the route-doc header block (lines 4-11) with the new line. - Import `createDispatchApi, type DispatchApi` from `./forgejo/dispatch-api.ts` and `type ListForgejoIssuesResponse` from the protocol package. - Add a module constant: `const ISSUE_PICKER_LIMIT = 50;` (the instance-side cap used elsewhere is `MAX_PAGE_LIMIT = 50`, `apps/server/src/forgejo/tools/shared.ts:189`). - Widen the router options (line 89-91) to `opts: { startImplementer?: ForgejoDispatcher["startImplementer"]; startPlanIssue?: StartPlanIssue; forgejoApi?: () => DispatchApi | null } = {}` and read it as `const forgejoApi = opts.forgejoApi ?? createDispatchApi;`. No change at the `index.ts:104-128` call site — the default covers production. - Register the handler next to the plan-issue handler: ```ts app.get("/forgejo/issues", async (c) => { const workspaceId = c.req.query("workspaceId"); if (!workspaceId) return c.json({ error: "workspaceId is required" }, 400); const workspace = listWorkspaces().find((w) => w.id === workspaceId); if (!workspace) return c.json({ error: "workspace not found" }, 404); const cfg = readForgejoConfig(); if (!cfg) return c.json({ error: "Forgejo is not configured" }, 422); const ref = await resolveRepoFromCwd(workspace.path, cfg.url); if (!ref) { return c.json({ error: "workspace origin is not a repository on the configured Forgejo instance" }, 422); } const api = forgejoApi(); if (!api) return c.json({ error: "Forgejo is not configured" }, 422); const q = c.req.query("q")?.trim() || null; const res = await api.searchOpenIssues(ref.owner, ref.repo, q, ISSUE_PICKER_LIMIT); if (!res.ok) return c.json({ error: `could not list issues: ${res.status} ${res.message}` }, 502); const body: ListForgejoIssuesResponse = { issues: res.data }; return c.json(body); }); ``` The workspace/config/repo resolution is the same sequence, with the same two 422 messages, as the plan-issue handler at lines 250-257; reuse it verbatim so a workspace that cannot plan an issue also cannot list issues, with one wording. ### 4. Web API client In `apps/web/src/lib/forgejo-api.ts`, beside `planIssue` (line 76), add: ```ts /** Open issues of the workspace's repository; `query` is Forgejo free text over titles and bodies. */ listIssues(workspaceId: string, query?: string): Promise<ListForgejoIssuesResponse> { const params = new URLSearchParams({ workspaceId }); if (query) params.set("q", query); return request<ListForgejoIssuesResponse>(`/forgejo/issues?${params}`); }, ``` Import `ListForgejoIssuesResponse` with the other protocol types at the top of the file. Nothing is added to `store.ts`: the modal calls `forgejoApi` directly, as it already does for the inbox list through `inboxApi`. ### 5. Issue suggestion matcher New file `apps/web/src/lib/issue-suggest.ts`, deliberately mirroring `apps/web/src/lib/inbox-suggest.ts` (its `suggestInboxItems` is typed on `InboxItem` and scores over title/body/id, so it cannot be reused directly): ```ts import type { ForgejoIssueSummary } from "@omp-deck/protocol"; const EMPTY_QUERY_LIMIT = 10; const MATCH_LIMIT = 50; /** * Keyword suggestions for picking an open issue. * * An all-digit query matches on the issue number, so typing a number narrows * the list to it instead of scoring prose. Otherwise the query is split into * whitespace-separated words and an issue matches when any word is a * case-insensitive substring of `#number` or the title, ranked by how many * distinct words hit, then newest-updated first, capped at 50. An empty query * returns the 10 most recently updated issues. */ export function suggestIssues(issues: ForgejoIssueSummary[], query: string): ForgejoIssueSummary[] { const byRecency = (a: ForgejoIssueSummary, b: ForgejoIssueSummary) => b.updatedAt.localeCompare(a.updatedAt); const q = query.trim().toLowerCase(); if (q === "") return [...issues].sort(byRecency).slice(0, EMPTY_QUERY_LIMIT); if (/^\d+$/.test(q)) { return issues .filter((i) => String(i.number).includes(q)) .sort((a, b) => { const aStarts = String(a.number).startsWith(q); const bStarts = String(b.number).startsWith(q); return aStarts === bStarts ? b.number - a.number : aStarts ? -1 : 1; }) .slice(0, EMPTY_QUERY_LIMIT); } const words = [...new Set(q.split(/\s+/).filter(Boolean))]; const scored: { item: ForgejoIssueSummary; score: number }[] = []; for (const item of issues) { const haystack = `#${item.number}\n${item.title}`.toLowerCase(); const score = words.filter((w) => haystack.includes(w)).length; if (score > 0) scored.push({ item, score }); } scored.sort((a, b) => b.score - a.score || byRecency(a.item, b.item)); return scored.slice(0, MATCH_LIMIT).map((s) => s.item); } ``` ### 6. Modal: default source and radio order In `apps/web/src/components/PlanIssueModal.tsx`: - Reorder `SEED_OPTIONS` (lines 22-26) to `issue`, `inbox`, `free` so the default sits in the leading radio slot. Labels unchanged. - `useState<SeedKind>("inbox")` (line 39) becomes `useState<SeedKind>("issue")`. - `setSeedKind("inbox")` in the open effect (line 58) becomes `setSeedKind("issue")`. The reset stays unconditional: every open starts on the issue field, never remembering the last-used source. ### 7. Modal: issue combobox Same file. The issue branch becomes a combobox built from the inbox one, so the two share the dropdown plumbing (`comboFocused`, `comboDismissed`, `activeIndex`, `listRef`, `inputEl`, `AnchoredPortal`, `onComboKey`). State changes: - Delete the `number` state (line 49). The shared `query` state holds the issue input too, so one combobox implementation serves both sources. - Add `issues: ForgejoIssueSummary[]` (prefetch, default `[]`), `issueHits: ForgejoIssueSummary[] | null` (server search results; `null` means "not searching"), `issuesLoading: boolean`, `issuesError: string | undefined`. Fetching: - In the existing open effect (lines 56-81), keep the eager inbox fetch unchanged and add an eager issue prefetch guarded on a workspace being selected: reset `issues`/`issueHits`/`issuesError`, set `issuesLoading`, call `forgejoApi.listIssues(activeWorkspaceId)`, and on resolve (when not `cancelled`) store `res.issues`; on reject store the error message in `issuesError`. Clear `issuesLoading` in both paths. The effect's existing `cancelled` flag and `[open, activeWorkspaceId]` deps cover the new call. - Add a debounced search effect with deps `[open, seedKind, query]`, copying the 140 ms pattern of `apps/web/src/views/KbCommandPalette.tsx:41-62`: return early unless `open && seedKind === "issue"`; let `q = query.trim()`; if `q` is empty or matches `/^\d+$/`, `setIssueHits(null)` and return — a typed number never costs a request, because Forgejo's `q=` searches prose, not issue numbers, and the number is already a valid seed on its own. Otherwise `setTimeout(…, 140)` calling `forgejoApi.listIssues(workspaceId, q)`, storing `res.issues` in `issueHits` and resetting `activeIndex` to 0; on failure set `issueHits` to `[]` and `issuesError` to the message. Clean up with `clearTimeout` plus a `cancelled` flag so a stale response cannot overwrite a newer one. - Add an effect on `[seedKind]` that clears `query`, `issueHits`, `activeIndex` and sets `comboDismissed` to `false`. Required because `query` is now shared: switching sources must not carry an inbox id into the issue field. Suggestions and seed: - `const issueSuggestions = issueHits ?? suggestIssues(issues, query);` — server hits are already filtered and ordered by relevance, so they are shown as returned; only the prefetched list is matched locally. - `const suggestions = seedKind === "inbox" ? suggestInboxItems(suggestible, query) : issueSuggestions;` and `dropdownOpen` keeps its existing shape against this combined list, so `dismissOnEscape={!dropdownOpen}` and the arrow/Enter/Escape handler work unchanged for both sources. - `pick` sets `query` to `item.id` for inbox entries and to `String(issue.number)` for issues, then dismisses the dropdown. Picking an issue therefore produces exactly the state a typed number produces. - Seed computation replaces the `Number(number)` path (lines 135-145): ```ts const issueNumber = /^\d+$/.test(trimmedQuery) ? Number(trimmedQuery) : NaN; ``` with the `issue` branch yielding `{ kind: "issue", number: issueNumber }` when `Number.isInteger(issueNumber) && issueNumber >= 1`, else `null`. The inbox and free branches are unchanged. Rendering the issue branch (replacing lines 286-298): - A `type="text"` input (not `type="number"`, which cannot hold a title query) with `autoFocus`, `role="combobox"`, `autoComplete="off"`, `placeholder="Issue number, or search open issues"`, wired to the same `onChange`/`onFocus`/`onBlur`/`onKeyDown` handlers and the same `setInputEl` ref as the inbox input. `autoFocus` with the new default is what makes a number typeable the moment the dialog opens. - The `AnchoredPortal` dropdown is the existing one; issue rows render `#{number} {title}` as the primary line and `formatRelativeAgo(issue.updatedAt)` (`apps/web/src/lib/time.ts:75`, already unit-tested) as the secondary line, in the same two-line markup and active/hover classes as the inbox rows. - Below the input, in priority order: - `issuesError` → `Could not load issues: {message} — type an issue number to plan it`, rendered in the muted `text-ink-3` style, not the red error box: the number path still works, so this is a degraded list, not a failed action. - a valid `issueNumber` matching a loaded issue (search `issueHits ?? issues`) and no open dropdown → `#{number} {title}`, mirroring the inbox confirmation line. A number with no match renders nothing: there is no per-number lookup request, because submit already validates and returns `#N is closed`, `#N is a pull request, not an issue` or `issue #N not found` into the existing error box. - `issuesLoading` and empty query → `Loading open issues…`. - loaded, empty, and empty query → `No open issues`. Nothing changes in the inbox branch, the model select, the footer, or `submit`. ## Verification Prerequisites: run from the repository root; a configured Forgejo instance whose `lucas/omp-deck` repository is the active workspace's `origin`. 1. `bun run typecheck` — passes across all workspaces. 2. `bun test apps/server/src/routes-forgejo.test.ts` — extend the file (its harness boots an isolated SQLite and drives `buildForgejoRouter` via `app.request`, lines 25-42) with a `describe("GET /forgejo/issues")` covering: - missing `workspaceId` → 400; - unknown `workspaceId` → 404; - a stubbed `forgejoApi` returning `{ok:true,data:[{number:240,title:"Graceful shutdown",updatedAt:"2026-11-09T11:26:22Z"}]}` → 200 with body `{"issues":[{"number":240,"title":"Graceful shutdown","updatedAt":"2026-11-09T11:26:22Z"}]}`, asserting the `q` query parameter reached `searchOpenIssues` as `null` when absent and as the trimmed string when present; - a stubbed `forgejoApi` returning `{ok:false,status:0,message:"request timed out after 20s"}` → 502 with an error containing `could not list issues`. These need Forgejo config present in the test DB; if the config rows make the 422 branch fire first, assert the 422 messages for the unconfigured case and write the happy-path and 502 cases against a test DB seeded with a config row the same way `routes-forgejo.test.ts` seeds deliveries. 3. New `apps/web/src/lib/issue-suggest.test.ts` (bun test, mirroring the existing `apps/web/src/lib/time.test.ts` style): empty query returns the 10 most recently updated issues; `"240"` returns only issues whose number contains 240 with a number-prefix match first; `"graceful shut"` ranks an issue titled `Graceful shutdown` above one matching a single word; a query matching nothing returns `[]`. 4. End-to-end, manually, with `bun run dev`: open the Plan issue dialog. - The "Forgejo issue" radio is selected and leftmost, and the caret is in the issue input — typing `240` immediately puts `240` in the field with no clicks, and the line under the input reads `#240 Graceful shutdown`. - Clearing the field shows the open issues (7 in this repository at the time of writing, including `#245 Provider login gets lost sometimes` and `#240 Graceful shutdown`), each with a relative updated time. - Typing `graceful` narrows the list to `#240 Graceful shutdown`; pressing Enter fills `240` into the field. - Pressing "Start planning" opens a session named `Plan issue: lucas/omp-deck#240` whose first visible turn is `Plan issue #240.` - Typing a closed issue number (e.g. `243`) and submitting leaves the dialog open with `#243 is closed` in the red error box. - Switching to "Inbox entry" clears the field and restores the inbox combobox behaviour unchanged. ## Assumptions & contingencies - The Forgejo issues endpoint is assumed to honour `type=issues` together with `state=open` and `q`, as `forgejo_search_issues` already relies on (`apps/server/src/forgejo/tools/issues.ts:103-113`). If an instance ignores `type` and returns pull requests, the local `pull_request == null` filter in `searchOpenIssues` already drops them; no further change. - `updated_at` is assumed present on issue payloads (it is declared on `ApiIssue` at `apps/server/src/forgejo/tools/api-types.ts:32`). If an instance omits it, `updatedAt` is `""`, `formatRelativeAgo("")` returns `""`, and rows render without a time line — acceptable, no fallback needed.
lucas closed this issue 2026-11-10 22:18:26 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
lucas/omp-deck#246
No description provided.