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
67 changes: 56 additions & 11 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
type SlashActionId,
} from '../utils/slashActionSelection';
import { parseReloadCommand, supportsLocalReloadContext } from '../utils/reloadCommand';
import { parseCompactCommand } from '../utils/compactCommand';
import { reviewPromptCommandShell } from '../utils/promptCommandShellReview';
import { notificationService } from '@/shared/notification-system';
import { useI18n } from '@/infrastructure/i18n';
Expand Down Expand Up @@ -3798,23 +3799,31 @@ export const ChatInput: React.FC<ChatInputProps> = ({
}

const message = inputState.value.trim();
if (!/^\/compact\s*$/i.test(message)) {
const compactRequest = parseCompactCommand(message);
if (!compactRequest) {
notificationService.warning(
t('chatInput.compactUsage')
);
return;
}

// Resolve the follow-up while its placeholders are still staged: large
// pastes and composer reference tokens only expand from the current draft.
const followUp = compactRequest.followUp
? expandComposerSpecialTokens(compactRequest.followUp)
: '';

dispatchInput({ type: 'CLEAR_VALUE' });
setQueuedInput(null);
setSlashCommandState({ isActive: false, kind: 'all', query: '', selectedIndex: 0 });

const sessionId = effectiveTargetSessionId;
try {
await FlowChatManager.getInstance().compactSession(effectiveTargetSessionId);
await FlowChatManager.getInstance().compactSession(sessionId);
} catch (error) {
log.error('Failed to trigger /compact', {
error,
sessionId: effectiveTargetSessionId,
sessionId,
});
dispatchInput({ type: 'SET_VALUE', payload: message });
notificationService.error(
Expand All @@ -3824,13 +3833,56 @@ export const ChatInput: React.FC<ChatInputProps> = ({
duration: 5000,
}
);
return;
}

if (!followUp) {
return;
}

/*
* A manual `/compact <message>` submits that message only once the
* compaction has landed, so the turn it starts reads the compressed context
* instead of racing the maintenance turn. Automatic compaction never comes
* through the composer, so it keeps submitting nothing.
*
* Never steer: the compaction's terminal bookkeeping can still be settling
* here, and the follow-up belongs to the turn after it. Queuing lets the
* host or the local drain send it as soon as the session is idle again. The
* composer keeps its attachments too; this send owns the follow-up text, and
* anything the user attached while compacting belongs to their next message.
*/
try {
await sendMessage(followUp, {
clearContextsOnSuccess: false,
sendImmediately: false,
});
} catch (error) {
log.error('Failed to submit the message sent with /compact', {
error,
sessionId,
});
// The follow-up never became a turn, so hand it back as a draft unless
// the user has already started writing something else.
if (!inputValueRef.current.trim()) {
dispatchInput({ type: 'SET_VALUE', payload: followUp });
}
notificationService.error(
error instanceof Error ? error.message : t('error.unknown'),
{
title: t('chatInput.compactFollowUpFailed'),
duration: 5000,
}
);
}
}, [
derivedState?.isProcessing,
dispatchInput,
effectiveTargetSession,
effectiveTargetSessionId,
expandComposerSpecialTokens,
inputState.value,
sendMessage,
setQueuedInput,
t,
]);
Expand Down Expand Up @@ -5422,7 +5474,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return;
}

if (promptSlashCommandsEnabled && caps.ops.has('compact') && /^\/compact\s*$/i.test(message)) {
if (promptSlashCommandsEnabled && caps.ops.has('compact') && parseCompactCommand(message)) {
await submitCompactFromInput();
return;
}
Expand Down Expand Up @@ -5456,13 +5508,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return;
}

if (promptSlashCommandsEnabled && caps.ops.has('compact') && isSlashCommand(message, '/compact')) {
notificationService.warning(
t('chatInput.compactUsage')
);
return;
}

if (promptSlashCommandsEnabled && caps.ops.has('usage') && isSlashCommand(message, '/usage')) {
notificationService.warning(
t('chatInput.usageCommandUsage')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { describe, expect, it } from 'vitest';

/**
* Composer wiring for `/compact <message>`.
*
* A mounted composer cannot be driven through a real compaction in a unit
* test, so these assertions lock the two properties that make the feature
* work: the command admits a follow-up, and the follow-up is only submitted
* after the compaction finished. Runtime behaviour still needs a manual pass
* against a desktop build.
*/
function readChatInput(): string {
return readFileSync(
fileURLToPath(new URL('./ChatInput.tsx', import.meta.url)),
'utf8',
).replace(/\r\n/g, '\n');
}

function readSubmitCompactBody(source: string): string {
const start = source.indexOf('const submitCompactFromInput = useCallback(');
expect(start).toBeGreaterThan(-1);
const end = source.indexOf('\n ]);', start);
expect(end).toBeGreaterThan(start);
return source.slice(start, end);
}

describe('/compact follow-up submission', () => {
it('routes every /compact submission through the parser that keeps a follow-up', () => {
const source = readChatInput();

expect(source).toContain("caps.ops.has('compact') && parseCompactCommand(message)");
// The old all-or-nothing gates rejected or warned on any extra argument.
expect(source).not.toContain('isSlashCommand(message, \'/compact\')');
});

it('submits the follow-up only after the compaction has settled', () => {
const body = readSubmitCompactBody(readChatInput());
const compactionAt = body.indexOf('compactSession(sessionId)');
const followUpAt = body.indexOf('sendMessage(followUp');

expect(compactionAt).toBeGreaterThan(-1);
expect(followUpAt).toBeGreaterThan(compactionAt);
// Queueing instead of steering keeps the message out of the compaction turn
// while its terminal bookkeeping is still settling.
expect(body).toContain('sendImmediately: false');
});

it('returns the dropped follow-up as a draft without overwriting newer input', () => {
const body = readSubmitCompactBody(readChatInput());

expect(body).toContain('if (!inputValueRef.current.trim()) {');
expect(body).toContain("dispatchInput({ type: 'SET_VALUE', payload: followUp })");
expect(body).toContain("title: t('chatInput.compactFollowUpFailed')");
});
});
28 changes: 28 additions & 0 deletions src/web-ui/src/flow_chat/utils/compactCommand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { parseCompactCommand } from './compactCommand';

describe('parseCompactCommand', () => {
it('accepts a bare command', () => {
expect(parseCompactCommand('/compact')).toEqual({ followUp: '' });
expect(parseCompactCommand(' /COMPACT ')).toEqual({ followUp: '' });
});

it('keeps everything after the command as the follow-up message', () => {
expect(parseCompactCommand('/compact continue the fix'))
.toEqual({ followUp: 'continue the fix' });
expect(parseCompactCommand('/compact first line\nsecond line '))
.toEqual({ followUp: 'first line\nsecond line' });
});

it('preserves the original casing and markdown of the follow-up', () => {
expect(parseCompactCommand('/compact Review `Foo.tsx` next'))
.toEqual({ followUp: 'Review `Foo.tsx` next' });
});

it('leaves unrelated prompts on the ordinary message path', () => {
expect(parseCompactCommand('/compacted')).toBeNull();
expect(parseCompactCommand('/compact-run')).toBeNull();
expect(parseCompactCommand('please /compact')).toBeNull();
expect(parseCompactCommand('')).toBeNull();
});
});
29 changes: 29 additions & 0 deletions src/web-ui/src/flow_chat/utils/compactCommand.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { matchesSlashCommand, stripSlashCommand } from './slashCommand';

/**
* A manual `/compact` submission.
*
* The command owns the composer text it was submitted with, so anything the
* user typed after `/compact` is the message to submit once the compaction
* finishes. Auto-triggered compaction never goes through the composer and
* therefore never carries a follow-up.
*/
export interface CompactCommandRequest {
/** Message to submit after the compaction completes; empty when there is none. */
followUp: string;
}

/**
* Parse a composer submission as `/compact [followUp]`.
*
* Returns `null` for any other prompt, so the caller can fall through to the
* ordinary message path.
*/
export function parseCompactCommand(input: string): CompactCommandRequest | null {
const text = input.trim();
if (matchesSlashCommand(text) !== '/compact') {
return null;
}

return { followUp: stripSlashCommand(text, '/compact').trim() };
}
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/en-US/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -1101,8 +1101,9 @@
"compactAction": "Compact session",
"compactNoSession": "No active session for /compact",
"compactBusy": "Wait until the session is idle before using /compact.",
"compactUsage": "Use /compact without extra arguments.",
"compactUsage": "Use /compact, optionally followed by the message to submit after compacting.",
"compactFailed": "Session compaction failed",
"compactFollowUpFailed": "The message after /compact was not sent",
"usageAction": "Session usage",
"usageNoSession": "No active session for /usage",
"usageCommandUsage": "Use /usage without extra arguments.",
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/zh-CN/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -1101,8 +1101,9 @@
"compactAction": "压缩会话",
"compactNoSession": "当前没有可用于 /compact 的会话",
"compactBusy": "请等待当前会话空闲后再使用 /compact。",
"compactUsage": "请直接使用 /compact,不要附加额外参数。",
"compactUsage": "使用 /compact,可在其后附加压缩完成后要发送的消息。",
"compactFailed": "会话压缩失败",
"compactFollowUpFailed": "压缩后附加的消息未能发送",
"goalAction": "会话目标",
"goalAchieved": "会话目标已达成",
"goalVerifyFailed": "目标验证失败,请检查模型配置后重试。",
Expand Down
3 changes: 2 additions & 1 deletion src/web-ui/src/locales/zh-TW/flow-chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -1101,8 +1101,9 @@
"compactAction": "壓縮會話",
"compactNoSession": "目前沒有可用於 /compact 的會話",
"compactBusy": "請等待目前會話空閒後再使用 /compact。",
"compactUsage": "請直接使用 /compact,不要附加額外參數。",
"compactUsage": "使用 /compact,可在其後附加壓縮完成後要傳送的訊息。",
"compactFailed": "會話壓縮失敗",
"compactFollowUpFailed": "壓縮後附加的訊息未能傳送",
"goalAction": "會話目標",
"goalAchieved": "會話目標已達成",
"goalVerifyFailed": "目標驗證失敗,請檢查模型設定後重試。",
Expand Down
Loading