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
Binary file added output/playwright/new-chat-agents-dropdown.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
69 changes: 67 additions & 2 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,17 @@
<p v-if="newThreadCwd" class="new-thread-folder-selected" :title="newThreadCwd">
{{ t('Selected folder') }}: {{ newThreadCwd }}
</p>
<div v-if="newThreadAgentOptions.length > 1" class="new-thread-agents-select">
<p class="new-thread-agents-select-label">{{ t('Instructions') }}</p>
<ComposerDropdown
class="new-thread-agents-dropdown"
:model-value="selectedNewThreadAgentFile"
:options="newThreadAgentDropdownOptions"
:placeholder="t('Select instructions')"
:disabled="isLoadingNewThreadAgents"
@update:model-value="onSelectNewThreadAgentFile"
/>
</div>
<div class="new-thread-folder-actions">
<button class="new-thread-folder-action new-thread-folder-action-primary" type="button" @click="onOpenExistingFolder">
{{ t('Select folder') }}
Expand Down Expand Up @@ -874,6 +885,7 @@ import {
getAccounts,
createLocalDirectory,
getFirstLaunchPluginsCardPreference,
getAgentInstructionsOptions,
getHomeDirectory,
getTelegramConfig,
getProjectRootSuggestion,
Expand All @@ -890,7 +902,7 @@ import {
} from './api/codexGateway'
import type { ReasoningEffort, SpeedMode, ThreadScrollState, UiAccountEntry, UiRateLimitWindow, UiServerRequest, UiServerRequestReply, UiThreadTokenUsage } from './types/codex'
import type { ComposerDraftPayload, ThreadComposerExposed } from './components/content/ThreadComposer.vue'
import type { LocalDirectoryEntry, TelegramStatus, WorktreeBranchOption } from './api/codexGateway'
import type { AgentInstructionsOption, LocalDirectoryEntry, TelegramStatus, WorktreeBranchOption } from './api/codexGateway'
import { getFreeModeStatus, setFreeMode, setFreeModeCustomKey, setCustomProvider } from './api/codexGateway'
import { getPathLeafName, getPathParent, isProjectlessChatPath, normalizePathForUi } from './pathUtils.js'

Expand Down Expand Up @@ -1133,6 +1145,9 @@ const newThreadRuntime = ref<'local' | 'worktree'>('local')
const newWorktreeBaseBranch = ref('')
const worktreeBranchOptions = ref<WorktreeBranchOption[]>([])
const isLoadingWorktreeBranches = ref(false)
const newThreadAgentOptions = ref<AgentInstructionsOption[]>([])
const selectedNewThreadAgentFile = ref('project:AGENTS.md')
const isLoadingNewThreadAgents = ref(false)
const workspaceRootOptionsState = ref<{ order: string[]; labels: Record<string, string> }>({ order: [], labels: {} })
const worktreeInitStatus = ref<{ phase: 'idle' | 'running' | 'error'; title: string; message: string }>({
phase: 'idle',
Expand Down Expand Up @@ -1421,6 +1436,12 @@ const newWorktreeBranchDropdownOptions = computed<Array<{ value: string; label:
}
return options
})
const newThreadAgentDropdownOptions = computed<Array<{ value: string; label: string }>>(() =>
newThreadAgentOptions.value.map((option) => ({ value: option.value, label: option.label })),
)
const selectedNewThreadAgentOption = computed(() =>
newThreadAgentOptions.value.find((option) => option.value === selectedNewThreadAgentFile.value) ?? null,
)
const selectedWorktreeBranchLabel = computed(() => {
const selectedBranch = newWorktreeBaseBranch.value.trim()
if (!selectedBranch) return ''
Expand Down Expand Up @@ -2342,6 +2363,10 @@ function onSelectNewThreadFolder(cwd: string): void {
createFolderError.value = ''
}

function onSelectNewThreadAgentFile(value: string): void {
selectedNewThreadAgentFile.value = value.trim() || 'project:AGENTS.md'
}

function onSelectNewWorktreeBranch(branch: string): void {
newWorktreeBaseBranch.value = branch.trim()
}
Expand Down Expand Up @@ -3332,10 +3357,32 @@ watch(
() => newThreadCwd.value,
() => {
worktreeInitStatus.value = { phase: 'idle', title: '', message: '' }
void loadNewThreadAgentOptions()
void refreshDefaultProjectName()
},
)

async function loadNewThreadAgentOptions(): Promise<void> {
const cwd = newThreadCwd.value.trim()
selectedNewThreadAgentFile.value = 'project:AGENTS.md'
newThreadAgentOptions.value = []
if (!cwd) return

isLoadingNewThreadAgents.value = true
try {
const options = await getAgentInstructionsOptions(cwd)
newThreadAgentOptions.value = options
if (!options.some((option) => option.value === selectedNewThreadAgentFile.value)) {
selectedNewThreadAgentFile.value = options[0]?.value ?? 'project:AGENTS.md'
}
} catch {
newThreadAgentOptions.value = []
selectedNewThreadAgentFile.value = 'project:AGENTS.md'
} finally {
isLoadingNewThreadAgents.value = false
}
}

watch(
() => [newThreadRuntime.value, newThreadCwd.value] as const,
([runtime, cwd]) => {
Expand Down Expand Up @@ -3445,7 +3492,9 @@ async function submitFirstMessageForNewThread(
targetCwd = directory.cwd
newThreadCwd.value = directory.cwd
}
const threadId = await sendMessageToNewThread(text, targetCwd, imageUrls, skills, fileAttachments)
const selectedAgent = selectedNewThreadAgentOption.value
const baseInstructions = selectedAgent && !selectedAgent.isDefault ? selectedAgent.content : undefined
const threadId = await sendMessageToNewThread(text, targetCwd, imageUrls, skills, fileAttachments, baseInstructions)
if (!threadId) return
await router.replace({ name: 'thread', params: { threadId } })
scheduleMobileConversationJumpToLatest()
Expand Down Expand Up @@ -3750,6 +3799,22 @@ async function loadWorktreeBranches(sourceCwd: string): Promise<void> {
@apply mt-2 mb-0 max-w-3xl text-center text-xs text-zinc-500 break-all;
}

.new-thread-agents-select {
@apply mt-3 w-full max-w-3xl;
}

.new-thread-agents-select-label {
@apply m-0 mb-1 text-xs font-medium uppercase tracking-wide text-zinc-500;
}

.new-thread-agents-dropdown :deep(.composer-dropdown-trigger) {
@apply h-9 rounded-xl border border-zinc-200 bg-white px-3 text-sm text-zinc-700;
}

:global(:root.dark) .new-thread-agents-select-label {
@apply text-zinc-500;
}

.new-thread-folder-actions {
@apply mt-3 flex w-full max-w-3xl flex-wrap items-center justify-center gap-2;
}
Expand Down
112 changes: 100 additions & 12 deletions src/api/codexGateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { startThreadTurn } from './codexGateway'
import { getAgentInstructionsOptions, startThread, startThreadTurn } from './codexGateway'

function mockRpcFetch(): { requests: Array<{ method: string, params: Record<string, unknown> }> } {
const requests: Array<{ method: string, params: Record<string, unknown> }> = []
function mockRpcFetch(): { requests: Array<{ method: string, params: Record<string, unknown>, url: string }> } {
const requests: Array<{ method: string, params: Record<string, unknown>, url: string }> = []

vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const url = String(_input)
const body = typeof init?.body === 'string'
? JSON.parse(init.body) as { method: string, params: Record<string, unknown> }
? JSON.parse(init.body) as { method: string, params: Record<string, unknown>, model?: unknown, baseInstructions?: unknown }
: { method: '', params: {} }

requests.push(body)
requests.push({ ...body, url })

return new Response(JSON.stringify({
result: {
turn: {
id: `turn-${requests.length}`,
},
},
}), {
const result = url.includes('/codex-api/thread/start-with-agent-instructions') || body.method === 'thread/start'
? { thread: { id: `thread-${requests.length}` }, model: body.params?.model ?? body.model ?? '' }
: { turn: { id: `turn-${requests.length}` } }

return new Response(JSON.stringify({ result }), {
status: 200,
headers: {
'Content-Type': 'application/json',
Expand All @@ -28,6 +27,95 @@ function mockRpcFetch(): { requests: Array<{ method: string, params: Record<stri
return { requests }
}

describe('startThread instruction payloads', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('omits baseInstructions for the default AGENTS.md selection', async () => {
const { requests } = mockRpcFetch()

await startThread('/tmp/TestChat', 'gpt-5.4')

expect(requests).toHaveLength(1)
expect(requests[0].method).toBe('thread/start')
expect(requests[0].params).toEqual({
cwd: '/tmp/TestChat',
model: 'gpt-5.4',
})
})

it('sends only the selected custom agent instructions as baseInstructions', async () => {
const { requests } = mockRpcFetch()

await startThread('/tmp/TestChat', 'gpt-5.4', 'CUSTOM_AGENT_ONLY', { deferCwdUntilTurn: true })

expect(requests).toHaveLength(1)
expect(requests[0].url).toBe('/codex-api/thread/start-with-agent-instructions')
expect(requests[0]).toMatchObject({
cwd: '/tmp/TestChat',
model: 'gpt-5.4',
baseInstructions: 'CUSTOM_AGENT_ONLY',
})
expect(JSON.stringify(requests[0])).not.toContain('ORIGINAL_AGENTS_MARKER')
})
})

describe('getAgentInstructionsOptions', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('preserves project and global instruction scopes', async () => {
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({
data: [
{
value: 'project:AGENTS.md',
label: 'Default AGENTS.md',
path: '/tmp/TestChat/AGENTS.md',
content: 'ORIGINAL_AGENTS_MARKER',
isDefault: true,
scope: 'project',
},
{
value: 'global:AGENTS.review.md',
label: 'Global: AGENTS.review.md',
path: '/Users/igor/.codex/AGENTS.review.md',
content: 'GLOBAL_REVIEW_MARKER',
isDefault: false,
scope: 'global',
},
],
}), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
})))

const options = await getAgentInstructionsOptions('/tmp/TestChat')

expect(options).toEqual([
{
value: 'project:AGENTS.md',
label: 'Default AGENTS.md',
path: '/tmp/TestChat/AGENTS.md',
content: 'ORIGINAL_AGENTS_MARKER',
isDefault: true,
scope: 'project',
},
{
value: 'global:AGENTS.review.md',
label: 'Global: AGENTS.review.md',
path: '/Users/igor/.codex/AGENTS.review.md',
content: 'GLOBAL_REVIEW_MARKER',
isDefault: false,
scope: 'global',
},
])
})
})

describe('startThreadTurn collaboration mode payloads', () => {
afterEach(() => {
vi.unstubAllGlobals()
Expand Down
Loading