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
11 changes: 11 additions & 0 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand All @@ -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" />
</div>
</template>
Expand Down Expand Up @@ -1391,6 +1395,7 @@ const {
forkThreadById,
renameThreadById,
forkThreadFromTurn,
compactSelectedThread,
sendMessageToSelectedThread,
sendMessageToNewThread,
interruptSelectedThreadTurn,
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/api/codexGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,12 @@ export async function archiveThread(threadId: string): Promise<void> {
await callRpc('thread/archive', { threadId })
}

export async function compactThread(threadId: string): Promise<void> {
const normalizedThreadId = threadId.trim()
if (!normalizedThreadId) return
await callRpc('thread/compact/start', { threadId: normalizedThreadId })
}

export async function renameThread(threadId: string, threadName: string): Promise<void> {
await callRpc('thread/name/set', { threadId, name: threadName })
}
Expand Down
17 changes: 17 additions & 0 deletions src/api/normalizers/v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ Reply with &lt;/instructions&gt; and A &amp; 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',
Expand Down
11 changes: 11 additions & 0 deletions src/api/normalizers/v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
}

Expand Down
145 changes: 143 additions & 2 deletions src/components/content/ThreadComposer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,49 @@
class="thread-composer-actions"
:class="{ 'thread-composer-actions--recording': isDictationRecording }"
>
<div
v-if="contextUsageView"
class="thread-composer-context-ring"
:class="[
`is-${contextUsageTone}`,
{ 'is-tooltip-dismissed': isContextUsageTooltipDismissed },
]"
:style="contextUsageRingStyle"
tabindex="0"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="contextUsageUsedPercent"
:aria-label="contextUsageTooltipText"
@mouseenter="resetContextUsageTooltipDismissal"
@mouseleave="resetContextUsageTooltipDismissal"
@focusin="resetContextUsageTooltipDismissal"
>
<span class="thread-composer-context-ring-track" aria-hidden="true">
<span class="thread-composer-context-ring-core">
{{ contextUsageUsedPercent }}
</span>
</span>
<span class="thread-composer-context-tooltip" role="tooltip">
<span class="thread-composer-context-tooltip-title">{{ contextUsageSummaryText }}</span>
<span
v-for="line in contextUsageTooltipLines"
:key="line"
class="thread-composer-context-tooltip-line"
>
{{ line }}
</span>
<button
type="button"
class="thread-composer-context-compact-button"
:disabled="!canCompactContext"
@click.stop="onCompactContextClick"
>
{{ contextCompactButtonText }}
</button>
</span>
</div>

<div v-if="dictationState === 'recording'" class="thread-composer-dictation-waveform-wrap" aria-hidden="true">
<canvas ref="dictationWaveformCanvasRef" class="thread-composer-dictation-waveform" />
</div>
Expand Down Expand Up @@ -444,6 +487,7 @@ const props = defineProps<{
skills?: SkillItem[]
threadTokenUsage?: UiThreadTokenUsage | null
codexQuota?: UiRateLimitSnapshot | null
contextCompactionAvailable?: boolean
isTurnInProgress?: boolean
isStopPending?: boolean
isInterruptingTurn?: boolean
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
)
Comment on lines +796 to +802

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Localize newly added context-compaction copy.

Line 796 and Line 1007 introduce new user-facing text as hardcoded English, while this component otherwise uses t(...). This will bypass localization.

💡 Suggested fix
const contextCompactButtonText = computed(() =>
  props.contextCompactionAvailable === false
-    ? 'Open thread to compact'
+    ? t('Open thread to compact')
    : props.isTurnInProgress
-      ? 'Compact after current turn'
-      : 'Compact context',
+      ? t('Compact after current turn')
+      : t('Compact context'),
)

Also applies to: 1007-1013

🤖 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/components/content/ThreadComposer.vue` around lines 796 - 802, The new
user-facing strings in the computed contextCompactButtonText (and the similar
strings added around the second occurrence) are hardcoded English; replace each
literal ('Open thread to compact', 'Compact after current turn', 'Compact
context', and the other added literals at the second location) with calls to the
i18n translator (e.g. t('thread.openToCompact'), t('thread.compactAfterTurn'),
t('thread.compactContext') or equivalent keys) so the component uses t(...) like
the rest of the file; update or add the corresponding translation keys in your
locale files to provide the English text and translations.


function formatPlanType(planType: string | null | undefined): string {
if (!planType || planType === 'unknown') return ''
Expand Down Expand Up @@ -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)}`,
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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;
}
Expand Down
49 changes: 48 additions & 1 deletion src/components/content/ThreadConversation.vue
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,16 @@
</a>
</div>

<article v-if="message.text.length > 0" class="message-card" :data-role="message.role">
<div
v-if="isContextCompactionMessage(message)"
class="context-compaction-separator"
:data-state="message.messageType === 'contextCompaction.live' ? 'live' : 'done'"
aria-live="polite"
>
<span class="context-compaction-text">{{ message.text }}</span>
</div>

<article v-else-if="message.text.length > 0" class="message-card" :data-role="message.role">
<div v-if="message.isAutomationRun" class="automation-message-label">
<span>Sent via automation</span>
<code v-if="message.automationDisplayName">{{ message.automationDisplayName }}</code>
Expand Down Expand Up @@ -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'
}
Expand Down Expand Up @@ -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)
);
}
Comment on lines +4621 to +4630

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.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Move dark-theme override for context compaction separator to src/style.css.

Line 4621 adds a decisive dark-mode surface rule in a component-scoped stylesheet. For this shared conversation surface, keep dark-theme override wiring in src/style.css and leave the component with base styles only.

Proposed change
-: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)
-    );
-}
/* add in src/style.css */
.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)
    );
}

As per coding guidelines: “src/style.css: For shared route surfaces and large feature UIs, put decisive dark-theme overrides in src/style.css instead of relying only on component-scoped :global(:root.dark) blocks”.

🤖 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/components/content/ThreadConversation.vue` around lines 4621 - 4630,
Remove the dark-mode override from the component stylesheet in
ThreadConversation.vue and instead add the dark-theme rule for the shared
selector ".context-compaction-separator" into src/style.css; specifically,
delete the :global(.dark) .context-compaction-separator block from
ThreadConversation.vue and create a top-level rule in src/style.css using the
same selector ".dark .context-compaction-separator" with the identical
linear-gradient background so the decisive dark override lives in the shared
stylesheet rather than the component.


.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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading