From e283341c55b525526c8f55e83f4f22c8e73d8bff Mon Sep 17 00:00:00 2001 From: Ryker_Feng <90562015+18062706139fcz@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:28:30 +0800 Subject: [PATCH] feat(workspace): validate /goal objective length in composer (#4337) The composer sent `/goal ` of any length, so an objective past the backend limit (`MAX_GOAL_OBJECTIVE_CHARS = 4000`) only failed after a round-trip with a raw HTTP 422. Mirror the limit client-side: reject an over-length objective before the PUT with a friendly toast, and reject the submit so PromptInput keeps the user's text for editing instead of clearing it. Add a live footer counter that surfaces near the limit and turns destructive when exceeded. Length is measured with the same whitespace normalization the backend applies, so the counts match exactly. --- .../components/workspace/input-box-helpers.ts | 46 +++++++++ .../src/components/workspace/input-box.tsx | 39 ++++++++ frontend/src/core/i18n/locales/en-US.ts | 2 + frontend/src/core/i18n/locales/types.ts | 2 + frontend/src/core/i18n/locales/zh-CN.ts | 2 + .../workspace/input-box-helpers.test.ts | 96 +++++++++++++++++++ 6 files changed, 187 insertions(+) diff --git a/frontend/src/components/workspace/input-box-helpers.ts b/frontend/src/components/workspace/input-box-helpers.ts index 0dbadbb11..90f924b57 100644 --- a/frontend/src/components/workspace/input-box-helpers.ts +++ b/frontend/src/components/workspace/input-box-helpers.ts @@ -6,6 +6,52 @@ export { export const MAX_SKILL_SUGGESTIONS = 6; +// Mirror of the backend raw request limit (`ThreadGoalRequest.objective` +// max_length and `MAX_GOAL_OBJECTIVE_CHARS` in backend goal.py). Kept here so +// the composer can reject an over-length `/goal ` before issuing the +// PUT request and show a friendly error instead of surfacing a raw HTTP 422. +export const MAX_GOAL_OBJECTIVE_CHARS = 4000; + +export function isGoalObjectiveTooLong(objective: string): boolean { + return objective.length > MAX_GOAL_OBJECTIVE_CHARS; +} + +// The live composer counter stays hidden until the objective approaches the +// limit, so it only surfaces when the user is at risk of being rejected rather +// than adding permanent noise to the footer. +export const GOAL_OBJECTIVE_COUNTER_VISIBLE_AT = Math.floor( + MAX_GOAL_OBJECTIVE_CHARS * 0.9, +); + +export type GoalObjectiveCounter = { + length: number; + max: number; + overLimit: boolean; +}; + +// Derive the live counter for the composer footer from the same parsed +// objective string sent to the API. Returns null unless the input is a +// `/goal ` set command whose raw length has reached the visibility +// threshold, so the counter only appears for the case the limit actually +// applies to. +export function getGoalObjectiveCounter( + value: string, +): GoalObjectiveCounter | null { + const command = parseGoalCommand(value); + if (command?.kind !== "set") { + return null; + } + const length = command.objective.length; + if (length < GOAL_OBJECTIVE_COUNTER_VISIBLE_AT) { + return null; + } + return { + length, + max: MAX_GOAL_OBJECTIVE_CHARS, + overLimit: length > MAX_GOAL_OBJECTIVE_CHARS, + }; +} + export type SlashSuggestion = { name: string; description: string; diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index b6ec06f5d..097137211 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -140,12 +140,15 @@ import { createGoalRequestState, findSuggestionTemplatePlaceholder, finishGoalRequest, + getGoalObjectiveCounter, getInputSubmitAction, getLeadingSlashSkillQuery, getMatchingSkillSuggestions, type GoalCommand, isAbortError, isCurrentGoalRequest, + isGoalObjectiveTooLong, + MAX_GOAL_OBJECTIVE_CHARS, readGoalResponseError, type SlashSuggestion, } from "./input-box-helpers"; @@ -1145,6 +1148,19 @@ export function InputBox({ status, }); if (submitAction.kind === "goal") { + if ( + submitAction.command.kind === "set" && + isGoalObjectiveTooLong(submitAction.command.objective) + ) { + toast.error( + t.inputBox.goalTooLong.replace("{max}", () => + String(MAX_GOAL_OBJECTIVE_CHARS), + ), + ); + // Reject so the composer keeps the user's text for editing instead of + // clearing it (PromptInput only preserves input on a rejected submit). + return Promise.reject(new Error("goal-too-long")); + } promptHistoryIndexRef.current = null; promptHistoryDraftRef.current = ""; setFollowups([]); @@ -1184,6 +1200,7 @@ export function InputBox({ selectedSlashSkill, status, submitThreadMessage, + t.inputBox.goalTooLong, t.inputBox.pleaseWaitStreaming, ], ); @@ -1243,6 +1260,10 @@ export function InputBox({ () => getLeadingSlashSkillQuery(textInput.value ?? ""), [textInput.value], ); + const goalObjectiveCounter = useMemo( + () => getGoalObjectiveCounter(textInput.value ?? ""), + [textInput.value], + ); const skillSuggestions = useMemo( () => slashSkillQuery === null @@ -2566,6 +2587,24 @@ export function InputBox({ )} + {goalObjectiveCounter && ( + + String(goalObjectiveCounter.length), + ) + .replace("{max}", () => String(goalObjectiveCounter.max))} + className={cn( + "shrink-0 text-xs tabular-nums", + goalObjectiveCounter.overLimit + ? "text-destructive font-medium" + : "text-muted-foreground", + )} + data-testid="goal-length-counter" + > + {goalObjectiveCounter.length}/{goalObjectiveCounter.max} + + )} { }); }); +describe("isGoalObjectiveTooLong", () => { + it("allows objectives up to the limit", () => { + expect(isGoalObjectiveTooLong("a")).toBe(false); + expect(isGoalObjectiveTooLong("a".repeat(MAX_GOAL_OBJECTIVE_CHARS))).toBe( + false, + ); + }); + + it("flags objectives past the limit", () => { + expect( + isGoalObjectiveTooLong("a".repeat(MAX_GOAL_OBJECTIVE_CHARS + 1)), + ).toBe(true); + }); + + it("mirrors the backend limit of 4000 characters", () => { + expect(MAX_GOAL_OBJECTIVE_CHARS).toBe(4000); + }); + + it("counts interior whitespace because the backend validates raw request length", () => { + const interiorPastLimit = `${"a".repeat(2000)} ${"a".repeat(1999)}`; + expect(interiorPastLimit.length).toBe(MAX_GOAL_OBJECTIVE_CHARS + 3); + expect(isGoalObjectiveTooLong(interiorPastLimit)).toBe(true); + }); + + it("uses the parsed objective after command boundary whitespace is trimmed", () => { + const command = parseGoalCommand( + `/goal ${"a".repeat(MAX_GOAL_OBJECTIVE_CHARS)} `, + ); + expect(command).toEqual({ + kind: "set", + objective: "a".repeat(MAX_GOAL_OBJECTIVE_CHARS), + }); + if (command?.kind !== "set") { + throw new Error("expected a /goal set command"); + } + expect(isGoalObjectiveTooLong(command.objective)).toBe(false); + }); +}); + +describe("getGoalObjectiveCounter", () => { + it("returns null for non-goal or non-set inputs", () => { + expect(getGoalObjectiveCounter("hello")).toBeNull(); + expect(getGoalObjectiveCounter("/goal")).toBeNull(); + expect(getGoalObjectiveCounter("/goal clear")).toBeNull(); + }); + + it("stays hidden while the objective is comfortably under the limit", () => { + expect(getGoalObjectiveCounter("/goal ship it")).toBeNull(); + const justBelowThreshold = "a".repeat( + GOAL_OBJECTIVE_COUNTER_VISIBLE_AT - 1, + ); + expect(getGoalObjectiveCounter(`/goal ${justBelowThreshold}`)).toBeNull(); + }); + + it("appears once the objective reaches the visibility threshold", () => { + const atThreshold = "a".repeat(GOAL_OBJECTIVE_COUNTER_VISIBLE_AT); + expect(getGoalObjectiveCounter(`/goal ${atThreshold}`)).toEqual({ + length: GOAL_OBJECTIVE_COUNTER_VISIBLE_AT, + max: MAX_GOAL_OBJECTIVE_CHARS, + overLimit: false, + }); + }); + + it("marks the counter over the limit and measures raw parsed length", () => { + const overLimit = "a".repeat(MAX_GOAL_OBJECTIVE_CHARS + 5); + expect(getGoalObjectiveCounter(`/goal ${overLimit}`)).toEqual({ + length: MAX_GOAL_OBJECTIVE_CHARS + 5, + max: MAX_GOAL_OBJECTIVE_CHARS, + overLimit: true, + }); + + // Interior whitespace is preserved in the request body, so it must count + // toward the same raw max_length enforced by the backend binding. + const padded = `${"a".repeat(2000)} ${"a".repeat(1999)}`; + expect(getGoalObjectiveCounter(`/goal ${padded}`)).toEqual({ + length: MAX_GOAL_OBJECTIVE_CHARS + 3, + max: MAX_GOAL_OBJECTIVE_CHARS, + overLimit: true, + }); + + expect( + getGoalObjectiveCounter( + `/goal ${"a".repeat(MAX_GOAL_OBJECTIVE_CHARS)} `, + ), + ).toEqual({ + length: MAX_GOAL_OBJECTIVE_CHARS, + max: MAX_GOAL_OBJECTIVE_CHARS, + overLimit: false, + }); + }); +}); + describe("parseCompactCommand", () => { it("matches compact commands", () => { expect(parseCompactCommand("/compact")).toBe(true);