Default the Plan issue dialog to Forgejo issue, with an open-issue picker #246
Labels
No labels
Agent
Ignore
Agent
In Progress
Agent
Planned
Agent
Planning
Agent
Ready
Agent
Triage
Compat
Breaking
Kind
Bug
Kind
Documentation
Kind
Enhancement
Kind
Feature
Kind
Security
Kind
Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
lucas/omp-deck#246
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
The "Plan issue" dialog (
apps/web/src/components/PlanIssueModal.tsx) opens onthe "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
ForgejoClienttheserver 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:
startPlanIssuerejects a closed issue (#N is closed, 422) and a pullrequest (
#N is a pull request, not an issue, 422) for issue seeds(
apps/server/src/forgejo/plan-issue.ts:126-127). Anything offered that isnot an open issue is a dead entry, so the picker only ever lists open issues.
/api/forgejoserves config,test, deliveries, implement and plan-issue only
(
apps/server/src/routes-forgejo.ts:4-11).ForgejoClientnever 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 makethe 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 afterPlanIssueResponse(endsline 2167), add:
2. DispatchApi: open-issue search
DispatchApi(apps/server/src/forgejo/dispatch-api.ts:15-83) hasreadIssuebut no search;
forgejo_search_issuesexists 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 routetests already fake.
In
dispatch-api.ts:IssueSummary(line 109):DispatchApiinterface, afterreadIssue(line 31):Add
updated_at?: string;tointerface RawIssue(line 194-202), whichcurrently lacks it.
Implement in
createDispatchApi's returned object, next toreadIssue(line 342):
type: "issues"already excludes pull requests server-side; thepull_requestfilter is the local guard that keeps the contract true regardless. Ordering is
done here rather than through a
sortquery parameter so no unverified Forgejosort value is relied on.
3. Route:
GET /api/forgejo/issuesIn
apps/server/src/routes-forgejo.ts:createDispatchApi, type DispatchApifrom./forgejo/dispatch-api.tsand
type ListForgejoIssuesResponsefrom the protocol package.const ISSUE_PICKER_LIMIT = 50;(the instance-side capused elsewhere is
MAX_PAGE_LIMIT = 50,apps/server/src/forgejo/tools/shared.ts:189).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-128call site — the default covers production.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, besideplanIssue(line 76), add:Import
ListForgejoIssuesResponsewith the other protocol types at the top ofthe file. Nothing is added to
store.ts: the modal callsforgejoApidirectly,as it already does for the inbox list through
inboxApi.5. Issue suggestion matcher
New file
apps/web/src/lib/issue-suggest.ts, deliberately mirroringapps/web/src/lib/inbox-suggest.ts(itssuggestInboxItemsis typed onInboxItemand scores over title/body/id, so it cannot be reused directly):6. Modal: default source and radio order
In
apps/web/src/components/PlanIssueModal.tsx:SEED_OPTIONS(lines 22-26) toissue,inbox,freeso thedefault sits in the leading radio slot. Labels unchanged.
useState<SeedKind>("inbox")(line 39) becomesuseState<SeedKind>("issue").setSeedKind("inbox")in the open effect (line 58) becomessetSeedKind("issue"). The reset stays unconditional: every open starts onthe 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:
numberstate (line 49). The sharedquerystate holds the issueinput too, so one combobox implementation serves both sources.
issues: ForgejoIssueSummary[](prefetch, default[]),issueHits: ForgejoIssueSummary[] | null(server search results;nullmeans"not searching"),
issuesLoading: boolean,issuesError: string | undefined.Fetching:
unchanged and add an eager issue prefetch guarded on a workspace being
selected: reset
issues/issueHits/issuesError, setissuesLoading, callforgejoApi.listIssues(activeWorkspaceId), and on resolve (when notcancelled) storeres.issues; on reject store the error message inissuesError. ClearissuesLoadingin both paths. The effect's existingcancelledflag and[open, activeWorkspaceId]deps cover the new call.[open, seedKind, query], copying the140 ms pattern of
apps/web/src/views/KbCommandPalette.tsx:41-62: returnearly unless
open && seedKind === "issue"; letq = query.trim(); ifqis empty or matches
/^\d+$/,setIssueHits(null)and return — a typednumber never costs a request, because Forgejo's
q=searches prose, not issuenumbers, and the number is already a valid seed on its own. Otherwise
setTimeout(…, 140)callingforgejoApi.listIssues(workspaceId, q), storingres.issuesinissueHitsand resettingactiveIndexto 0; on failure setissueHitsto[]andissuesErrorto the message. Clean up withclearTimeoutplus acancelledflag so a stale response cannot overwrite anewer one.
[seedKind]that clearsquery,issueHits,activeIndexand sets
comboDismissedtofalse. Required becausequeryis now shared:switching sources must not carry an inbox id into the issue field.
Suggestions and seed:
const issueSuggestions = issueHits ?? suggestIssues(issues, query);— serverhits 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
dropdownOpenkeeps its existing shape against this combined list, sodismissOnEscape={!dropdownOpen}and the arrow/Enter/Escape handler workunchanged for both sources.
picksetsquerytoitem.idfor inbox entries and toString(issue.number)for issues, then dismisses the dropdown. Picking an issue therefore produces
exactly the state a typed number produces.
Number(number)path (lines 135-145):with the
issuebranch yielding{ kind: "issue", number: issueNumber }whenNumber.isInteger(issueNumber) && issueNumber >= 1, elsenull. The inbox andfree branches are unchanged.
Rendering the issue branch (replacing lines 286-298):
type="text"input (nottype="number", which cannot hold a title query)with
autoFocus,role="combobox",autoComplete="off",placeholder="Issue number, or search open issues", wired to the sameonChange/onFocus/onBlur/onKeyDownhandlers and the samesetInputElref as the inbox input.
autoFocuswith the new default is what makes anumber typeable the moment the dialog opens.
AnchoredPortaldropdown is the existing one; issue rows render#{number} {title}as the primary line andformatRelativeAgo(issue.updatedAt)(apps/web/src/lib/time.ts:75, alreadyunit-tested) as the secondary line, in the same two-line markup and
active/hover classes as the inbox rows.
issuesError→Could not load issues: {message} — type an issue number to plan it, rendered in the mutedtext-ink-3style, not the red error box: the number path still works, so this is a degraded list, not a failed action.issueNumbermatching a loaded issue (searchissueHits ?? 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 issueorissue #N not foundinto the existing error box.issuesLoadingand empty query →Loading open issues….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-deckrepository is the active workspace'sorigin.bun run typecheck— passes across all workspaces.bun test apps/server/src/routes-forgejo.test.ts— extend the file (itsharness boots an isolated SQLite and drives
buildForgejoRouterviaapp.request, lines 25-42) with adescribe("GET /forgejo/issues")covering:workspaceId→ 400;workspaceId→ 404;forgejoApireturning{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
qquery parameter reachedsearchOpenIssuesasnullwhenabsent and as the trimmed string when present;
forgejoApireturning{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.tsseeds deliveries.apps/web/src/lib/issue-suggest.test.ts(bun test, mirroring theexisting
apps/web/src/lib/time.test.tsstyle): empty query returns the 10most recently updated issues;
"240"returns only issues whose numbercontains 240 with a number-prefix match first;
"graceful shut"ranks anissue titled
Graceful shutdownabove one matching a single word; a querymatching nothing returns
[].bun run dev: open the Plan issue dialog.issue input — typing
240immediately puts240in the field with noclicks, and the line under the input reads
#240 Graceful shutdown.of writing, including
#245 Provider login gets lost sometimesand#240 Graceful shutdown), each with a relative updated time.gracefulnarrows the list to#240 Graceful shutdown; pressingEnter fills
240into the field.Plan issue: lucas/omp-deck#240whose first visible turn isPlan issue #240.243) and submitting leaves the dialogopen with
#243 is closedin the red error box.combobox behaviour unchanged.
Assumptions & contingencies
type=issuestogether withstate=openandq, asforgejo_search_issuesalready relies on(
apps/server/src/forgejo/tools/issues.ts:103-113). If an instance ignorestypeand returns pull requests, the localpull_request == nullfilter insearchOpenIssuesalready drops them; no further change.updated_atis assumed present on issue payloads (it is declared onApiIssueat
apps/server/src/forgejo/tools/api-types.ts:32). If an instance omits it,updatedAtis"",formatRelativeAgo("")returns"", and rows renderwithout a time line — acceptable, no fallback needed.