Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 81 additions & 4 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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 }}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -1193,6 +1210,7 @@ import {
checkoutGitBranch,
cloneGithubRepository,
configureTelegramBot,
createChatGptProHandoff,
createPermanentWorktree,
createWorktree,
createProjectlessThreadDirectory,
Expand Down Expand Up @@ -1417,14 +1435,18 @@ const {
codexQuota,
selectedThreadId,
availableCollaborationModes,
availableModelIds,
availableModels,
availableReasoningEfforts,
selectedCollaborationMode,
selectedModelId,
selectedReasoningEffort,
selectedSpeedMode,
codexCliMissingError,
installedSkills,
accountRateLimitSnapshots,
rateLimitResetCredits,
isConsumingRateLimitReset,
rateLimitResetNotice,
messages,
hasMoreOlderMessages,
isLoadingThreads,
Expand All @@ -1437,6 +1459,7 @@ const {
isUpdatingSpeedMode,
error: desktopError,
refreshAll,
consumeBankedRateLimitReset,
refreshSkills,
selectThread,
ensureThreadMessagesLoaded,
Expand Down Expand Up @@ -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: '',
Expand Down Expand Up @@ -2174,6 +2208,10 @@ onUnmounted(() => {
clearTimeout(threadSearchTimer)
threadSearchTimer = null
}
if (proHandoffNoticeTimer) {
clearTimeout(proHandoffNoticeTimer)
proHandoffNoticeTimer = null
}
clearTerminalKeyboardFocusFallbackTimer()
stopPolling()
})
Expand Down Expand Up @@ -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')
Comment on lines +4251 to +4253

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Remediation recommended

3. Pro handoff silent no-op 🐞 Bug ≡ Correctness

The sidebar enables “Continue in ChatGPT Pro…” for any selected thread, but the App.vue handler
returns immediately when composerCwd is empty, resulting in a click that does nothing (no notice, no
error) for threads without a working directory. This can occur when the selected thread’s cwd is
missing/empty.
Agent Prompt
### Issue description
The new menu action can be enabled but perform a silent early return when `composerCwd` is empty, producing a confusing no-op.

### Issue Context
- The menu item is disabled only when the thread is not selected.
- `composerCwd` is derived from `selectedThread.cwd` on the thread route; if that is empty, `onContinueInChatGptPro()` returns before showing any notice.

### Fix Focus Areas
- src/App.vue[4241-4246]
- src/App.vue[1800-1803]
- src/components/sidebar/SidebarThreadTree.vue[627-644]

### Suggested fix
Pick one:
1) In `onContinueInChatGptPro`, replace the silent return for missing `composerCwd` with `showProHandoffNotice('This thread has no working directory to package.', 'error')`.
2) Pass a `hasCwd`/`cwd` boolean down to `SidebarThreadTree` so it can disable the menu item (and set a title) when the selected thread lacks a cwd.

Either approach ensures the user gets immediate feedback instead of nothing happening.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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'
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. Dark overrides kept in app.vue 📘 Rule violation ⚙ Maintainability

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
## Issue description
The dark-theme overrides for the new `pro-handoff-notice` are implemented via component-scoped `:root.dark ...` rules inside `src/App.vue`, but compliance requires decisive dark-theme overrides for shared/large UIs to be placed in `src/style.css`.

## Issue Context
`pro-handoff-notice` is a global/shared UI notice (fixed overlay) and should follow centralized theming rules to avoid scattered dark-mode overrides.

## Fix Focus Areas
- src/App.vue[6181-6187]
- src/style.css[1-999999]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


</style>
122 changes: 121 additions & 1 deletion src/api/codexGateway.test.ts
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> }> = []
Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Remove the duplicate declarations.

requests is declared twice in the same scope, so TypeScript compilation fails. The duplicate requests.push(...) must also be removed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/api/codexGateway.test.ts` around lines 124 - 127, Remove the duplicate
requests declaration and its corresponding duplicate requests.push call within
the test case “uses the selected opaque credit ID and caller-provided
idempotency key,” leaving one requests collection and one recording operation.

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()
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading