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
39 changes: 39 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,14 @@
@hide="onHideSelectedThreadTerminal"
@terminal-focus-change="onTerminalFocusChange"
/>
<ThreadGoalBar
v-if="selectedThreadGoal"
:goal="selectedThreadGoal"
:disabled="isUpdatingThreadGoal"
@edit="onEditThreadGoal"
@toggle-paused="onToggleThreadGoalPaused"
@clear="onClearThreadGoal"
/>
<ThreadPendingRequestPanel
v-if="selectedThreadPendingRequest"
:request="selectedThreadPendingRequest"
Expand Down Expand Up @@ -1173,6 +1181,7 @@ import DesktopLayout from './components/layout/DesktopLayout.vue'
import SidebarThreadTree from './components/sidebar/SidebarThreadTree.vue'
import ContentHeader from './components/content/ContentHeader.vue'
import ThreadComposer from './components/content/ThreadComposer.vue'
import ThreadGoalBar from './components/content/ThreadGoalBar.vue'
import ThreadPendingRequestPanel from './components/content/ThreadPendingRequestPanel.vue'
import QueuedMessages from './components/content/QueuedMessages.vue'
import RateLimitStatus from './components/content/RateLimitStatus.vue'
Expand Down Expand Up @@ -1411,6 +1420,7 @@ const {
projectDisplayNameById,
selectedThread,
selectedThreadTokenUsage,
selectedThreadGoal,
selectedThreadTerminalOpen,
selectedThreadServerRequests,
selectedLiveOverlay,
Expand Down Expand Up @@ -1469,6 +1479,9 @@ const {
stopPolling,
primeSelectedThread,
rollbackSelectedThread,
updateSelectedThreadGoalObjective,
toggleSelectedThreadGoalPaused,
clearSelectedThreadGoal,
} = useDesktopState()

const route = useRoute()
Expand Down Expand Up @@ -1506,6 +1519,7 @@ function prepareFeedbackLink(event: MouseEvent, message?: string): void {
}
const homeThreadComposerRef = ref<ThreadComposerExposed | null>(null)
const threadComposerRef = ref<ThreadComposerExposed | null>(null)
const isUpdatingThreadGoal = ref(false)
const threadConversationRef = ref<{ jumpToLatest: () => void } | null>(null)
const homeTerminalPanelRef = ref<ThreadTerminalPanelExposed | null>(null)
const threadTerminalPanelRef = ref<ThreadTerminalPanelExposed | null>(null)
Expand Down Expand Up @@ -3430,6 +3444,31 @@ function onSubmitThreadMessage(payload: { text: string; imageUrls: string[]; fil
void sendMessageToSelectedThread(text, payload.imageUrls, payload.skills, payload.mode, payload.fileAttachments, queueInsertIndex)
}

async function runThreadGoalUpdate(update: () => Promise<void>): Promise<void> {
if (isUpdatingThreadGoal.value) return
isUpdatingThreadGoal.value = true
try {
Comment on lines +3447 to +3450

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. Goal updates block other threads 🐞 Bug ☼ Reliability

App.vue uses a single global isUpdatingThreadGoal flag and runThreadGoalUpdate returns early when it
is set; if a goal update is in-flight and the user switches threads, the newly selected thread’s
goal bar stays disabled and new goal actions are dropped. This makes goal controls appear
unresponsive during cross-thread navigation.
Agent Prompt
### Issue description
`isUpdatingThreadGoal` is global in App.vue and used to disable the goal bar. While an update is in-flight for thread A, switching to thread B keeps the bar disabled and `runThreadGoalUpdate` drops new updates (`return`), blocking valid actions on thread B.

### Issue Context
Goal updates are thread-scoped, but the in-flight lock is app-scoped.

### Fix Focus Areas
- Track in-flight goal updates per-thread (e.g., `updatingThreadGoalById: Record<string, boolean>` or `updatingThreadGoalId: string | null`).
- Disable the goal bar only when the selected thread is the one being updated.
- Only suppress duplicate updates for the same thread, not all threads.
- file/path[start_line-end_line]
  - src/App.vue[1520-1523]
  - src/App.vue[1015-1022]
  - src/App.vue[3447-3456]

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

await update()
} catch {
// The shared desktop error state already contains the app-server failure.
} finally {
isUpdatingThreadGoal.value = false
}
}

function onEditThreadGoal(objective: string): void {
void runThreadGoalUpdate(() => updateSelectedThreadGoalObjective(objective))
}

function onToggleThreadGoalPaused(): void {
void runThreadGoalUpdate(toggleSelectedThreadGoalPaused)
}

function onClearThreadGoal(): void {
if (!window.confirm('Clear this thread goal?')) return
void runThreadGoalUpdate(clearSelectedThreadGoal)
}

function onEditQueuedMessage(messageId: string): void {
const queueIndex = selectedThreadQueuedMessages.value.findIndex((item) => item.id === messageId)
const message = queueIndex >= 0 ? selectedThreadQueuedMessages.value[queueIndex] : undefined
Expand Down
67 changes: 66 additions & 1 deletion src/api/codexGateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { getAvailableModelIds, getThreadDetail, listDirectoryComposioConnectors, resumeThread, startThreadTurn } from './codexGateway'
import {
clearThreadGoal,
compactThread,
getAvailableModelIds,
getThreadDetail,
getThreadGoal,
listDirectoryComposioConnectors,
resumeThread,
setThreadGoal,
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 +71,61 @@ describe('startThreadTurn collaboration mode payloads', () => {
})
})

describe('thread goal RPCs', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('normalizes goal reads and sends lifecycle updates through app-server', async () => {
const requests: Array<{ method: string; params: Record<string, unknown> }> = []
const goal = {
threadId: 'thread-1',
objective: 'Ship Goal mode',
status: 'active',
tokenBudget: 5000,
tokensUsed: 120,
timeUsedSeconds: 9,
createdAt: 10,
updatedAt: 11,
}
vi.stubGlobal('fetch', vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const request = JSON.parse(String(init?.body)) as { method: string; params: Record<string, unknown> }
requests.push(request)
const result = request.method === 'thread/goal/clear' ? { cleared: true } : { goal }
return new Response(JSON.stringify({ result }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}))

await expect(getThreadGoal('thread-1')).resolves.toEqual(goal)
await expect(setThreadGoal('thread-1', { status: 'paused' })).resolves.toEqual(goal)
await expect(clearThreadGoal('thread-1')).resolves.toBe(true)

expect(requests).toEqual([
{ method: 'thread/goal/get', params: { threadId: 'thread-1' } },
{ method: 'thread/goal/set', params: { threadId: 'thread-1', status: 'paused' } },
{ method: 'thread/goal/clear', params: { threadId: 'thread-1' } },
])
})
})

describe('thread compaction RPC', () => {
afterEach(() => {
vi.unstubAllGlobals()
})

it('starts app-server compaction for the selected thread', async () => {
const { requests } = mockRpcFetch()

await compactThread('thread-compact')

expect(requests).toEqual([
{ method: 'thread/compact/start', params: { threadId: 'thread-compact' } },
])
})
})

describe('listDirectoryComposioConnectors', () => {
afterEach(() => {
vi.unstubAllGlobals()
Expand Down
57 changes: 57 additions & 0 deletions src/api/codexGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import type {
UiMessage,
UiProjectGroup,
UiThread,
UiThreadGoal,
ThreadGoalStatus,
UiReviewAction,
UiReviewActionLevel,
UiReviewFile,
Expand Down Expand Up @@ -555,6 +557,57 @@ async function callRpc<T>(method: string, params?: unknown): Promise<T> {
}
}

const THREAD_GOAL_STATUSES = new Set<ThreadGoalStatus>([
'active',
'paused',
'blocked',
'usageLimited',
'budgetLimited',
'complete',
])

export function normalizeThreadGoal(value: unknown): UiThreadGoal | null {
const record = asRecord(value)
if (!record) return null
const threadId = readString(record.threadId)
const objective = readString(record.objective)
const rawStatus = readString(record.status)
if (!threadId || objective === null || !rawStatus || !THREAD_GOAL_STATUSES.has(rawStatus as ThreadGoalStatus)) {
return null
}

return {
threadId,
objective,
status: rawStatus as ThreadGoalStatus,
tokenBudget: readNumber(record.tokenBudget),
tokensUsed: readNumber(record.tokensUsed) ?? 0,
timeUsedSeconds: readNumber(record.timeUsedSeconds) ?? 0,
createdAt: readNumber(record.createdAt) ?? 0,
updatedAt: readNumber(record.updatedAt) ?? 0,
}
}

export async function getThreadGoal(threadId: string): Promise<UiThreadGoal | null> {
const payload = await callRpc<{ goal?: unknown }>('thread/goal/get', { threadId })
return normalizeThreadGoal(payload.goal)
}

export async function setThreadGoal(
threadId: string,
update: { objective?: string | null; status?: ThreadGoalStatus | null; tokenBudget?: number | null },
): Promise<UiThreadGoal> {
const payload = await callRpc<{ goal?: unknown }>('thread/goal/set', { threadId, ...update })
const goal = normalizeThreadGoal(payload.goal)
if (!goal) throw new Error('RPC thread/goal/set returned an invalid goal')
return goal
}

export async function clearThreadGoal(threadId: string): Promise<boolean> {
const payload = await callRpc<{ cleared?: boolean }>('thread/goal/clear', { threadId })
return payload.cleared === true
}

function normalizeFallbackFileChange(value: unknown): UiFileChange | null {
const record = asRecord(value)
if (!record) return null
Expand Down Expand Up @@ -1550,6 +1603,10 @@ export async function archiveThread(threadId: string): Promise<void> {
await callRpc('thread/archive', { threadId })
}

export async function compactThread(threadId: string): Promise<void> {
await callRpc('thread/compact/start', { threadId })
}

export async function renameThread(threadId: string, threadName: string): Promise<void> {
await callRpc('thread/name/set', { threadId, name: threadName })
}
Expand Down
Loading