-
-
Notifications
You must be signed in to change notification settings - Fork 172
Add model-aware reasoning and ChatGPT Pro handoff #214
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -100,6 +100,7 @@ | |
| @fork-thread="onForkThread" | ||
| @remove-project="onRemoveProject" @reorder-project="onReorderProject" | ||
| @copy-thread-chat="onCopyThreadChat" | ||
| @continue-in-chatgpt-pro="onContinueInChatGptPro" | ||
| @automations-changed="onAutomationsChanged" | ||
| @start-new-chat="onStartProjectlessNewChat" /> | ||
| </div> | ||
|
|
@@ -488,7 +489,13 @@ | |
| </span> | ||
| </div> | ||
| <div class="sidebar-settings-rate-limits"> | ||
| <RateLimitStatus :snapshots="accountRateLimitSnapshots" /> | ||
| <RateLimitStatus | ||
| :snapshots="accountRateLimitSnapshots" | ||
| :reset-credits="rateLimitResetCredits" | ||
| :is-consuming-reset="isConsumingRateLimitReset" | ||
| :reset-notice="rateLimitResetNotice" | ||
| @consume-reset="consumeBankedRateLimitReset" | ||
| /> | ||
| </div> | ||
| <div class="sidebar-settings-build-label" :aria-label="t('Worktree name and version')"> | ||
| WT {{ worktreeName }} · v{{ appVersion }} | ||
|
|
@@ -944,7 +951,8 @@ | |
| :cwd="composerCwd" | ||
| :collaboration-modes="availableCollaborationModes" | ||
| :selected-collaboration-mode="selectedCollaborationMode" | ||
| :models="availableModelIds" :selected-model="composerSelectedModelId" | ||
| :models="availableModels" :selected-model="composerSelectedModelId" | ||
| :reasoning-efforts="availableReasoningEfforts" | ||
| :selected-reasoning-effort="selectedReasoningEffort" | ||
| :selected-speed-mode="selectedSpeedMode" | ||
| :is-updating-speed-mode="isUpdatingSpeedMode" | ||
|
|
@@ -1026,7 +1034,8 @@ | |
| :cwd="composerCwd" | ||
| :collaboration-modes="availableCollaborationModes" | ||
| :selected-collaboration-mode="selectedCollaborationMode" | ||
| :models="availableModelIds" | ||
| :models="availableModels" | ||
| :reasoning-efforts="availableReasoningEfforts" | ||
| :selected-model="composerSelectedModelId" | ||
| :selected-reasoning-effort="selectedReasoningEffort" | ||
| :selected-speed-mode="selectedSpeedMode" | ||
|
|
@@ -1054,6 +1063,14 @@ | |
| </section> | ||
| </template> | ||
| </DesktopLayout> | ||
| <div | ||
| v-if="proHandoffNotice" | ||
| class="pro-handoff-notice" | ||
| :class="{ 'is-error': proHandoffNotice.type === 'error' }" | ||
| :role="proHandoffNotice.type === 'error' ? 'alert' : 'status'" | ||
| > | ||
| {{ proHandoffNotice.text }} | ||
| </div> | ||
| <div v-if="projectZipExportStatus.phase !== 'idle'" class="project-zip-modal-backdrop" role="presentation"> | ||
| <div class="project-zip-modal" role="dialog" aria-modal="true" :aria-label="t('Export Project')" @click.stop> | ||
| <div class="project-zip-modal-header"> | ||
|
|
@@ -1193,6 +1210,7 @@ import { | |
| checkoutGitBranch, | ||
| cloneGithubRepository, | ||
| configureTelegramBot, | ||
| createChatGptProHandoff, | ||
| createPermanentWorktree, | ||
| createWorktree, | ||
| createProjectlessThreadDirectory, | ||
|
|
@@ -1417,14 +1435,18 @@ const { | |
| codexQuota, | ||
| selectedThreadId, | ||
| availableCollaborationModes, | ||
| availableModelIds, | ||
| availableModels, | ||
| availableReasoningEfforts, | ||
| selectedCollaborationMode, | ||
| selectedModelId, | ||
| selectedReasoningEffort, | ||
| selectedSpeedMode, | ||
| codexCliMissingError, | ||
| installedSkills, | ||
| accountRateLimitSnapshots, | ||
| rateLimitResetCredits, | ||
| isConsumingRateLimitReset, | ||
| rateLimitResetNotice, | ||
| messages, | ||
| hasMoreOlderMessages, | ||
| isLoadingThreads, | ||
|
|
@@ -1437,6 +1459,7 @@ const { | |
| isUpdatingSpeedMode, | ||
| error: desktopError, | ||
| refreshAll, | ||
| consumeBankedRateLimitReset, | ||
| refreshSkills, | ||
| selectThread, | ||
| ensureThreadMessagesLoaded, | ||
|
|
@@ -1541,6 +1564,17 @@ const projectZipExportStatus = ref<{ phase: 'idle' | 'exporting' | 'ready'; load | |
| fileName: '', | ||
| error: '', | ||
| }) | ||
| const proHandoffNotice = ref<{ text: string; type: 'success' | 'error' } | null>(null) | ||
| let proHandoffNoticeTimer: ReturnType<typeof setTimeout> | null = null | ||
|
|
||
| function showProHandoffNotice(text: string, type: 'success' | 'error' = 'success'): void { | ||
| proHandoffNotice.value = { text, type } | ||
| if (proHandoffNoticeTimer) clearTimeout(proHandoffNoticeTimer) | ||
| proHandoffNoticeTimer = setTimeout(() => { | ||
| proHandoffNotice.value = null | ||
| proHandoffNoticeTimer = null | ||
| }, type === 'error' ? 6000 : 4000) | ||
| } | ||
| const worktreeInitStatus = ref<{ phase: 'idle' | 'running' | 'error'; title: string; message: string }>({ | ||
| phase: 'idle', | ||
| title: '', | ||
|
|
@@ -2174,6 +2208,10 @@ onUnmounted(() => { | |
| clearTimeout(threadSearchTimer) | ||
| threadSearchTimer = null | ||
| } | ||
| if (proHandoffNoticeTimer) { | ||
| clearTimeout(proHandoffNoticeTimer) | ||
| proHandoffNoticeTimer = null | ||
| } | ||
| clearTerminalKeyboardFocusFallbackTimer() | ||
| stopPolling() | ||
| }) | ||
|
|
@@ -4210,6 +4248,29 @@ async function copySelectedThreadChat(): Promise<void> { | |
| } | ||
| } | ||
|
|
||
| async function onContinueInChatGptPro(threadId: string): Promise<void> { | ||
| if (threadId !== selectedThreadId.value || !composerCwd.value) return | ||
| const chatWindow = window.open('about:blank', '_blank') | ||
| if (chatWindow) chatWindow.opener = null | ||
| showProHandoffNotice('Packaging this thread for ChatGPT Pro…') | ||
| try { | ||
| const handoff = await createChatGptProHandoff(threadId, composerCwd.value) | ||
| await copyTextToClipboard(handoff.markdown) | ||
| if (chatWindow) { | ||
| chatWindow.location.replace(handoff.chatgptUrl) | ||
| showProHandoffNotice('Handoff copied. Select GPT-5.6 Sol Pro, then paste and send.') | ||
| } else { | ||
| showProHandoffNotice('Handoff copied. Open ChatGPT, select GPT-5.6 Sol Pro, then paste and send.') | ||
| } | ||
| } catch (error) { | ||
| chatWindow?.close() | ||
| showProHandoffNotice( | ||
| error instanceof Error ? error.message : 'Failed to prepare the ChatGPT Pro handoff', | ||
| 'error', | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| function buildThreadMarkdown(): string { | ||
| const lines: string[] = [] | ||
| const threadTitle = selectedThread.value?.title?.trim() || 'Untitled thread' | ||
|
|
@@ -6119,4 +6180,20 @@ async function loadWorktreeBranches(sourceCwd: string): Promise<void> { | |
| @apply border-t border-zinc-100 px-3 py-2 text-[11px] text-zinc-500; | ||
| } | ||
|
|
||
| .pro-handoff-notice { | ||
| @apply fixed bottom-5 left-1/2 z-[120] max-w-[min(92vw,34rem)] -translate-x-1/2 rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-900 shadow-lg; | ||
| } | ||
|
|
||
| .pro-handoff-notice.is-error { | ||
| @apply border-rose-200 bg-rose-50 text-rose-900; | ||
| } | ||
|
|
||
| :root.dark .pro-handoff-notice { | ||
| @apply border-emerald-800 bg-emerald-950 text-emerald-100; | ||
| } | ||
|
|
||
| :root.dark .pro-handoff-notice.is-error { | ||
| @apply border-rose-800 bg-rose-950 text-rose-100; | ||
| } | ||
|
Comment on lines
+6191
to
+6197
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. Dark overrides kept in app.vue The new pro-handoff-notice dark-theme overrides are implemented as component-scoped :root.dark CSS inside src/App.vue rather than placing the decisive overrides in src/style.css. This violates the requirement to centralize dark-theme fixes for shared/large UI surfaces, increasing the risk of fragmented or fragile theming behavior. Agent Prompt
|
||
|
|
||
| </style> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,14 @@ | ||
| import { afterEach, describe, expect, it, vi } from 'vitest' | ||
| import { getAvailableModelIds, getThreadDetail, listDirectoryComposioConnectors, resumeThread, startThreadTurn } from './codexGateway' | ||
| import { | ||
| consumeRateLimitResetCredit, | ||
| getAccountRateLimitsState, | ||
| getAvailableModelIds, | ||
| getAvailableModels, | ||
| getThreadDetail, | ||
| listDirectoryComposioConnectors, | ||
| resumeThread, | ||
| startThreadTurn, | ||
| } from './codexGateway' | ||
|
|
||
| function mockRpcFetch(): { requests: Array<{ method: string, params: Record<string, unknown> }> } { | ||
| const requests: Array<{ method: string, params: Record<string, unknown> }> = [] | ||
|
|
@@ -61,6 +70,78 @@ describe('startThreadTurn collaboration mode payloads', () => { | |
| }) | ||
| }) | ||
|
|
||
| describe('banked rate-limit resets', () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals() | ||
| }) | ||
|
|
||
| it('preserves the authoritative reset count and optional credit details', async () => { | ||
| vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { | ||
| const request = JSON.parse(String(init?.body)) as { method: string } | ||
| expect(request.method).toBe('account/rateLimits/read') | ||
| return new Response(JSON.stringify({ | ||
| result: { | ||
| rateLimits: { | ||
| limitId: 'codex', | ||
| primary: { usedPercent: 75, windowDurationMins: 300, resetsAt: 1780000000 }, | ||
| }, | ||
| rateLimitResetCredits: { | ||
| availableCount: 2, | ||
| credits: [{ | ||
| id: 'RateLimitResetCredit_1', | ||
| resetType: 'codexRateLimits', | ||
| status: 'available', | ||
| grantedAt: 1779000000, | ||
| expiresAt: 1781000000, | ||
| title: 'Full reset', | ||
| description: 'Reset an eligible Codex rate-limit window.', | ||
| }], | ||
| }, | ||
| }, | ||
| }), { | ||
| status: 200, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }) | ||
| })) | ||
|
|
||
| await expect(getAccountRateLimitsState()).resolves.toMatchObject({ | ||
| codexSnapshot: { | ||
| limitId: 'codex', | ||
| primary: { usedPercent: 75 }, | ||
| }, | ||
| snapshots: [{ limitId: 'codex' }], | ||
| resetCredits: { | ||
| availableCount: 2, | ||
| credits: [{ | ||
| id: 'RateLimitResetCredit_1', | ||
| title: 'Full reset', | ||
| expiresAt: 1781000000, | ||
| }], | ||
| }, | ||
| }) | ||
| }) | ||
|
|
||
| it('uses the selected opaque credit ID and caller-provided idempotency key', async () => { | ||
| const requests: Array<{ method: string; params: Record<string, unknown> }> = [] | ||
| vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { | ||
| requests.push(JSON.parse(String(init?.body)) as { method: string; params: Record<string, unknown> }) | ||
|
Comment on lines
+124
to
+127
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win Remove the duplicate declarations.
🤖 Prompt for AI Agents |
||
| return new Response(JSON.stringify({ result: { outcome: 'reset' } }), { | ||
| status: 200, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }) | ||
| })) | ||
|
|
||
| await expect(consumeRateLimitResetCredit('RateLimitResetCredit_1', 'reset-attempt-1')).resolves.toBe('reset') | ||
| expect(requests).toEqual([{ | ||
| method: 'account/rateLimitResetCredit/consume', | ||
| params: { | ||
| idempotencyKey: 'reset-attempt-1', | ||
| creditId: 'RateLimitResetCredit_1', | ||
| }, | ||
| }]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('listDirectoryComposioConnectors', () => { | ||
| afterEach(() => { | ||
| vi.unstubAllGlobals() | ||
|
|
@@ -173,6 +254,45 @@ describe('getAvailableModelIds', () => { | |
| })).resolves.toEqual(['gpt-5.5', 'gpt-5.4-mini']) | ||
| expect(requests).toEqual(['/codex-api/provider-models', '/codex-api/rpc']) | ||
| }) | ||
|
|
||
| it('preserves server-provided reasoning effort order including max and ultra', async () => { | ||
| vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { | ||
| const body = typeof init?.body === 'string' | ||
| ? JSON.parse(init.body) as { method: string } | ||
| : { method: '' } | ||
| expect(body.method).toBe('model/list') | ||
| return new Response(JSON.stringify({ | ||
| result: { | ||
| data: [{ | ||
| id: 'gpt-5.6-sol', | ||
| displayName: 'GPT-5.6 Sol', | ||
| description: 'Frontier coding model', | ||
| supportedReasoningEfforts: [ | ||
| { reasoningEffort: 'low', description: 'Fast' }, | ||
| { reasoningEffort: 'max', description: 'Deep' }, | ||
| { reasoningEffort: 'ultra', description: 'Deepest' }, | ||
| ], | ||
| defaultReasoningEffort: 'low', | ||
| }], | ||
| }, | ||
| }), { | ||
| status: 200, | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| }) | ||
| })) | ||
|
|
||
| await expect(getAvailableModels({ includeProviderModels: false })).resolves.toEqual([{ | ||
| id: 'gpt-5.6-sol', | ||
| displayName: 'GPT-5.6 Sol', | ||
| description: 'Frontier coding model', | ||
| supportedReasoningEfforts: [ | ||
| { value: 'low', description: 'Fast' }, | ||
| { value: 'max', description: 'Deep' }, | ||
| { value: 'ultra', description: 'Deepest' }, | ||
| ], | ||
| defaultReasoningEffort: 'low', | ||
| }]) | ||
| }) | ||
| }) | ||
|
|
||
| describe('getThreadDetail', () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3. Pro handoff silent no-op
🐞 Bug≡ CorrectnessAgent Prompt
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools