From 84235495a9adaa955845463b40187bcedde6fbd2 Mon Sep 17 00:00:00 2001 From: jasonchou021 Date: Thu, 21 May 2026 12:29:41 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E5=AE=9E=E6=97=B6=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/composables/useDesktopState.test.ts | 78 +++++++++++++++++++++++++ src/composables/useDesktopState.ts | 29 +++++++++ 2 files changed, 107 insertions(+) diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index c8ce3e575..0b372cc9d 100644 --- a/src/composables/useDesktopState.test.ts +++ b/src/composables/useDesktopState.test.ts @@ -598,6 +598,84 @@ describe('startup request deduplication', () => { }) }) +describe('thread archive notifications', () => { + it('removes a remotely archived thread from the live thread list', async () => { + installTestWindow() + let notificationHandler: ((notification: { method: string; params?: unknown }) => void) | undefined + gatewayMocks.subscribeCodexNotifications.mockImplementation((handler) => { + notificationHandler = handler as typeof notificationHandler + return vi.fn() + }) + gatewayMocks.getPendingServerRequests.mockResolvedValue([]) + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ + groups: [{ + projectName: 'Project', + threads: [ + thread('keep-thread', '/tmp/project'), + thread('archive-me', '/tmp/project'), + ], + }], + nextCursor: null, + }) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false }) + state.startPolling() + + notificationHandler?.({ + method: 'thread/archived', + params: { threadId: 'archive-me' }, + }) + + expect(state.projectGroups.value.flatMap((group) => group.threads.map((row) => row.id))).toEqual([ + 'keep-thread', + ]) + }) + + it('moves selection away when the selected thread is archived by another client', async () => { + installTestWindow() + let notificationHandler: ((notification: { method: string; params?: unknown }) => void) | undefined + gatewayMocks.subscribeCodexNotifications.mockImplementation((handler) => { + notificationHandler = handler as typeof notificationHandler + return vi.fn() + }) + gatewayMocks.getPendingServerRequests.mockResolvedValue([]) + gatewayMocks.resumeThread.mockResolvedValue(null) + gatewayMocks.getThreadDetail.mockResolvedValue({ + messages: [], + inProgress: false, + activeTurnId: '', + turnIndexByTurnId: {}, + hasMoreOlder: false, + }) + gatewayMocks.getThreadGroupsPage.mockResolvedValue({ + groups: [{ + projectName: 'Project', + threads: [ + thread('archive-me', '/tmp/project'), + thread('next-thread', '/tmp/project'), + ], + }], + nextCursor: null, + }) + + const state = useDesktopState() + await state.refreshAll({ includeSelectedThreadMessages: false }) + state.primeSelectedThread('archive-me') + state.startPolling() + + notificationHandler?.({ + method: 'thread/archived', + params: { threadId: 'archive-me' }, + }) + + expect(state.selectedThreadId.value).toBe('next-thread') + expect(state.projectGroups.value.flatMap((group) => group.threads.map((row) => row.id))).toEqual([ + 'next-thread', + ]) + }) +}) + describe('live error overlay', () => { it('shows the default thinking overlay while a selected thread is in progress without activity events', async () => { installTestWindow() diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index a1809e544..eff9a8599 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -3752,6 +3752,14 @@ export function useDesktopState() { } } + if (notification.method === 'thread/archived') { + const threadId = extractThreadIdFromNotification(notification) + if (threadId) { + applyArchivedThreadNotification(threadId) + } + return + } + if (notification.method === 'account/rateLimits/updated') { setCodexRateLimit(pickCodexRateLimitSnapshot(notification.params)) return @@ -4220,6 +4228,27 @@ export function useDesktopState() { applyThreadFlags() } + function applyArchivedThreadNotification(threadId: string): void { + const normalizedThreadId = threadId.trim() + if (!normalizedThreadId) return + + const wasSelectedThread = selectedThreadId.value === normalizedThreadId + const nextSelectedThreadId = wasSelectedThread + ? findAdjacentThreadId(flattenThreads(projectGroups.value), normalizedThreadId) + : selectedThreadId.value + + if (wasSelectedThread) { + setSelectedThreadId(nextSelectedThreadId) + } + + removeArchivedThreadFromLoadedLists(normalizedThreadId) + pruneThreadScopedState(flattenThreads(projectGroups.value)) + + if (wasSelectedThread && nextSelectedThreadId) { + void loadMessages(nextSelectedThreadId, { silent: true }) + } + } + function mergeThreadGroupPages(previous: UiProjectGroup[], incoming: UiProjectGroup[]): UiProjectGroup[] { if (previous.length === 0) return incoming if (incoming.length === 0) return previous From c35db60d6e201e73462056b2747d0414f969d0da Mon Sep 17 00:00:00 2001 From: jasonchou021 Date: Thu, 21 May 2026 13:46:41 +0800 Subject: [PATCH 2/2] Add context usage compaction controls Surface thread token usage directly in the composer as a circular context indicator with a hover/focus details popover. The popover includes a compact context action that calls thread/compact/start for the active thread while keeping the new-thread composer disabled for compaction. Render v2 contextCompaction items in the conversation as Codex-style divider rows, and show live compaction progress from item/started without duplicating the activity overlay. Historical thread/read payloads now normalize contextCompaction items so completed compactions remain visible after reload. Tests: - pnpm test:unit -- src/api/normalizers/v2.test.ts src/composables/useDesktopState.test.ts - pnpm run build:frontend --- src/App.vue | 11 ++ src/api/codexGateway.ts | 6 + src/api/normalizers/v2.test.ts | 17 ++ src/api/normalizers/v2.ts | 11 ++ src/components/content/ThreadComposer.vue | 145 +++++++++++++++++- src/components/content/ThreadConversation.vue | 49 +++++- src/composables/useDesktopState.test.ts | 81 ++++++++++ src/composables/useDesktopState.ts | 82 +++++++++- src/style.css | 28 ++++ 9 files changed, 426 insertions(+), 4 deletions(-) diff --git a/src/App.vue b/src/App.vue index 6e22f0739..7df2a0689 100644 --- a/src/App.vue +++ b/src/App.vue @@ -938,12 +938,14 @@ :skills="installedSkills" :thread-token-usage="selectedThreadTokenUsage" :codex-quota="codexQuota" + :context-compaction-available="false" :is-turn-in-progress="false" :is-stop-pending="false" :is-interrupting-turn="false" :send-with-enter="sendWithEnter" :in-progress-submit-mode="inProgressSendMode" :dictation-click-to-toggle="dictationClickToToggle" :dictation-auto-send="dictationAutoSend" :dictation-language="dictationLanguage" @submit="onSubmitThreadMessage" + @compact-context="onCompactContext" @update:selected-collaboration-mode="onSelectCollaborationMode" @update:selected-model="onSelectModel" @update:selected-reasoning-effort="onSelectReasoningEffort" @@ -1021,6 +1023,7 @@ :skills="installedSkills" :thread-token-usage="selectedThreadTokenUsage" :codex-quota="codexQuota" + :context-compaction-available="true" :is-turn-in-progress="isSelectedThreadInProgress" :is-stop-pending="isSelectedThreadInterruptPending" :is-interrupting-turn="isInterruptingTurn" @@ -1032,6 +1035,7 @@ @submit="onSubmitThreadMessage" @update:selected-model="onSelectModel" @update:selected-reasoning-effort="onSelectReasoningEffort" @update:selected-speed-mode="onSelectSpeedMode" + @compact-context="onCompactContext" @interrupt="onInterruptTurn" /> @@ -1391,6 +1395,7 @@ const { forkThreadById, renameThreadById, forkThreadFromTurn, + compactSelectedThread, sendMessageToSelectedThread, sendMessageToNewThread, interruptSelectedThreadTurn, @@ -3234,6 +3239,12 @@ function onSubmitThreadMessage(payload: { text: string; imageUrls: string[]; fil void sendMessageToSelectedThread(text, payload.imageUrls, payload.skills, payload.mode, payload.fileAttachments, queueInsertIndex) } +function onCompactContext(): void { + if (isHomeRoute.value) return + scheduleMobileConversationJumpToLatest() + void compactSelectedThread() +} + function onEditQueuedMessage(messageId: string): void { const queueIndex = selectedThreadQueuedMessages.value.findIndex((item) => item.id === messageId) const message = queueIndex >= 0 ? selectedThreadQueuedMessages.value[queueIndex] : undefined diff --git a/src/api/codexGateway.ts b/src/api/codexGateway.ts index bd6213fbf..24ce636a2 100644 --- a/src/api/codexGateway.ts +++ b/src/api/codexGateway.ts @@ -1538,6 +1538,12 @@ export async function archiveThread(threadId: string): Promise { await callRpc('thread/archive', { threadId }) } +export async function compactThread(threadId: string): Promise { + const normalizedThreadId = threadId.trim() + if (!normalizedThreadId) return + await callRpc('thread/compact/start', { threadId: normalizedThreadId }) +} + export async function renameThread(threadId: string, threadName: string): Promise { await callRpc('thread/name/set', { threadId, name: threadName }) } diff --git a/src/api/normalizers/v2.test.ts b/src/api/normalizers/v2.test.ts index 37a709569..5396bf4a0 100644 --- a/src/api/normalizers/v2.test.ts +++ b/src/api/normalizers/v2.test.ts @@ -106,6 +106,23 @@ Reply with </instructions> and A & B }) }) + it('renders context compaction items as system messages', () => { + const messages = normalizeThreadMessagesV2(threadReadResponseWithContent([{ + type: 'contextCompaction', + id: 'compact-item-1', + }])) + + expect(messages).toHaveLength(1) + expect(messages[0]).toMatchObject({ + id: 'compact-item-1', + role: 'system', + text: 'Context compacted', + messageType: 'contextCompaction', + turnId: 'turn-1', + turnIndex: 0, + }) + }) + it('renders failed turn errors as chat system messages', () => { const response = threadReadResponseWithContent([{ type: 'userMessage', diff --git a/src/api/normalizers/v2.ts b/src/api/normalizers/v2.ts index 0a473181a..52fd1445a 100644 --- a/src/api/normalizers/v2.ts +++ b/src/api/normalizers/v2.ts @@ -523,6 +523,17 @@ function toUiMessages(item: ThreadItem): UiMessage[] { ] } + if (item.type === 'contextCompaction') { + return [ + { + id: item.id, + role: 'system', + text: 'Context compacted', + messageType: 'contextCompaction', + }, + ] + } + return [] } diff --git a/src/components/content/ThreadComposer.vue b/src/components/content/ThreadComposer.vue index 9be5a3561..384de2f5d 100644 --- a/src/components/content/ThreadComposer.vue +++ b/src/components/content/ThreadComposer.vue @@ -301,6 +301,49 @@ class="thread-composer-actions" :class="{ 'thread-composer-actions--recording': isDictationRecording }" > +
+ + + {{ contextUsageSummaryText }} + + {{ line }} + + + +
+ @@ -444,6 +487,7 @@ const props = defineProps<{ skills?: SkillItem[] threadTokenUsage?: UiThreadTokenUsage | null codexQuota?: UiRateLimitSnapshot | null + contextCompactionAvailable?: boolean isTurnInProgress?: boolean isStopPending?: boolean isInterruptingTurn?: boolean @@ -482,6 +526,7 @@ export type ThreadComposerExposed = { const emit = defineEmits<{ submit: [payload: SubmitPayload] + 'compact-context': [] interrupt: [] 'update:selected-collaboration-mode': [mode: CollaborationModeKind] 'update:selected-model': [modelId: string] @@ -574,6 +619,7 @@ const isFileMentionOpen = ref(false) const fileMentionHighlightedIndex = ref(0) const isComposerExpanded = ref(false) const isDraftOverflowing = ref(false) +const isContextUsageTooltipDismissed = ref(false) let composerOverflowMeasurementQueued = false const draftGeneration = ref(0) let fileMentionSearchToken = 0 @@ -734,8 +780,26 @@ const quotaTooltipText = computed(() => buildQuotaTooltipText(props.codexQuota ? const contextUsageView = computed(() => buildContextUsageView(props.threadTokenUsage ?? null)) const contextUsageSummaryText = computed(() => contextUsageView.value?.summaryText ?? '') const contextUsageTooltipText = computed(() => contextUsageView.value?.tooltipText ?? '') +const contextUsageTooltipLines = computed(() => contextUsageTooltipText.value.split('\n').filter(Boolean)) const contextUsageRemainingPercent = computed(() => contextUsageView.value?.percentRemaining ?? 0) +const contextUsageUsedPercent = computed(() => Math.max(0, Math.min(100, 100 - contextUsageRemainingPercent.value))) const contextUsageTone = computed(() => contextUsageView.value?.tone ?? 'healthy') +const contextUsageRingStyle = computed(() => ({ + '--context-usage-used': `${contextUsageUsedPercent.value}%`, +})) +const canCompactContext = computed(() => + props.contextCompactionAvailable !== false && + Boolean(props.activeThreadId) && + !isInteractionDisabled.value && + props.isTurnInProgress !== true, +) +const contextCompactButtonText = computed(() => + props.contextCompactionAvailable === false + ? 'Open thread to compact' + : props.isTurnInProgress + ? 'Compact after current turn' + : 'Compact context', +) function formatPlanType(planType: string | null | undefined): string { if (!planType || planType === 'unknown') return '' @@ -940,9 +1004,9 @@ function buildContextUsageView( : 'healthy' return { - summaryText: `${percentRemaining}% · ${formatCompactTokenCount(tokensInContext)} / ${formatCompactTokenCount(contextWindow)}`, + summaryText: `${percentUsed}% used · ${formatCompactTokenCount(tokensInContext)} / ${formatCompactTokenCount(contextWindow)}`, tooltipText: [ - `Context window: ${percentRemaining}% left (${percentUsed}% used)`, + `Context window: ${percentUsed}% used (${percentRemaining}% left)`, `In context: ${tokensInContext.toLocaleString()} / ${contextWindow.toLocaleString()} tokens`, `Last turn: ${formatBreakdownSummary(usage.last)}`, `Session total: ${formatBreakdownSummary(usage.total)}`, @@ -975,6 +1039,17 @@ function onSubmit(mode: 'steer' | 'queue' = 'steer'): void { nextTick(() => inputRef.value?.focus()) } +function resetContextUsageTooltipDismissal(): void { + isContextUsageTooltipDismissed.value = false +} + +function onCompactContextClick(): void { + if (!canCompactContext.value) return + isContextUsageTooltipDismissed.value = true + emit('compact-context') + nextTick(() => inputRef.value?.focus()) +} + function setActiveInProgressMode(mode: 'steer' | 'queue'): void { activeInProgressMode.value = mode } @@ -2021,6 +2096,72 @@ watch( background: var(--context-usage-accent); } +.thread-composer-context-ring { + --context-usage-accent: rgb(34 197 94); + --context-usage-track: rgb(228 228 231); + --context-usage-used: 0%; + @apply relative inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full outline-none transition; +} + +.thread-composer-context-ring.is-warning { + --context-usage-accent: rgb(245 158 11); +} + +.thread-composer-context-ring.is-danger { + --context-usage-accent: rgb(239 68 68); +} + +.thread-composer-context-ring:focus-visible { + @apply ring-2 ring-emerald-500 ring-offset-2 ring-offset-white; +} + +.thread-composer-context-ring-track { + @apply flex h-9 w-9 items-center justify-center rounded-full; + background: conic-gradient( + var(--context-usage-accent) var(--context-usage-used), + var(--context-usage-track) 0 + ); +} + +.thread-composer-context-ring-core { + @apply flex h-7 w-7 items-center justify-center rounded-full bg-white text-[10px] font-semibold leading-none tabular-nums text-zinc-700; +} + +.thread-composer-context-tooltip { + @apply pointer-events-auto absolute bottom-full right-0 z-40 mb-2 hidden w-72 rounded-lg border border-zinc-200 bg-white p-3 text-left text-[11px] leading-4 text-zinc-600 shadow-xl; +} + +.thread-composer-context-ring:hover .thread-composer-context-tooltip, +.thread-composer-context-ring:focus-visible .thread-composer-context-tooltip, +.thread-composer-context-ring:focus-within .thread-composer-context-tooltip { + @apply block; +} + +.thread-composer-context-ring.is-tooltip-dismissed .thread-composer-context-tooltip { + @apply hidden; +} + +.thread-composer-context-tooltip::before { + @apply absolute left-0 top-full h-3 w-full content-['']; +} + +.thread-composer-context-tooltip::after { + @apply absolute right-3 top-full h-2 w-2 rotate-45 border-b border-r border-zinc-200 bg-white content-['']; + transform: translateY(-50%) rotate(45deg); +} + +.thread-composer-context-tooltip-title { + @apply mb-1 block font-semibold text-zinc-900; +} + +.thread-composer-context-tooltip-line { + @apply block; +} + +.thread-composer-context-compact-button { + @apply mt-2 inline-flex w-full items-center justify-center rounded-md border border-zinc-200 bg-zinc-100 px-2.5 py-1.5 text-xs font-semibold text-zinc-700 transition hover:bg-zinc-200 hover:text-zinc-900 disabled:cursor-not-allowed disabled:bg-zinc-100 disabled:text-zinc-400; +} + .thread-composer-input-wrap { @apply relative; } diff --git a/src/components/content/ThreadConversation.vue b/src/components/content/ThreadConversation.vue index 1d7febf28..bc22ee0f8 100644 --- a/src/components/content/ThreadConversation.vue +++ b/src/components/content/ThreadConversation.vue @@ -262,7 +262,16 @@ -
+
+ {{ message.text }} +
+ +
Sent via automation {{ message.automationDisplayName }} @@ -1021,6 +1030,10 @@ function isPlanMessage(message: UiMessage): boolean { return message.messageType === 'plan' || message.messageType === 'plan.live' } +function isContextCompactionMessage(message: UiMessage): boolean { + return message.messageType === 'contextCompaction' || message.messageType === 'contextCompaction.live' +} + function isTurnErrorMessage(message: UiMessage): boolean { return message.messageType === 'turnError' } @@ -4589,6 +4602,33 @@ onBeforeUnmount(() => { @apply flex flex-col w-full min-w-0; } +.context-compaction-separator { + @apply relative my-2 flex w-full max-w-full items-center justify-center text-xs font-medium text-zinc-500 dark:text-zinc-400; + background: + linear-gradient( + to bottom, + transparent calc(50% - 0.5px), + rgb(228 228 231) calc(50% - 0.5px), + rgb(228 228 231) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} + +.context-compaction-text { + @apply relative shrink-0 whitespace-nowrap bg-white px-3 dark:bg-zinc-950; +} + +:global(.dark) .context-compaction-separator { + background: + linear-gradient( + to bottom, + transparent calc(50% - 0.5px), + rgb(63 63 70) calc(50% - 0.5px), + rgb(63 63 70) calc(50% + 0.5px), + transparent calc(50% + 0.5px) + ); +} + .request-card { @apply w-full max-w-[min(var(--chat-column-max,45rem),100%)] rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 flex flex-col gap-2; } @@ -5184,6 +5224,13 @@ onBeforeUnmount(() => { @apply w-full max-w-full; } +.conversation-item[data-message-type='contextCompaction'] .message-stack, +.conversation-item[data-message-type='contextCompaction'] .message-body, +.conversation-item[data-message-type='contextCompaction.live'] .message-stack, +.conversation-item[data-message-type='contextCompaction.live'] .message-body { + @apply w-full max-w-full; +} + .worked-separator-wrap { @apply w-full flex flex-col gap-0; } diff --git a/src/composables/useDesktopState.test.ts b/src/composables/useDesktopState.test.ts index 0b372cc9d..6e816547c 100644 --- a/src/composables/useDesktopState.test.ts +++ b/src/composables/useDesktopState.test.ts @@ -13,6 +13,7 @@ import type { WorkspaceRootsState } from '../api/codexGateway' const gatewayMocks = vi.hoisted(() => ({ archiveThread: vi.fn(), + compactThread: vi.fn(), forkThread: vi.fn(), getAccountRateLimits: vi.fn(), getAvailableCollaborationModes: vi.fn(), @@ -676,6 +677,86 @@ describe('thread archive notifications', () => { }) }) +describe('context compaction notifications', () => { + it('starts manual compaction for the selected idle thread', async () => { + installTestWindow() + gatewayMocks.compactThread.mockResolvedValue(undefined) + + const state = useDesktopState() + state.primeSelectedThread('thread-compact') + + await state.compactSelectedThread() + + expect(gatewayMocks.compactThread).toHaveBeenCalledWith('thread-compact') + expect(state.messages.value.at(-1)).toMatchObject({ + id: 'context-compaction:pending:thread-compact', + role: 'system', + text: 'Compacting context…', + messageType: 'contextCompaction.live', + }) + expect(state.selectedLiveOverlay.value).toBe(null) + }) + + it('shows live context compaction progress and completion messages', async () => { + installTestWindow() + let notificationHandler: ((notification: { method: string; params?: unknown }) => void) | undefined + gatewayMocks.subscribeCodexNotifications.mockImplementation((handler) => { + notificationHandler = handler as typeof notificationHandler + return vi.fn() + }) + gatewayMocks.getPendingServerRequests.mockResolvedValue([]) + gatewayMocks.getThreadDetail.mockResolvedValue({ + messages: [], + inProgress: true, + activeTurnId: 'turn-1', + turnIndexByTurnId: { 'turn-1': 0 }, + hasMoreOlder: false, + }) + + const state = useDesktopState() + state.primeSelectedThread('thread-compact') + await state.loadMessages('thread-compact') + state.startPolling() + + notificationHandler?.({ + method: 'item/started', + params: { + threadId: 'thread-compact', + turnId: 'turn-1', + item: { id: 'compact-item-1', type: 'contextCompaction' }, + }, + }) + + expect(state.messages.value.at(-1)).toMatchObject({ + id: 'compact-item-1', + role: 'system', + text: 'Compacting context…', + messageType: 'contextCompaction.live', + turnId: 'turn-1', + turnIndex: 0, + }) + expect(state.selectedLiveOverlay.value).toBe(null) + + notificationHandler?.({ + method: 'item/completed', + params: { + threadId: 'thread-compact', + turnId: 'turn-1', + item: { id: 'compact-item-1', type: 'contextCompaction' }, + }, + }) + + expect(state.messages.value.at(-1)).toMatchObject({ + id: 'compact-item-1', + role: 'system', + text: 'Context compacted', + messageType: 'contextCompaction', + turnId: 'turn-1', + turnIndex: 0, + }) + }) +}) + describe('live error overlay', () => { it('shows the default thinking overlay while a selected thread is in progress without activity events', async () => { installTestWindow() diff --git a/src/composables/useDesktopState.ts b/src/composables/useDesktopState.ts index eff9a8599..1a4dc97fa 100644 --- a/src/composables/useDesktopState.ts +++ b/src/composables/useDesktopState.ts @@ -2,6 +2,7 @@ import { computed, ref } from 'vue' import { archiveThread, + compactThread, forkThread, getAvailableCollaborationModes, getAccountRateLimits, @@ -1573,6 +1574,8 @@ export function useDesktopState() { ? (liveReasoningTextByThreadId.value[threadId] ?? '').trim() : '' const liveErrorText = (turnErrorByThreadId.value[threadId]?.message ?? '').trim() + const hasLiveContextCompaction = (liveAgentMessagesByThreadId.value[threadId] ?? []) + .some((message) => message.messageType === 'contextCompaction.live') let latestPersistedTurnErrorText = '' if (!isInProgress && liveErrorText) { const persistedMessages = persistedMessagesByThreadId.value[threadId] ?? [] @@ -1588,6 +1591,7 @@ export function useDesktopState() { ? '' : liveErrorText + if (hasLiveContextCompaction && !reasoningText && !errorText) return null if (!isInProgress && !activity && !reasoningText && !errorText) return null return { activityLabel: activity?.label || 'Thinking', @@ -3225,6 +3229,15 @@ export function useDesktopState() { }, } } + if (itemType === 'contextcompaction') { + return { + threadId, + activity: { + label: 'Compacting context', + details: [], + }, + } + } } if (notification.method === 'item/commandExecution/outputDelta') { @@ -3669,6 +3682,32 @@ export function useDesktopState() { } } + function readContextCompactionNotification(notification: RpcNotification): UiMessage | null { + if (notification.method !== 'item/started' && notification.method !== 'item/completed') return null + + const params = asRecord(notification.params) + const threadId = readString(params?.threadId) + const turnId = readString(params?.turnId) + const turnIndex = threadId && turnId + ? turnIndexByTurnIdByThreadId.value[threadId]?.[turnId] + : undefined + + const item = asRecord(params?.item) + const itemType = readString(item?.type).toLowerCase() + if (itemType !== 'contextcompaction') return null + + const id = readString(item?.id) || readString(params?.itemId) || `context-compaction:${turnId || Date.now()}` + const isCompleted = notification.method === 'item/completed' + return { + id, + role: 'system', + text: isCompleted ? 'Context compacted' : 'Compacting context…', + messageType: isCompleted ? 'contextCompaction' : 'contextCompaction.live', + turnId: turnId || undefined, + turnIndex: typeof turnIndex === 'number' ? turnIndex : undefined, + } + } + function upsertLiveCommand(threadId: string, msg: UiMessage): void { const previous = liveCommandsByThreadId.value[threadId] ?? [] const next = upsertMessage(previous, msg) @@ -3975,6 +4014,17 @@ export function useDesktopState() { upsertLiveFileChangeMessage(notificationThreadId, completedFileChange) } + const contextCompactionMessage = readContextCompactionNotification(notification) + if (contextCompactionMessage && notificationThreadId) { + const pendingId = `context-compaction:pending:${notificationThreadId}` + const previousLiveAgent = liveAgentMessagesByThreadId.value[notificationThreadId] ?? [] + const withoutPending = previousLiveAgent.filter((message) => message.id !== pendingId) + setLiveAgentMessagesForThread( + notificationThreadId, + upsertMessage(withoutPending, contextCompactionMessage), + ) + } + if (isAgentContentEvent(notification)) { activeReasoningItemId = '' clearLiveReasoningForThread(notificationThreadId) @@ -4464,7 +4514,10 @@ export function useDesktopState() { const previousLiveAgent = liveAgentMessagesByThreadId.value[threadId] ?? [] if (inProgress) { - const nextLiveAgent = removeRedundantLiveAgentMessages(previousLiveAgent, nextMessages) + const nextLiveAgent = removePersistedLiveMessages( + removeRedundantLiveAgentMessages(previousLiveAgent, nextMessages), + nextMessages, + ) setLiveAgentMessagesForThread(threadId, nextLiveAgent) } else { clearLiveAgentMessagesForThread(threadId) @@ -4866,6 +4919,32 @@ export function useDesktopState() { }) } + async function compactSelectedThread(): Promise { + const threadId = selectedThreadId.value + if (!threadId || inProgressById.value[threadId] === true) return + + error.value = '' + try { + setThreadInProgress(threadId, true) + upsertLiveAgentMessage(threadId, { + id: `context-compaction:pending:${threadId}`, + role: 'system', + text: 'Compacting context…', + messageType: 'contextCompaction.live', + }) + await compactThread(threadId) + } catch (unknownError) { + setThreadInProgress(threadId, false) + const previousLiveAgent = liveAgentMessagesByThreadId.value[threadId] ?? [] + setLiveAgentMessagesForThread( + threadId, + previousLiveAgent.filter((message) => message.id !== `context-compaction:pending:${threadId}`), + ) + setTurnActivityForThread(threadId, null) + error.value = unknownError instanceof Error ? unknownError.message : 'Failed to compact context' + } + } + async function sendMessageToSelectedThread( text: string, imageUrls: string[] = [], @@ -5736,6 +5815,7 @@ export function useDesktopState() { forkThreadFromTurn, rollbackSelectedThread, + compactSelectedThread, sendMessageToSelectedThread, sendMessageToNewThread, interruptSelectedThreadTurn, diff --git a/src/style.css b/src/style.css index 9bdc7d083..a0434f45d 100644 --- a/src/style.css +++ b/src/style.css @@ -299,6 +299,34 @@ @apply bg-zinc-200 text-zinc-900 hover:bg-white; } +:root.dark .thread-composer-context-ring { + --context-usage-track: rgb(63 63 70); +} + +:root.dark .thread-composer-context-ring:focus-visible { + @apply ring-offset-zinc-800; +} + +:root.dark .thread-composer-context-ring-core { + @apply bg-zinc-800 text-zinc-200; +} + +:root.dark .thread-composer-context-tooltip { + @apply border-zinc-700 bg-zinc-900 text-zinc-300 shadow-zinc-950/60; +} + +:root.dark .thread-composer-context-tooltip::after { + @apply border-zinc-700 bg-zinc-900; +} + +:root.dark .thread-composer-context-tooltip-title { + @apply text-zinc-100; +} + +:root.dark .thread-composer-context-compact-button { + @apply border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 hover:text-white disabled:bg-zinc-800 disabled:text-zinc-500; +} + :root.dark .composer-runtime-error { @apply border-rose-800/70 bg-rose-950/70 text-rose-100 shadow-none; }