Skip to content

feat(app): copy the device code before opening the sign-in page - #59676

Open
aryankeluskar wants to merge 3 commits into
anomalyco:devfrom
aryankeluskar:copy-device-code
Open

aryankeluskar wants to merge 3 commits into
anomalyco:devfrom
aryankeluskar:copy-device-code

Conversation

@aryankeluskar

Copy link
Copy Markdown

Issue for this PR

None that I could find.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Connecting GitHub Copilot in the app opens GitHub's device page. That page asks for the confirmation code but doesn't prefill it, so you had to switch back to the app and press the copy button next to the code. Now the app copies the code to the clipboard right before it opens the browser, and you can paste it straight away. ChatGPT's device sign-in gets the same behavior.

The change is in open() in packages/app/src/providers/connect/controller.ts, which runs for the automatic open and for the Open browser button. It finds the code in the attempt's instructions, such as Enter code: 8EA6-ACBA, with the same regex as OAUTH_CODE in the TUI's dialog-integration.tsx. The copy has to come first because once the browser has focus, the web app can no longer write to the clipboard.

If the URL already carries user_code, as the OpenCode Console sign-in's URL does, open() leaves the clipboard alone. If the clipboard write fails, open() ignores the error and opens the browser anyway. The copy button still works.

How did you verify your code works?

A new case in packages/app/e2e/regression/settings.spec.ts, "providers: a device sign-in copies the code its page does not prefill", starts a Copilot sign-in, waits for the popup, and reads the clipboard. It fails without the change with Expected "8EA6-ACBA", Received "". To support it, onIntegrationOAuth in the e2e mock server can now return instructions. The server used to hard-code them to "".

All 13 providers: cases pass, and typecheck, oxlint, and prettier are clean:

cd packages/app
bun typecheck
bunx playwright test e2e/regression/settings.spec.ts --project chromium --grep "providers:"

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
  <style>
    text { fill: currentColor; stroke: none; font: 600 9px ui-monospace, monospace; letter-spacing: 0.4px }
    .fly { opacity: 0; animation: fly 3.2s ease-in-out infinite }
    .check { animation: check 3.2s ease-out infinite }
    .browser { animation: browser 3.2s ease-out infinite }
    .paste { animation: paste 3.2s ease-out infinite }
    @keyframes fly { 0% { opacity: 0; transform: translateX(0) } 6% { opacity: 0.8 } 30% { opacity: 0.8; transform: translateX(62px) scale(0.6) } 36%, 100% { opacity: 0; transform: translateX(62px) scale(0.6) } }
    @keyframes check { 0%, 30% { opacity: 0 } 38%, 100% { opacity: 1 } }
    @keyframes browser { 0%, 42% { opacity: 0.2 } 52%, 100% { opacity: 1 } }
    @keyframes paste { 0%, 64% { opacity: 0 } 74%, 100% { opacity: 1 } }
  </style>
  <!-- confirmation code in the dialog -->
  <rect x="8" y="24" width="70" height="24" rx="5" stroke-opacity="0.45"/>
  <text x="43" y="39.5" text-anchor="middle">8EA6-ACBA</text>
  <g class="fly" style="transform-box: fill-box; transform-origin: center">
    <text x="43" y="39.5" text-anchor="middle">8EA6-ACBA</text>
  </g>
  <!-- clipboard -->
  <rect x="100" y="23" width="22" height="28" rx="3.5" stroke-width="1.5"/>
  <rect x="105.5" y="20" width="11" height="6" rx="2" stroke-width="1.5"/>
  <path class="check" d="M105.5 37.5l4 4 7-8" stroke-width="1.75"/>
  <!-- then the browser -->
  <path d="M130 37h14m-4-4l4 4-4 4" stroke-width="1.5" stroke-opacity="0.45"/>
  <g class="browser">
    <rect x="152" y="13" width="80" height="46" rx="5" stroke-width="1.5"/>
    <path d="M152 22.5h80" stroke-opacity="0.45"/>
    <circle cx="158" cy="17.8" r="1.2" fill="currentColor" stroke="none" opacity="0.5"/>
    <circle cx="162.5" cy="17.8" r="1.2" fill="currentColor" stroke="none" opacity="0.5"/>
    <circle cx="167" cy="17.8" r="1.2" fill="currentColor" stroke="none" opacity="0.5"/>
    <rect x="162" y="31" width="60" height="16" rx="3" stroke-opacity="0.45"/>
    <text class="paste" x="192" y="42" text-anchor="middle">8EA6-ACBA</text>
  </g>
</svg>

Screenshots / recordings

None. Nothing on screen changes, because the code is already on the clipboard when the browser opens.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

Device sign-ins such as GitHub Copilot's open a page that asks for the
code without prefilling it. Copy the code to the clipboard first, so it
can be pasted straight away; once the browser has focus the app can no
longer write to the clipboard. Sign-ins whose URL already carries the
code (`user_code`) leave the clipboard alone.
@aryankeluskar
aryankeluskar marked this pull request as ready for review October 27, 2026 17:50

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Works as described. With the base controller, the new e2e case fails (Expected "8EA6-ACBA", Received ""), and with this change it passes. The other providers: cases and tsc --noEmit for packages/app pass. The regex matches the TUI's OAUTH_CODE and the Enter code: … text the Copilot, OpenAI and Console plugins produce, and the Console sign-in (whose URL carries user_code) is left alone.

The public API is unchanged.

One small fix is noted inline: new URL(url) can now throw before the browser opens.


// Device pages such as GitHub's ask for a code the URL does not prefill (no `user_code`). Copy it
// before opening the browser: once the browser has focus, the app can no longer write to the clipboard.
const code = new URL(url).searchParams.has("user_code")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now runs before any URL check and outside the .catch. With an empty or unparsable url (previously guarded by if (!url) return), new URL throws, open() rejects unhandled, and browserFailed is never set. Using URL.canParse(url) && new URL(url).searchParams.has("user_code"), like the web platform's openExternal does, or keeping the !url early return, avoids that.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new commit only merges dev. Nothing in this PR's files changed, so the earlier review still applies and the open point about new URL(url) throwing on an empty or invalid URL is not yet addressed. The public API is unchanged.

Restore the early return for an empty sign-in URL and check
URL.canParse before reading user_code, so open() no longer throws
before the browser opens and still reports browserFailed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The earlier point is addressed: the empty-URL guard is back, and URL.canParse keeps an invalid URL from throwing before the browser opens. I re-ran the device-code and Console sign-in e2e cases (both pass) and tsc --noEmit for packages/app (clean). The public API is unchanged. No new concerns.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant