diff --git a/src/crates/core/src/agentic/agents/prompts/code_review.md b/src/crates/core/src/agentic/agents/prompts/code_review.md index e53bdbe66..1961dd4f7 100644 --- a/src/crates/core/src/agentic/agents/prompts/code_review.md +++ b/src/crates/core/src/agentic/agents/prompts/code_review.md @@ -115,7 +115,9 @@ When you have gathered sufficient context and completed your review, call the `s "remediation_groups": { "must_fix": ["Required correctness/security/regression fixes"], "should_improve": ["Non-blocking cleanup or quality improvements"], - "needs_decision": ["Items needing user/product judgment"], + "needs_decision": [ + {"question": "Decision point description", "plan": "Remediation if approved", "options": ["Option A", "Option B"], "tradeoffs": "Trade-off explanation", "recommendation": 0} + ], "verification": ["Focused verification steps"] }, "strength_groups": { diff --git a/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md b/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md index 1d78a24a0..7803ca7df 100644 --- a/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md +++ b/src/crates/core/src/agentic/agents/prompts/deep_review_agent.md @@ -188,7 +188,12 @@ After the quality gate finishes: - `executive_summary`: 1-3 concise bullets with the final decision and most important risk. - `remediation_groups.must_fix`: required correctness/security/regression fixes. - `remediation_groups.should_improve`: non-blocking cleanup or quality improvements. - - `remediation_groups.needs_decision`: items that need user/product judgment. + - `remediation_groups.needs_decision`: items that need user/product judgment. Each item MUST be an object with: + - `question` (required): the specific decision point (e.g. "Should we use eager loading or lazy loading for this relation?") + - `plan` (required): the remediation plan text to execute if the user approves this item + - `options` (optional): 2-4 possible approaches or choices + - `tradeoffs` (optional): brief trade-off explanation + - `recommendation` (optional): 0-based index of the recommended option - `remediation_groups.verification`: focused verification or follow-up review steps. - `strength_groups`: positive observations grouped under `architecture`, `maintainability`, `tests`, `security`, `performance`, `user_experience`, or `other`. - `coverage_notes`: confidence, timeout/cancel/failure, scope, or manual follow-up notes. diff --git a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs index 51f513237..075950dc0 100644 --- a/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/code_review_tool.rs @@ -245,7 +245,41 @@ impl CodeReviewTool { }, "needs_decision": { "type": "array", - "items": { "type": "string" } + "description": "Items needing user/product judgment. Each item should be an object with a 'question' and 'plan'.", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "The specific decision the user needs to make" + }, + "plan": { + "type": "string", + "description": "The remediation plan text to execute if the user approves" + }, + "options": { + "type": "array", + "description": "2-4 possible choices or approaches", + "items": { "type": "string" } + }, + "tradeoffs": { + "type": "string", + "description": "Brief explanation of trade-offs between options" + }, + "recommendation": { + "type": "integer", + "description": "Index of the recommended option (0-based), if any" + } + }, + "required": ["question", "plan"] + }, + { + "type": "string" + } + ] + } }, "verification": { "type": "array", diff --git a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs index 653c6ec78..32dfe0f0c 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs @@ -25,9 +25,9 @@ impl Default for FileReadTool { impl FileReadTool { pub fn new() -> Self { Self { - default_max_lines_to_read: 250, - max_line_chars: 300, - max_total_chars: 32_000, + default_max_lines_to_read: 2000, + max_line_chars: 2000, + max_total_chars: 50_000, } } @@ -165,13 +165,14 @@ Assume this tool is able to read all files on the machine. If the User provides Usage: - The file_path parameter must be either an absolute path or an exact `bitfun://runtime/...` URI returned by another tool. -- By default, it reads up to {} lines starting from the beginning of the file. +- By default, it reads up to {} lines starting from the beginning of the file. - You can optionally specify a start_line and limit. For large files, prefer reading targeted ranges instead of starting over from the beginning every time. - Any lines longer than {} characters will be truncated. - Total output is capped at {} characters. If that limit is hit, narrow the range with start_line and limit. -- Results are returned using cat -n format, with line numbers starting at 1 +- Results are returned using cat -n format, with line numbers starting at 1. - This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool. - You can call multiple tools in a single response. It is always better to speculatively read multiple potentially useful files in parallel. +- Avoid tiny repeated slices (e.g. 30-100 line chunks). If you need more context, read a larger window. "#, self.default_max_lines_to_read, self.max_line_chars, self.max_total_chars )) @@ -400,8 +401,17 @@ Usage: result_for_assistant.push_str(rules_content); } - if read_file_result.hit_total_char_limit { - result_for_assistant.push_str("\n\n[Output truncated after reaching the Read tool size limit. Request a narrower range with start_line and limit.]"); + let has_more = read_file_result.end_line < read_file_result.total_lines; + if has_more { + let next_start = read_file_result.end_line + 1; + if read_file_result.hit_total_char_limit { + result_for_assistant.push_str( + &format!("\n\n[Output truncated after reaching the Read tool size limit. Use start_line={} and limit to continue reading.]", next_start)); + } else { + result_for_assistant.push_str( + &format!("\n\n[Showing lines {}-{} of {} total. Use start_line={} and limit to continue reading.]", + read_file_result.start_line, read_file_result.end_line, read_file_result.total_lines, next_start)); + } } let lines_read = if read_file_result.total_lines == 0 diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.scss b/src/web-ui/src/app/scenes/skills/SkillsScene.scss index 59d60f253..1cf662291 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.scss +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.scss @@ -159,34 +159,36 @@ $skills-content-top: clamp(36px, 4.5vh, 48px); flex-shrink: 0; display: inline-flex; align-items: center; - justify-content: center; - gap: 5px; - height: 34px; - padding: 0 $size-gap-3; - border: none; - border-radius: $size-radius-base; - @include btn-primary.btn-primary-surface-default; + gap: $size-gap-1; + height: 24px; + padding: 0 $size-gap-2; + border: 1px solid color-mix(in srgb, var(--color-accent-500) 20%, var(--border-subtle)); + border-radius: $size-radius-full; + background: color-mix(in srgb, var(--color-accent-500) 8%, transparent); + color: var(--color-accent-600, var(--color-text-secondary)); font-size: var(--font-size-xs); font-weight: $font-weight-medium; cursor: pointer; + white-space: nowrap; transition: background $motion-fast $easing-standard, color $motion-fast $easing-standard, - box-shadow $motion-fast $easing-standard; - white-space: nowrap; + border-color $motion-fast $easing-standard; &:hover { - @include btn-primary.btn-primary-surface-hover; + background: color-mix(in srgb, var(--color-accent-500) 14%, transparent); + border-color: color-mix(in srgb, var(--color-accent-500) 30%, var(--border-medium)); + color: var(--color-accent-600, var(--color-text-primary)); } &:active { - @include btn-primary.btn-primary-surface-active; + background: color-mix(in srgb, var(--color-accent-500) 18%, transparent); } &:focus-visible { outline: 2px solid color-mix( in srgb, - var(--btn-primary-color, var(--color-accent-500)) 55%, + var(--color-accent-500) 55%, transparent ); outline-offset: 2px; diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx index 3a821f6e3..b893c32ac 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx @@ -3,6 +3,7 @@ import { CheckCircle2, ChevronLeft, ChevronRight, + Filter, FolderOpen, Package, Plus, @@ -43,10 +44,12 @@ const SkillsScene: React.FC = () => { searchDraft, marketQuery, installedFilter, + hideDuplicates, isAddFormOpen, setSearchDraft, submitMarketQuery, setInstalledFilter, + setHideDuplicates, setAddFormOpen, toggleAddForm, } = useSkillsSceneStore(); @@ -115,7 +118,9 @@ const SkillsScene: React.FC = () => { const selectedInstalledSkill = selectedDetail?.type === 'installed' ? selectedDetail.skill : null; const selectedMarketSkill = selectedDetail?.type === 'market' ? selectedDetail.skill : null; - const installedFiltered = installed.filteredSkills; + const installedFiltered = hideDuplicates + ? installed.filteredSkills.filter((s) => !s.isShadowed) + : installed.filteredSkills; const installedTotalPages = Math.max( 1, Math.ceil(installedFiltered.length / INSTALLED_PAGE_SIZE), @@ -300,13 +305,24 @@ const SkillsScene: React.FC = () => { {count} ))} + diff --git a/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts b/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts index c25b44989..b14575354 100644 --- a/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts +++ b/src/web-ui/src/app/scenes/skills/skillsSceneStore.ts @@ -6,10 +6,12 @@ interface SkillsSceneState { searchDraft: string; marketQuery: string; installedFilter: InstalledFilter; + hideDuplicates: boolean; isAddFormOpen: boolean; setSearchDraft: (value: string) => void; submitMarketQuery: () => void; setInstalledFilter: (filter: InstalledFilter) => void; + setHideDuplicates: (hide: boolean) => void; setAddFormOpen: (open: boolean) => void; toggleAddForm: () => void; } @@ -18,10 +20,12 @@ export const useSkillsSceneStore = create((set) => ({ searchDraft: '', marketQuery: '', installedFilter: 'all', + hideDuplicates: false, isAddFormOpen: false, setSearchDraft: (value) => set({ searchDraft: value }), submitMarketQuery: () => set((state) => ({ marketQuery: state.searchDraft.trim() })), setInstalledFilter: (filter) => set({ installedFilter: filter }), + setHideDuplicates: (hide) => set({ hideDuplicates: hide }), setAddFormOpen: (open) => set({ isAddFormOpen: open }), toggleAddForm: () => set((state) => ({ isAddFormOpen: !state.isAddFormOpen })), })); diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx index 61a431f33..cb0ff8d73 100644 --- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx +++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx @@ -750,13 +750,19 @@ export const Markdown = React.memo(({ icon: 'FolderOpen', onClick: () => handleRevealInExplorer(displayPath || filePath), }, + { + id: 'markdown-copy-file-path', + label: t('markdown.copyFilePath'), + icon: 'Copy', + onClick: () => void handleCopyLink(displayPath || filePath), + }, ]; showLinkContextMenu(event, items, 'markdown-local-file-link', { filePath, displayPath, }); - }, [handleRevealInExplorer, showLinkContextMenu, t]); + }, [handleRevealInExplorer, handleCopyLink, showLinkContextMenu, t]); const handleWebLinkContextMenu = useCallback((event: React.MouseEvent, url: string) => { const targetElement = event.currentTarget; diff --git a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss index 9f7b2c8aa..e0f28d8db 100644 --- a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss +++ b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss @@ -88,6 +88,31 @@ color: var(--color-error); } + &__error-banner { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 10px 16px; + background: color-mix(in srgb, var(--color-error, #ef4444) 10%, var(--color-bg-secondary)); + border-bottom: 1px solid color-mix(in srgb, var(--color-error, #ef4444) 22%, transparent); + flex-shrink: 0; + + &-icon { + flex-shrink: 0; + margin-top: 1px; + color: var(--color-error); + } + + &-text { + flex: 1; + font-size: 12px; + line-height: 1.5; + color: var(--color-error); + word-break: break-word; + user-select: text; + } + } + &__content { flex: 1; display: flex; diff --git a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx index 603113c90..144185872 100644 --- a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx +++ b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx @@ -16,7 +16,7 @@ import { FlowTextBlock } from '../FlowTextBlock'; import { FlowToolCard } from '../FlowToolCard'; import { ModelThinkingDisplay } from '../../tool-cards/ModelThinkingDisplay'; import { ToolTimeoutIndicator } from '../../tool-cards/ToolTimeoutIndicator'; -import { Button, Tooltip, DotMatrixLoader } from '@/component-library'; +import { Button, DotMatrixLoader } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import type { ReviewerContext } from '@/shared/services/reviewTeamService'; @@ -542,14 +542,16 @@ export const TaskDetailPanel: React.FC = ({ data }) => { )} - {isFailed && ( - - - - )} -
+ + {getErrorMessage()} +
+ )} + +
diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index 23ba347e8..c2d5bac1d 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -421,9 +421,9 @@ export const BtwSessionPanel: React.FC = ({ completedRemediationIds: store.completedRemediationIds, }); } else { - // Fix completed with no further remediation needed — dismiss the action bar - // so the user can focus on the fix results in the chat stream. - store.dismiss(); + // Fix completed with no further remediation needed — update phase to + // show completion state in the action bar instead of dismissing it. + store.updatePhase('fix_completed'); } } return; diff --git a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss index c086f9c60..ff171cd59 100644 --- a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss +++ b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss @@ -6,14 +6,14 @@ left: auto; right: auto; z-index: 10; - border-radius: 12px; - padding: 16px 18px; - max-height: min(520px, calc(100vh - 180px)); + border-radius: 10px; + padding: 12px 14px; + max-height: min(400px, calc(100vh - 200px)); display: flex; flex-direction: column; - gap: 12px; - font-size: 13px; - line-height: 1.5; + gap: 8px; + font-size: 12px; + line-height: 1.45; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(255, 255, 255, 0.06); @@ -59,17 +59,31 @@ border: 1px solid var(--border-base); } - /* Close button */ - &__close { + /* Top-right controls group */ + &__controls { position: absolute; - top: 10px; - right: 10px; + top: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 3px; + } + + &__controls-divider { + width: 1px; + height: 12px; + background: var(--border-subtle); + margin: 0 1px; + flex-shrink: 0; + } + + &__controls-btn { display: flex; align-items: center; justify-content: center; - width: 28px; - height: 28px; - border-radius: 6px; + width: 24px; + height: 24px; + border-radius: 5px; background: transparent; border: none; color: var(--color-text-muted); @@ -82,12 +96,40 @@ } } + &__controls .code-review-report-actions { + display: inline-flex; + align-items: center; + gap: 1px; + } + + &__controls .code-review-report-actions__button { + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 5px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: background 0.2s, color 0.2s; + + &:hover:not(:disabled) { + background: var(--element-bg-soft); + color: var(--color-text-primary); + } + + &:disabled { + cursor: default; + opacity: 0.5; + } + } + /* Status header */ &__status { display: flex; align-items: center; - gap: 8px; - padding-right: 32px; + gap: 6px; + padding-right: 90px; } &__icon { @@ -113,7 +155,7 @@ &__status-title { font-weight: 600; - font-size: 14px; + font-size: 13px; color: var(--color-text-primary); } @@ -131,7 +173,7 @@ &__remediation { display: flex; flex-direction: column; - gap: 8px; + gap: 6px; } &__remediation-toggle { @@ -165,8 +207,8 @@ &__remediation-list { display: flex; flex-direction: column; - gap: 4px; - max-height: min(260px, 32vh); + gap: 3px; + max-height: min(200px, 28vh); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; @@ -182,15 +224,15 @@ &__remediation-group-header { display: flex; align-items: center; - gap: 8px; - padding: 6px 8px; + gap: 6px; + padding: 4px 6px; background: transparent; border: none; cursor: pointer; width: 100%; text-align: left; transition: background 0.2s; - font-size: 13px; + font-size: 12px; color: var(--color-text-primary); border-radius: 6px; @@ -217,17 +259,17 @@ &__remediation-group-items { display: flex; flex-direction: column; - padding-left: 28px; + padding-left: 24px; } &__remediation-item { display: flex; align-items: flex-start; - gap: 8px; - padding: 5px 8px; + gap: 6px; + padding: 3px 6px; cursor: pointer; transition: background 0.15s; - border-radius: 6px; + border-radius: 5px; &:hover { background: var(--element-bg-subtle); @@ -267,6 +309,100 @@ overflow: hidden; } + &__remediation-link { + color: inherit; + text-decoration: none; + border-bottom: 1px dashed color-mix(in srgb, var(--color-text-muted) 50%, transparent); + cursor: pointer; + transition: border-bottom-color 0.15s ease; + + &:hover { + border-bottom-style: solid; + border-bottom-color: var(--color-text-muted); + } + } + + &__remediation-tradeoffs { + display: block; + font-size: 10px; + color: var(--color-text-muted); + font-style: italic; + margin-top: 2px; + line-height: 1.4; + } + + &__decision-toggle { + display: inline-block; + margin-top: 3px; + padding: 0; + border: none; + background: none; + color: var(--color-accent-500, #60a5fa); + font-size: 10px; + cursor: pointer; + opacity: 0.8; + transition: opacity 0.15s ease; + + &:hover { + opacity: 1; + } + } + + &__decision-options { + list-style: none; + margin: 4px 0 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + &__decision-option { + & > button { + display: flex; + align-items: flex-start; + gap: 5px; + width: 100%; + padding: 3px 6px; + border: none; + border-radius: 4px; + background: none; + color: var(--color-text-secondary); + font-size: 11px; + line-height: 1.45; + cursor: pointer; + text-align: left; + transition: background-color 0.12s ease; + + &:hover { + background: var(--element-bg-soft, rgba(255, 255, 255, 0.06)); + } + } + + &.is-selected > button { + background: color-mix(in srgb, var(--color-accent-500, #60a5fa) 12%, transparent); + color: var(--color-text-primary); + } + + &.is-recommended .deep-review-action-bar__decision-option-text { + color: var(--color-accent-500, #60a5fa); + font-weight: 500; + } + } + + &__decision-option-marker { + flex-shrink: 0; + font-size: 10px; + line-height: 1.45; + color: var(--color-text-muted); + } + + &__decision-option-text { + flex: 1; + min-width: 0; + word-break: break-word; + } + &__remediation-tag { display: inline-flex; align-items: center; @@ -331,6 +467,26 @@ color: var(--color-text-secondary); } + /* Fix completed message */ + &__fix-done { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 5px; + background: color-mix(in srgb, var(--color-success, #22c55e) 8%, transparent); + } + + &__fix-done-icon { + color: var(--color-success, #22c55e); + flex-shrink: 0; + } + + &__fix-done-text { + color: var(--color-text-secondary); + font-size: 12px; + } + /* Custom instructions */ &__custom { display: flex; @@ -390,10 +546,10 @@ z-index: 2; display: flex; align-items: center; - gap: 8px; + gap: 6px; flex-wrap: wrap; margin: 2px 0 -6px; - padding: 12px 0 6px; + padding: 8px 0 4px; border-top: 1px solid color-mix(in srgb, var(--border-base) 56%, transparent); background: var(--deep-review-action-bar-surface); } diff --git a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.test.tsx b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.test.tsx index f11472846..fe8730a7b 100644 --- a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.test.tsx +++ b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.test.tsx @@ -336,7 +336,7 @@ describeWithJsdom('DeepReviewActionBar', () => { root.render(); }); - const closeButton = container.querySelector('.deep-review-action-bar__close'); + const closeButton = container.querySelector('.deep-review-action-bar__controls-btn'); expect(closeButton).toBeTruthy(); await act(async () => { diff --git a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.tsx b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.tsx index 22a92a7bc..393254ae3 100644 --- a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.tsx +++ b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.tsx @@ -8,7 +8,6 @@ import { Loader2, ChevronDown, ChevronUp, - X, MessageSquare, Play, Copy, @@ -16,6 +15,7 @@ import { SkipForward, RotateCcw, Eye, + Minus, } from 'lucide-react'; import { Button, Checkbox, Tooltip } from '@/component-library'; import { useReviewActionBarStore, type ReviewActionPhase } from '../../store/deepReviewActionBarStore'; @@ -25,6 +25,7 @@ import type { RemediationGroupId } from '../../utils/codeReviewReport'; import { continueDeepReviewSession } from '../../services/DeepReviewContinuationService'; import { flowChatManager } from '../../services/FlowChatManager'; import { globalEventBus } from '@/infrastructure/event-bus'; +import { DEEP_REVIEW_SCROLL_TO_EVENT } from '../../events/flowchatNavigation'; import { notificationService } from '@/shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import { getAiErrorPresentation } from '@/shared/ai-errors/aiErrorPresenter'; @@ -38,6 +39,7 @@ import { extractPartialReviewData, } from '../../utils/deepReviewExperience'; import { flowChatStore } from '../../store/FlowChatStore'; +import { CodeReviewReportExportActions } from '../../tool-cards/CodeReviewReportExportActions'; import './DeepReviewActionBar.scss'; const log = createLogger('DeepReviewActionBar'); @@ -93,12 +95,14 @@ export const ReviewActionBar: React.FC = () => { interruption, completedRemediationIds, remainingFixIds, + decisionSelections, } = store; const [showCustomInput, setShowCustomInput] = useState(false); const [showRemediationList, setShowRemediationList] = useState(true); const [showPartialResults, setShowPartialResults] = useState(false); const [showRecoveryPlan, setShowRecoveryPlan] = useState(false); + const [expandedDecisionIds, setExpandedDecisionIds] = useState>(new Set()); const [elapsedMs, setElapsedMs] = useState(0); const [longRunningNotified, setLongRunningNotified] = useState(false); @@ -220,6 +224,7 @@ export const ReviewActionBar: React.FC = () => { rerunReview, reviewMode, completedItems: [...completedRemediationIds], + decisionSelections: store.decisionSelections, }); if (!prompt) return; @@ -279,6 +284,7 @@ export const ReviewActionBar: React.FC = () => { selectedIds: selectedRemediationIds, rerunReview: false, reviewMode, + decisionSelections: store.decisionSelections, }); if (customInstructions.trim()) { @@ -555,14 +561,21 @@ export const ReviewActionBar: React.FC = () => { onWheel={stopNestedScrollPropagation} onTouchMove={stopNestedScrollPropagation} > - + {/* Top-right controls: export actions + minimize */} +
+ {reviewData && ( + + )} + + +
{/* Phase status header */}
@@ -838,7 +851,10 @@ export const ReviewActionBar: React.FC = () => { disabled={isCompleted} size="small" /> - + {isCompleted && ( )} @@ -847,7 +863,86 @@ export const ReviewActionBar: React.FC = () => { {t('reviewActionBar.needsDecisionTag', { defaultValue: 'Decision' })} )} - {item.plan} + {item.groupId === 'verification' ? ( + + {item.decisionContext?.question ?? item.plan} + + ) : ( + { + e.preventDefault(); + e.stopPropagation(); + if (item.groupId != null) { + globalEventBus.emit(DEEP_REVIEW_SCROLL_TO_EVENT, { + groupId: item.groupId, + groupIndex: item.groupIndex, + issueIndex: item.issueIndex ?? -1, + }); + } + }} + > + {item.decisionContext?.question ?? item.plan} + + )} + {item.decisionContext?.tradeoffs && ( + + {item.decisionContext.tradeoffs} + + )} + {item.decisionContext?.options && item.decisionContext.options.length > 0 && ( + + )} + {expandedDecisionIds.has(item.id) && item.decisionContext?.options && ( +
    + {item.decisionContext.options.map((opt, oi) => { + const isSelected = decisionSelections[item.id] === oi; + const isRecommended = item.decisionContext?.recommendation === oi; + return ( +
  • + +
  • + ); + })} +
+ )}
); @@ -885,6 +980,18 @@ export const ReviewActionBar: React.FC = () => {
)} + {/* Fix completed — show success message */} + {phase === 'fix_completed' && ( +
+ + + {t('deepReviewActionBar.fixCompletedMessage', { + defaultValue: 'All fixes applied successfully.', + })} + +
+ )} + {/* Custom instructions input */} {phase === 'review_completed' && remediationItems.length > 0 && (
diff --git a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBarLayout.test.ts b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBarLayout.test.ts index 08e6cfa65..e96837ec2 100644 --- a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBarLayout.test.ts +++ b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBarLayout.test.ts @@ -47,7 +47,7 @@ describe('DeepReviewActionBar layout styles', () => { expect(root).toContain('overflow-y: auto;'); expect(root).toContain('scrollbar-gutter: stable;'); expect(actions).toContain('margin: 2px 0 -6px;'); - expect(actions).toContain('padding: 12px 0 6px;'); + expect(actions).toContain('padding: 8px 0 4px;'); expect(stylesheet).not.toContain('--deep-review-action-bar-scrollbar-gutter'); expect(actions).not.toContain('calc('); }); diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx index 4ec688aec..95fd7458d 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ExploreGroupRenderer.tsx @@ -105,13 +105,17 @@ export const ExploreGroupRenderer: React.FC = React.m ]); // Auto-scroll to bottom during streaming. + // Use double requestAnimationFrame to ensure the browser has completed + // layout of newly added content before we measure scrollHeight. useEffect(() => { if (!isCollapsed && isGroupStreaming && containerRef.current) { requestAnimationFrame(() => { - if (containerRef.current) { - containerRef.current.scrollTop = containerRef.current.scrollHeight; - checkScrollState(); - } + requestAnimationFrame(() => { + if (containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight; + checkScrollState(); + } + }); }); } }, [allItems, checkScrollState, isCollapsed, isGroupStreaming]); diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss index e192c203e..6b101383e 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss @@ -58,18 +58,18 @@ // ==================== Collapsible (header + animated content) ==================== .explore-region--collapsible { - margin: 0 0 var(--flowchat-flow-item-gap, 0.45rem) 0; - font-size: var(--flowchat-font-size-base); - line-height: 1.65; + margin: 0 0 0.3rem 0; + font-size: var(--flowchat-font-size-sm); + line-height: 1.5; .explore-region__header { display: flex; align-items: center; - gap: 6px; + gap: 4px; padding: 0; color: var(--text-tertiary, #6b7280); - font-size: var(--flowchat-font-size-base); - line-height: 1.65; + font-size: var(--flowchat-font-size-sm); + line-height: 1.5; cursor: pointer; user-select: none; border-radius: var(--radius-sm, 6px); @@ -90,8 +90,8 @@ } .explore-region__summary { - font-size: var(--flowchat-font-size-base); - line-height: 1.65; + font-size: var(--flowchat-font-size-sm); + line-height: 1.5; opacity: 0.8; } @@ -213,6 +213,9 @@ .explore-region--expanded.explore-region--streaming { .explore-region__content { max-height: 400px; + // Add bottom padding so the last line of text isn't obscured by the + // streaming gradient overlay or clipped at the container edge. + padding-bottom: 8px; } } diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 8f981c933..1542591c7 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -535,15 +535,19 @@ const SubagentItemsContainer = React.memo(({ // Auto-scroll only when the subagent item data changes. A MutationObserver on // the whole subtree also reacts to layout-driven DOM churn while the right // panel opens, which amplifies FlowChat reflow work. + // Use double requestAnimationFrame to ensure the browser has completed + // layout of newly added content before we measure scrollHeight. useEffect(() => { const container = containerRef.current; if (!container || isCollapsed) return; const rafId = requestAnimationFrame(() => { - if (!userScrolledUpRef.current) { - container.scrollTop = container.scrollHeight; - lastScrollTopRef.current = container.scrollTop; - } + requestAnimationFrame(() => { + if (!userScrolledUpRef.current) { + container.scrollTop = container.scrollHeight; + lastScrollTopRef.current = container.scrollTop; + } + }); }); return () => cancelAnimationFrame(rafId); @@ -576,6 +580,69 @@ const SubagentItemsContainer = React.memo(({ ); }); +/** + * Truncates text content by line count to avoid breaking Markdown structures + * (code blocks, tables, links) in the middle. Shows an expand hint when truncated. + */ +const SUBAGENT_TEXT_TRUNCATE_LINES = 50; + +const SubagentTextBlock = React.memo<{ textItem: FlowTextItem; className?: string }>(({ + textItem, + className = '', +}) => { + const [isExpanded, setIsExpanded] = useState(false); + const { t } = useTranslation('flow-chat'); + + const content = typeof textItem.content === 'string' + ? textItem.content + : String(textItem.content || ''); + + const isStreaming = textItem.isStreaming && + (textItem.status === 'streaming' || textItem.status === 'running'); + + const lines = content.split('\n'); + const shouldTruncate = !isStreaming && !isExpanded && lines.length > SUBAGENT_TEXT_TRUNCATE_LINES; + + if (!shouldTruncate) { + return ( + + ); + } + + // Truncate at line boundary to keep Markdown structures intact. + const truncatedContent = lines.slice(0, SUBAGENT_TEXT_TRUNCATE_LINES).join('\n'); + const truncatedItem: FlowTextItem = { + ...textItem, + content: truncatedContent, + isStreaming: false, + }; + + return ( +
+ +
+ + {t('subagent.showingLines', { shown: SUBAGENT_TEXT_TRUNCATE_LINES, total: lines.length })} + + +
+
+ ); +}); + /** * Subagent item renderer (used inside the container, no collapse logic). */ @@ -615,7 +682,7 @@ const SubagentItemRenderer = React.memo<{ item: FlowItem; turnId: string; roundI switch (item.type) { case 'text': return ( - diff --git a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss index a1f76b8f5..c6493f3cd 100644 --- a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss +++ b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss @@ -41,8 +41,6 @@ .flow-text-block--subagent-compact { margin-bottom: 0.15rem; - max-height: calc(var(--flowchat-font-size-base) * 1.45 * 3); - overflow: hidden; line-height: 1.45; .markdown-renderer, @@ -66,6 +64,31 @@ overflow: hidden; } +// Truncated subagent text block styles. +.subagent-text-block--truncated { + .subagent-text-block__truncation-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + font-size: var(--flowchat-font-size-sm); + color: var(--color-text-muted); + } + + .subagent-text-block__expand-btn { + background: none; + border: none; + color: var(--flowchat-link-color, var(--markdown-link-color, #60a5fa)); + cursor: pointer; + font-size: var(--flowchat-font-size-sm); + padding: 0; + + &:hover { + text-decoration: underline; + } + } +} + // Keep the legacy subagent-item class for compatibility. .subagent-item { display: block; diff --git a/src/web-ui/src/flow_chat/events/flowchatNavigation.ts b/src/web-ui/src/flow_chat/events/flowchatNavigation.ts index 280515563..87c99fdc0 100644 --- a/src/web-ui/src/flow_chat/events/flowchatNavigation.ts +++ b/src/web-ui/src/flow_chat/events/flowchatNavigation.ts @@ -23,3 +23,16 @@ export interface FlowChatPinTurnToTopRequest { source?: FlowChatPinTurnToTopSource; pinMode?: FlowChatPinTurnToTopMode; } + +/** + * Event for scrolling from the review action bar to a specific remediation + * item in the CodeReviewToolCard report. + */ +export const DEEP_REVIEW_SCROLL_TO_EVENT = 'deep-review:scroll-to'; + +export interface DeepReviewScrollToRequest { + groupId: string; + groupIndex: number; + /** Index of the best-matching issue in the report's `issues` array, or -1. */ + issueIndex: number; +} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts index d43f37548..86dff03ad 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/TextChunkModule.test.ts @@ -32,6 +32,17 @@ function makeContext(session: Session): any { } }, batchUpdateModelRoundItems: () => {}, + updateDialogTurn: ( + _sessionId: string, + _turnId: string, + updater: (turn: any) => any, + ) => { + const turn = session.dialogTurns[0]; + const updated = updater(turn); + if (updated) { + Object.assign(turn, updated); + } + }, }, contentBuffers: new Map(), activeTextItems: new Map(), @@ -42,6 +53,7 @@ function makeContext(session: Session): any { lastSaveHashes: new Map(), turnSaveInFlight: new Map(), turnSavePending: new Set(), + runtimeStatusTimers: new Map(), }; } diff --git a/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts b/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts index 301f6dc8b..be6a2d855 100644 --- a/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts +++ b/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts @@ -71,6 +71,8 @@ export interface ReviewActionBarState { fixingRemediationIds: Set; /** IDs of items remaining when a fix was interrupted */ remainingFixIds: string[]; + /** User's option choice for needs_decision items: map of item id -> option index */ + decisionSelections: Record; // ---- actions ---- showActionBar: (params: { @@ -98,6 +100,7 @@ export interface ReviewActionBarState { minimize: () => void; restore: () => void; skipRemainingFixes: () => void; + setDecisionSelection: (itemId: string, optionIndex: number) => void; reset: () => void; } @@ -121,6 +124,7 @@ const initialState = { completedRemediationIds: new Set(), fixingRemediationIds: new Set(), remainingFixIds: [] as string[], + decisionSelections: {} as Record, }; export const useReviewActionBarStore = create((set, get) => ({ @@ -159,6 +163,7 @@ export const useReviewActionBarStore = create((set, get) = completedRemediationIds: preservedCompleted, fixingRemediationIds: new Set(), remainingFixIds: [], + decisionSelections: {}, }); }, @@ -181,6 +186,7 @@ export const useReviewActionBarStore = create((set, get) = completedRemediationIds: new Set(), fixingRemediationIds: new Set(), remainingFixIds: [], + decisionSelections: {}, }); }, @@ -267,6 +273,10 @@ export const useReviewActionBarStore = create((set, get) = dismiss: () => set({ dismissed: true }), minimize: () => set({ minimized: true }), restore: () => set({ minimized: false }), + setDecisionSelection: (itemId, optionIndex) => + set((state) => ({ + decisionSelections: { ...state.decisionSelections, [itemId]: optionIndex }, + })), skipRemainingFixes: () => set({ phase: 'review_completed', remainingFixIds: [], diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss index b5f0d06af..d85131adf 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.scss @@ -362,6 +362,7 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + cursor: default; } /* ========== Other option ========== */ diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx index 45a5563a1..1c22d052d 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx @@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next'; import type { FlowToolItem, ToolCardProps } from '../types/flow-chat'; import { toolAPI } from '@/infrastructure/api/service-api/ToolAPI'; import { createLogger } from '@/shared/utils/logger'; -import { Button } from '@/component-library'; +import { Button, Tooltip } from '@/component-library'; import { useToolCardHeightContract } from './useToolCardHeightContract'; import './AskUserQuestionCard.scss'; @@ -27,6 +27,33 @@ interface QuestionData { multiSelect: boolean; } +/** Renders option description with tooltip for truncated text */ +const OptionDescription: React.FC<{ description: string }> = ({ description }) => { + const descRef = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + useLayoutEffect(() => { + const el = descRef.current; + if (el) { + setIsTruncated(el.scrollWidth > el.clientWidth); + } + }, [description]); + + const descElement = ( +
{description}
+ ); + + if (isTruncated) { + return ( + + {descElement} + + ); + } + + return descElement; +}; + function normalizeQuestionsFromParams(input: unknown): QuestionData[] { if (!input || typeof input !== 'object') return []; const raw = input as Record; @@ -255,7 +282,7 @@ export const AskUserQuestionCard: React.FC = ({ )}
{option.label}
-
{option.description}
+
))} diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss index 8b03b00b1..87eb6f1e6 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.scss @@ -223,6 +223,59 @@ color: var(--color-text-secondary); font-size: 12px; line-height: 1.55; + + li { + padding: 2px 4px; + border-radius: 3px; + transition: background-color 0.15s ease; + + &.is-highlighted { + animation: review-highlight-fade 2s ease-out forwards; + } + } + } + + @keyframes review-highlight-fade { + 0% { + background-color: color-mix(in srgb, var(--color-accent-500, #60a5fa) 18%, transparent); + } + 100% { + background-color: transparent; + } + } + + .review-decision-item { + display: flex; + flex-direction: column; + gap: 4px; + } + + .review-decision-item__question { + color: var(--color-text-primary); + font-weight: 500; + } + + .review-decision-item__options { + margin: 0; + padding-left: 16px; + color: var(--color-text-secondary); + font-size: 12px; + line-height: 1.55; + + li { + padding: 1px 0; + + &.is-recommended { + color: var(--color-accent-500, #60a5fa); + font-weight: 500; + } + } + } + + .review-decision-item__tradeoffs { + color: var(--color-text-muted); + font-size: 11px; + font-style: italic; } .team-list { diff --git a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx index 1be5488b5..2cb9cb03a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/CodeReviewToolCard.tsx @@ -34,6 +34,9 @@ import { type StrengthGroupId, } from '../utils/codeReviewReport'; import { CodeReviewReportExportActions } from './CodeReviewReportExportActions'; +import { DEEP_REVIEW_SCROLL_TO_EVENT, type DeepReviewScrollToRequest } from '../events/flowchatNavigation'; +import { globalEventBus } from '@/infrastructure'; +import { normalizeDecisionEntry, type DecisionContext } from '../utils/codeReviewReport'; import './CodeReviewToolCard.scss'; const log = createLogger('CodeReviewToolCard'); @@ -129,11 +132,11 @@ function renderReportGroupList titleForGroup: (id: TId) => string, ): React.ReactNode { return groups.map((group) => ( -
+
{titleForGroup(group.id)}
    {group.items.map((item, index) => ( -
  • {item}
  • +
  • {item}
  • ))}
@@ -319,6 +322,49 @@ export const CodeReviewToolCard: React.FC = React.memo(({ }); }, []); + // Listen for scroll-to events from the review action bar + useEffect(() => { + const handler = (request: DeepReviewScrollToRequest) => { + // Ensure the card is expanded + if (!isExpanded) { + setIsExpanded(true); + } + + // Ensure both issues and remediation sections are expanded + setExpandedReportSectionIds((current) => { + const next = new Set(current); + next.add('remediation'); + next.add('issues'); + return next; + }); + + // Double rAF: wait for React state update + DOM render before scrolling + requestAnimationFrame(() => { + requestAnimationFrame(() => { + // Prefer scrolling to the matching issue (has title + description) + // Fall back to the remediation plan item + let anchor: HTMLElement | null = null; + if (request.issueIndex >= 0) { + anchor = document.getElementById(`review-issue-${request.issueIndex}`); + } + if (!anchor) { + anchor = document.getElementById(`review-remediation-${request.groupId}-${request.groupIndex}`); + } + if (anchor) { + anchor.scrollIntoView({ behavior: 'smooth', block: 'center' }); + anchor.classList.add('is-highlighted'); + setTimeout(() => anchor!.classList.remove('is-highlighted'), 2000); + } + }); + }); + }; + + globalEventBus.on(DEEP_REVIEW_SCROLL_TO_EVENT, handler); + return () => { + globalEventBus.off(DEEP_REVIEW_SCROLL_TO_EVENT, handler); + }; + }, [isExpanded]); + const renderContent = () => { if (status === 'completed' && reviewData) { const riskLevel = reviewData.summary?.risk_level ?? 'low'; @@ -504,6 +550,7 @@ export const CodeReviewToolCard: React.FC = React.memo(({ {issues.map((issue, index) => (
@@ -564,10 +611,60 @@ export const CodeReviewToolCard: React.FC = React.memo(({
{review_mode === 'deep' ? (
- {renderReportGroupList( - reportSections.remediationGroups, - (id) => getRemediationGroupTitle(id, t), - )} + {reportSections.remediationGroups.map((group) => { + const groupTitle = getRemediationGroupTitle(group.id, t); + + // Render needs_decision group with structured decision context + if (group.id === 'needs_decision') { + const rawEntries = reviewData?.report_sections?.remediation_groups?.needs_decision; + return ( +
+
{groupTitle}
+
    + {group.items.map((_, index) => { + const raw = rawEntries?.[index]; + const ctx = raw ? normalizeDecisionEntry(raw as string | DecisionContext) : null; + return ( +
  • + {ctx && ctx.question !== ctx.plan ? ( +
    +
    {ctx.question}
    + {ctx.options && ctx.options.length > 0 && ( +
      + {ctx.options.map((opt, oi) => ( +
    • + {opt}{oi === ctx.recommendation ? ` (${t('toolCards.codeReview.remediationActions.recommended', { defaultValue: 'recommended' })})` : ''} +
    • + ))} +
    + )} + {ctx.tradeoffs && ( +
    {ctx.tradeoffs}
    + )} +
    + ) : ( + group.items[index] + )} +
  • + ); + })} +
+
+ ); + } + + // Default rendering for other groups + return ( +
+
{groupTitle}
+
    + {group.items.map((item, index) => ( +
  • {item}
  • + ))} +
+
+ ); + })}
) : (
diff --git a/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx index 3e4ad3fbc..f8f0a09e0 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/ContextCompressionDisplay.tsx @@ -66,7 +66,7 @@ export const ContextCompressionDisplay: React.FC case 'tool_batch': return t('toolCards.contextCompression.toolBatchComplete'); case 'ai_response': - return 'After AI response'; + return t('toolCards.contextCompression.afterAiResponse'); case 'manual': return t('toolCards.contextCompression.manualTrigger'); default: @@ -111,16 +111,22 @@ export const ContextCompressionDisplay: React.FC {data.tokensBefore !== undefined && data.tokensAfter !== undefined ? ( <> - {data.tokensBefore.toLocaleString()} → {data.tokensAfter.toLocaleString()} tokens + {t('toolCards.contextCompression.tokenChange', { + before: data.tokensBefore.toLocaleString(), + after: data.tokensAfter.toLocaleString(), + })} {savedTokens !== undefined && data.compressionRatio !== undefined && ( - Saved {savedTokens.toLocaleString()} · Ratio {(data.compressionRatio * 100).toFixed(0)}% + {t('toolCards.contextCompression.savingsTag', { + saved: savedTokens.toLocaleString(), + ratio: (data.compressionRatio * 100).toFixed(0), + })} )} ) : ( - Compressing context... + {t('toolCards.contextCompression.compressingContext')} )} } @@ -128,7 +134,7 @@ export const ContextCompressionDisplay: React.FC <> {data.status === 'completed' && data.compressionCount && ( - {getTriggerText(data.trigger)} · Compression #{data.compressionCount} + {getTriggerText(data.trigger)} · {t('toolCards.contextCompression.compressionCount', { count: data.compressionCount })} )} diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss index 0a57c2d9d..732b29633 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss @@ -227,7 +227,7 @@ that can leave only padding visible and push the prompt text out of view. */ .task-expanded-content .task-prompt-content { padding: 0 12px; - max-height: calc(var(--flowchat-font-size-base) * 1.65 * 3 + 10px); + max-height: calc(var(--flowchat-font-size-base) * 1.5 * 2 + 6px); overflow-y: auto; } @@ -271,7 +271,7 @@ /* Extra breathing room below header / divider before prompt + actions. */ &.base-tool-card-wrapper > .base-tool-card-expanded { - padding-top: 14px; + padding-top: 8px; } &.base-tool-card-wrapper > .base-tool-card-expanded:has(.task-prompt-content) { @@ -319,7 +319,7 @@ .task-expanded-content { display: flex; flex-direction: column; - gap: 12px; + gap: 8px; } diff --git a/src/web-ui/src/flow_chat/utils/codeReviewRemediation.ts b/src/web-ui/src/flow_chat/utils/codeReviewRemediation.ts index bff899cbc..01ae8ddee 100644 --- a/src/web-ui/src/flow_chat/utils/codeReviewRemediation.ts +++ b/src/web-ui/src/flow_chat/utils/codeReviewRemediation.ts @@ -1,8 +1,10 @@ import type { CodeReviewReportSectionsData, + DecisionContext, RemediationGroupId, ReviewMode, } from './codeReviewReport'; +import { normalizeDecisionEntry } from './codeReviewReport'; export interface CodeReviewRemediationSummary { overall_assessment?: string; @@ -34,10 +36,14 @@ export interface CodeReviewRemediationData { export interface ReviewRemediationItem { id: string; index: number; + groupIndex: number; plan: string; issue?: CodeReviewRemediationIssue; + /** Index of the best-matching issue in the report's `issues` array, or -1. */ + issueIndex: number; groupId?: RemediationGroupId; requiresDecision?: boolean; + decisionContext?: DecisionContext; defaultSelected: boolean; } @@ -49,20 +55,54 @@ export const REMEDIATION_GROUP_ORDER: RemediationGroupId[] = [ 'verification', ]; -function nonEmpty(values?: Array): string[] { - const seen = new Set(); - const result: string[] = []; +/** + * Find the best-matching issue index for a remediation plan text. + * Matches by extracting file paths from the plan and checking issue.file. + * Falls back to category matching, then to overall position order. + */ +function findMatchingIssueIndex( + plan: string, + issues: CodeReviewRemediationIssue[] | undefined, + positionHint: number, +): number { + if (!issues || issues.length === 0) return -1; + + const planLower = plan.toLowerCase(); + + // Strategy 1: match by file path mentioned in plan + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + if (issue.file && planLower.includes(issue.file.toLowerCase())) { + return i; + } + } - for (const value of values ?? []) { - const trimmed = value?.trim(); - if (!trimmed || seen.has(trimmed)) { - continue; + // Strategy 2: match by category keyword in plan + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + if (issue.category && planLower.includes(issue.category.toLowerCase())) { + return i; + } + } + + // Strategy 3: match by issue title keywords (significant words) + for (let i = 0; i < issues.length; i++) { + const issue = issues[i]; + if (issue.title) { + const titleWords = issue.title.toLowerCase().split(/\s+/).filter(w => w.length > 3); + const matchCount = titleWords.filter(w => planLower.includes(w)).length; + if (matchCount >= Math.ceil(titleWords.length * 0.5) && matchCount >= 2) { + return i; + } } - seen.add(trimmed); - result.push(trimmed); } - return result; + // Strategy 4: positional hint (for legacy data where plans and issues are 1:1 ordered) + if (positionHint < issues.length) { + return positionHint; + } + + return -1; } function hasConcreteFixSignal(issue?: CodeReviewRemediationIssue): boolean { @@ -95,20 +135,41 @@ function buildStructuredRemediationItems( return []; } + const issues = reviewData.issues; const items: ReviewRemediationItem[] = []; + let globalIssueOffset = 0; for (const groupId of REMEDIATION_GROUP_ORDER) { - for (const plan of nonEmpty(remediationGroups[groupId])) { + const rawEntries = remediationGroups[groupId]; + if (!rawEntries || !Array.isArray(rawEntries) || rawEntries.length === 0) { + continue; + } + + let groupIndex = 0; + for (const raw of rawEntries) { + // Normalize: needs_decision entries may be structured objects or plain strings + const isDecision = groupId === 'needs_decision'; + const normalized = isDecision ? normalizeDecisionEntry(raw as string | DecisionContext) : null; + const plan = isDecision && normalized ? normalized.plan : String(raw).trim(); + if (!plan) { + continue; + } + const index = items.length; - const requiresDecision = groupId === 'needs_decision'; + const issueIndex = findMatchingIssueIndex(plan, issues, globalIssueOffset); items.push({ id: `remediation-${groupId}-${index}`, index, + groupIndex, plan, + issueIndex, groupId, - requiresDecision, + requiresDecision: isDecision, + decisionContext: isDecision ? normalized ?? undefined : undefined, defaultSelected: groupId === 'must_fix', }); + groupIndex++; + globalIssueOffset++; } } @@ -132,10 +193,13 @@ export function buildReviewRemediationItems( } const issue = reviewData.issues?.[index]; + const issueIndex = issue ? index : findMatchingIssueIndex(trimmedPlan, reviewData.issues, index); items.push({ id: `remediation-${index}`, index, + groupIndex: index, plan: trimmedPlan, + issueIndex, ...(issue ? { issue } : {}), defaultSelected: shouldSelectByDefault(reviewData, issue), }); @@ -158,11 +222,29 @@ function formatIssueLocation(issue: CodeReviewRemediationIssue): string { return issue.line ? `${issue.file}:${issue.line}` : issue.file; } -function formatIssueForPrompt(item: ReviewRemediationItem): string { +function formatIssueForPrompt(item: ReviewRemediationItem, decisionSelection?: number): string { const issue = item.issue; + const decisionCtx = item.decisionContext; + + // Build decision context line if available + const decisionLines: string[] = []; + if (decisionCtx) { + decisionLines.push(` Decision: ${decisionCtx.question}`); + if (decisionCtx.options && decisionCtx.options.length > 0) { + if (decisionSelection != null) { + decisionLines.push(` User chose option ${decisionSelection + 1}: ${decisionCtx.options[decisionSelection]}`); + } else if (decisionCtx.recommendation != null) { + decisionLines.push(` Recommended option ${decisionCtx.recommendation + 1}: ${decisionCtx.options[decisionCtx.recommendation]}`); + } + } + } + if (!issue) { const groupLabel = item.groupId ? ` [${item.groupId}]` : ''; - return `${item.index + 1}.${groupLabel} No directly-linked issue. Plan: ${item.plan}`; + return [ + `${item.index + 1}.${groupLabel} No directly-linked issue. Plan: ${item.plan}`, + ...decisionLines, + ].filter(Boolean).join('\n'); } return [ @@ -170,6 +252,7 @@ function formatIssueForPrompt(item: ReviewRemediationItem): string { ` Description: ${issue.description ?? 'N/A'}`, ` Suggestion: ${issue.suggestion ?? item.plan}`, issue.validation_note ? ` Validation: ${issue.validation_note}` : undefined, + ...decisionLines, ].filter(Boolean).join('\n'); } @@ -177,6 +260,7 @@ export function buildSelectedRemediationPrompt(params: { reviewData: CodeReviewRemediationData; selectedIds: Set; rerunReview: boolean; + decisionSelections?: Record; }): string { return buildSelectedReviewRemediationPrompt({ ...params, @@ -190,6 +274,7 @@ export function buildSelectedReviewRemediationPrompt(params: { rerunReview: boolean; reviewMode: ReviewMode; completedItems?: string[]; + decisionSelections?: Record; }): string { if (params.selectedIds.size === 0) { return ''; @@ -206,7 +291,7 @@ export function buildSelectedReviewRemediationPrompt(params: { .map((item, index) => `${index + 1}. ${item.plan}`) .join('\n'); const issuesBlock = selectedItems - .map(formatIssueForPrompt) + .map((item) => formatIssueForPrompt(item, params.decisionSelections?.[item.id])) .join('\n\n'); const isDeepReview = params.reviewMode === 'deep'; const reviewLabel = isDeepReview ? 'Deep Review' : 'Code Review'; diff --git a/src/web-ui/src/flow_chat/utils/codeReviewReport.ts b/src/web-ui/src/flow_chat/utils/codeReviewReport.ts index ce95b8501..f34c57889 100644 --- a/src/web-ui/src/flow_chat/utils/codeReviewReport.ts +++ b/src/web-ui/src/flow_chat/utils/codeReviewReport.ts @@ -44,11 +44,31 @@ export interface CodeReviewReviewer { export interface CodeReviewReportSectionsData { executive_summary?: string[]; - remediation_groups?: Partial>; + remediation_groups?: Partial>; strength_groups?: Partial>; coverage_notes?: string[]; } +/** + * Structured decision context for `needs_decision` remediation items. + * Falls back to a plain string when the AI returns a legacy format. + */ +export interface DecisionContext { + question: string; + plan: string; + options?: string[]; + tradeoffs?: string; + recommendation?: number; +} + +/** Normalize a raw `needs_decision` entry to a DecisionContext object. */ +export function normalizeDecisionEntry(entry: string | DecisionContext): DecisionContext { + if (typeof entry === 'string') { + return { question: entry, plan: entry }; + } + return entry; +} + export interface CodeReviewReportData { schema_version?: number; schemaVersion?: number; @@ -253,7 +273,21 @@ function buildReviewerStats(reviewers: CodeReviewReviewer[] = []): ReviewReviewe export function buildCodeReviewReportSections(report: CodeReviewReportData): ReviewReportSections { const structuredSections = report.report_sections; - const remediationGroups = buildGroups(REMEDIATION_GROUP_ORDER, structuredSections?.remediation_groups); + + // Normalize remediation groups: DecisionContext entries become their plan text for display + const rawRemediationGroups = structuredSections?.remediation_groups; + const normalizedRemediationGroups: Partial> = {}; + if (rawRemediationGroups) { + for (const [key, entries] of Object.entries(rawRemediationGroups) as [RemediationGroupId, (string | DecisionContext)[] | undefined][]) { + if (!entries) continue; + normalizedRemediationGroups[key] = entries.map((entry) => { + if (typeof entry === 'string') return entry; + return entry.plan; + }); + } + } + + const remediationGroups = buildGroups(REMEDIATION_GROUP_ORDER, normalizedRemediationGroups); const strengthGroups = buildGroups(STRENGTH_GROUP_ORDER, structuredSections?.strength_groups); const executiveSummary = nonEmpty(structuredSections?.executive_summary); const coverageNotes = nonEmpty(structuredSections?.coverage_notes); diff --git a/src/web-ui/src/locales/en-US/components.json b/src/web-ui/src/locales/en-US/components.json index 756269e41..217b6995c 100644 --- a/src/web-ui/src/locales/en-US/components.json +++ b/src/web-ui/src/locales/en-US/components.json @@ -46,7 +46,8 @@ "openInExplorer": "Open in Explorer", "openInBrowser": "Open in browser", "openInBuiltInBrowser": "Open in built-in browser", - "copyLink": "Copy link" + "copyLink": "Copy link", + "copyFilePath": "Copy file path" }, "mermaidBlock": { "renderFailed": "Diagram render failed", diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 3b961ef19..757fd3c34 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -386,7 +386,7 @@ "close": "Close", "minimize": "Minimize", "restore": "Open {{label}}", - "fixAndReviewRunning": "Fixing and preparing re-review...", + "fixAndReviewRunning": "Fixing \u0026 preparing re-review...", "minimizedDeep": "Deep Review", "minimizedStandard": "Code Review", "minimizedFix": "Fixing", @@ -400,6 +400,7 @@ "customInstructionsPlaceholder": "Describe additional requirements or context for the fix...", "fillBackInput": "Fill to input", "fillBackInputHint": "Copy selected fix plan to the input box for manual editing", + "fixCompletedMessage": "All fixes applied successfully.", "replaceInputConfirmTitle": "Replace current input?", "replaceInputConfirmMessage": "The chat input already has text. Filling this plan will replace the current draft.", "replaceInputConfirmAction": "Replace input", @@ -1000,11 +1001,16 @@ "toolBatchComplete": "Tool batch complete", "manualTrigger": "Manual trigger", "autoTrigger": "Auto trigger", + "afterAiResponse": "After AI response", "contextCompressionFailed": "Context compression failed", "contextCompression": "Context compression:", - "localFallbackHeader": "Model request failed; local structured trimming", + "localFallbackHeader": "Model request failed; local structured compression", "localFallbackNotice": "Model-based summary was unavailable, so this compression used local structured truncation.", - "noSummaryNotice": "No additional summary was generated for this compaction pass." + "noSummaryNotice": "No additional summary was generated for this compaction pass.", + "tokenChange": "{{before}} → {{after}} tokens", + "savingsTag": "Saved {{saved}} · Ratio {{ratio}}%", + "compressingContext": "Compressing context...", + "compressionCount": "Compression #{{count}}" }, "globSearch": { "parsingPattern": "Parsing search pattern...", @@ -1232,11 +1238,14 @@ "noSelectionHint": "Select at least one remediation item to start fixing.", "ungrouped": "Other", "startFix": "Start fixing", - "fixAndReview": "Fix and re-review", + "fixAndReview": "Fix \\u0026 re-review", "cancel": "Cancel", "fixUnavailable": "Unable to start remediation because the review session is unavailable.", "fixRequestDisplay": "Start fixing Deep Review findings", - "fixAndReviewRequestDisplay": "Fix Deep Review findings and re-review" + "fixAndReviewRequestDisplay": "Fix Deep Review findings and re-review", + "recommended": "recommended", + "expandOptions": "Show options", + "collapseOptions": "Hide options" }, "riskLevels": { "low": "Low Risk", @@ -1432,5 +1441,9 @@ "prompt": "Help me create a budget plan.\n\nContext:\n- Budget period (monthly / quarterly / project):\n- Total budget:\n- Fixed costs:\n- Variable costs:\n- Goal (save money / control spending / save for something):\n\nOutput:\n1) Suggested categories + ratios\n2) A category budget table (copy-paste friendly)\n3) Overrun rules + adjustment plan\n4) Weekly/monthly review checklist" } } + }, + "subagent": { + "showingLines": "Showing {{shown}} of {{total}} lines", + "showAll": "Show all" } } diff --git a/src/web-ui/src/locales/en-US/scenes/skills.json b/src/web-ui/src/locales/en-US/scenes/skills.json index 1c300ee54..3687bd097 100644 --- a/src/web-ui/src/locales/en-US/scenes/skills.json +++ b/src/web-ui/src/locales/en-US/scenes/skills.json @@ -36,7 +36,8 @@ }, "toolbar": { "searchPlaceholder": "Search skills...", - "addTooltip": "Add Skill" + "addTooltip": "Add Skill", + "hideDuplicates": "Duplicates" }, "filters": { "all": "All", diff --git a/src/web-ui/src/locales/zh-CN/components.json b/src/web-ui/src/locales/zh-CN/components.json index 48dcb07f2..e60533c9c 100644 --- a/src/web-ui/src/locales/zh-CN/components.json +++ b/src/web-ui/src/locales/zh-CN/components.json @@ -46,7 +46,8 @@ "openInExplorer": "在资源管理器中打开", "openInBrowser": "通过浏览器打开", "openInBuiltInBrowser": "通过内置浏览器打开", - "copyLink": "复制链接" + "copyLink": "复制链接", + "copyFilePath": "复制文件路径" }, "mermaidBlock": { "renderFailed": "图表渲染失败", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 28f02a6af..6c09614d8 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -386,7 +386,7 @@ "close": "关闭", "minimize": "收起", "restore": "打开 {{label}}", - "fixAndReviewRunning": "正在修复并准备重新审核...", + "fixAndReviewRunning": "正在修复\u0026准备重审...", "minimizedDeep": "深度审核", "minimizedStandard": "代码审核", "minimizedFix": "正在修复", @@ -400,6 +400,7 @@ "customInstructionsPlaceholder": "描述修复的额外要求或上下文信息...", "fillBackInput": "填入输入框", "fillBackInputHint": "将选中的修复计划填入输入框,供手动编辑", + "fixCompletedMessage": "所有修复已成功应用。", "replaceInputConfirmTitle": "替换当前输入?", "replaceInputConfirmMessage": "输入框中已有文本,填入计划将替换当前草稿内容。", "replaceInputConfirmAction": "替换输入", @@ -1004,11 +1005,16 @@ "toolBatchComplete": "工具批次完成", "manualTrigger": "手动触发", "autoTrigger": "自动触发", + "afterAiResponse": "AI 回复后", "contextCompressionFailed": "上下文压缩失败", "contextCompression": "上下文压缩:", - "localFallbackHeader": "模型请求异常,已改用本地结构化裁剪", + "localFallbackHeader": "模型请求异常,改用本地压缩", "localFallbackNotice": "本次压缩无法使用模型生成总结,因此改用了本地结构化裁剪。", - "noSummaryNotice": "本次压缩没有生成额外的总结内容。" + "noSummaryNotice": "本次压缩没有生成额外的总结内容。", + "tokenChange": "{{before}} → {{after}} tokens", + "savingsTag": "节省 {{saved}} · 压缩率 {{ratio}}%", + "compressingContext": "正在压缩上下文...", + "compressionCount": "第 {{count}} 次压缩" }, "globSearch": { "parsingPattern": "解析搜索模式中...", @@ -1236,11 +1242,14 @@ "noSelectionHint": "至少选择一项修复计划后才能开始修复。", "ungrouped": "其他", "startFix": "开始修复", - "fixAndReview": "修复后重新审核", + "fixAndReview": "修复\\u0026重审", "cancel": "取消", "fixUnavailable": "无法开始修复:审核会话不可用。", "fixRequestDisplay": "开始修复深度审核问题", - "fixAndReviewRequestDisplay": "修复深度审核问题并重新审核" + "fixAndReviewRequestDisplay": "修复深度审核问题并重新审核", + "recommended": "推荐", + "expandOptions": "查看选项", + "collapseOptions": "收起选项" }, "riskLevels": { "low": "低风险", @@ -1437,5 +1446,9 @@ "prompt": "请帮我做一个预算规划。\n\n背景:\n- 预算周期(按月/按季度/按项目):\n- 总预算:\n- 固定支出:\n- 可变支出:\n- 目标(省钱/更可控/为某目标攒钱):\n\n请输出:\n1) 预算分类与比例建议\n2) 具体到类别的预算表(可复制到表格)\n3) 超支预案与调整规则\n4) 每周/每月复盘清单" } } + }, + "subagent": { + "showingLines": "当前仅显示 {{shown}} / {{total}} 行", + "showAll": "显示全部" } } diff --git a/src/web-ui/src/locales/zh-CN/scenes/skills.json b/src/web-ui/src/locales/zh-CN/scenes/skills.json index 52c908918..106ad1f55 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/skills.json +++ b/src/web-ui/src/locales/zh-CN/scenes/skills.json @@ -36,7 +36,8 @@ }, "toolbar": { "searchPlaceholder": "搜索技能...", - "addTooltip": "添加技能" + "addTooltip": "添加技能", + "hideDuplicates": "重复项" }, "filters": { "all": "全部", diff --git a/src/web-ui/src/locales/zh-TW/components.json b/src/web-ui/src/locales/zh-TW/components.json index e6b6a9284..e96556deb 100644 --- a/src/web-ui/src/locales/zh-TW/components.json +++ b/src/web-ui/src/locales/zh-TW/components.json @@ -46,7 +46,8 @@ "openInExplorer": "在檔案總管中開啟", "openInBrowser": "透過瀏覽器開啟", "openInBuiltInBrowser": "透過內建瀏覽器開啟", - "copyLink": "複製連結" + "copyLink": "複製連結", + "copyFilePath": "複製檔案路徑" }, "mermaidBlock": { "renderFailed": "圖表渲染失敗", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 85344b74e..2d1aac44c 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -377,7 +377,7 @@ "close": "關閉", "minimize": "收起", "restore": "打開 {{label}}", - "fixAndReviewRunning": "正在修復並準備重新審核...", + "fixAndReviewRunning": "正在修復\u0026準備重審...", "minimizedDeep": "深度審核", "minimizedStandard": "程式碼審核", "minimizedFix": "正在修復", @@ -391,6 +391,7 @@ "customInstructionsPlaceholder": "描述修復的額外要求或上下文信息...", "fillBackInput": "填入輸入框", "fillBackInputHint": "將選中的修復計劃填入輸入框,供手動編輯", + "fixCompletedMessage": "所有修復已成功應用。", "replaceInputConfirmTitle": "替換當前輸入?", "replaceInputConfirmMessage": "輸入框中已有文本,填入計劃將替換當前草稿內容。", "replaceInputConfirmAction": "替換輸入", @@ -959,11 +960,16 @@ "toolBatchComplete": "工具批次完成", "manualTrigger": "手動觸發", "autoTrigger": "自動觸發", + "afterAiResponse": "AI 回覆後", "contextCompressionFailed": "上下文壓縮失敗", "contextCompression": "上下文壓縮:", - "localFallbackHeader": "模型請求異常,已改用本地結構化裁剪", + "localFallbackHeader": "模型請求異常,改用本地壓縮", "localFallbackNotice": "本次壓縮無法使用模型生成總結,因此改用了本地結構化裁剪。", - "noSummaryNotice": "本次壓縮沒有生成額外的總結內容。" + "noSummaryNotice": "本次壓縮沒有生成額外的總結內容。", + "tokenChange": "{{before}} → {{after}} tokens", + "savingsTag": "節省 {{saved}} · 壓縮率 {{ratio}}%", + "compressingContext": "正在壓縮上下文...", + "compressionCount": "第 {{count}} 次壓縮" }, "globSearch": { "parsingPattern": "解析搜索模式中...", @@ -1191,11 +1197,14 @@ "noSelectionHint": "至少選擇一項修復計劃後才能開始修復。", "ungrouped": "其他", "startFix": "開始修復", - "fixAndReview": "修復後重新審核", + "fixAndReview": "修復\\u0026重審", "cancel": "取消", "fixUnavailable": "無法開始修復:審核會話不可用。", "fixRequestDisplay": "開始修復深度審核問題", - "fixAndReviewRequestDisplay": "修復深度審核問題並重新審核" + "fixAndReviewRequestDisplay": "修復深度審核問題並重新審核", + "recommended": "推薦", + "expandOptions": "查看選項", + "collapseOptions": "收起選項" }, "riskLevels": { "low": "低風險", @@ -1392,5 +1401,9 @@ "prompt": "請幫我做一個預算規劃。\n\n背景:\n- 預算週期(按月/按季度/按項目):\n- 總預算:\n- 固定支出:\n- 可變支出:\n- 目標(省錢/更可控/為某目標攢錢):\n\n請輸出:\n1) 預算分類與比例建議\n2) 具體到類別的預算表(可複製到表格)\n3) 超支預案與調整規則\n4) 每週/每月覆盤清單" } } + }, + "subagent": { + "showingLines": "當前僅顯示 {{shown}} / {{total}} 行", + "showAll": "顯示全部" } } diff --git a/src/web-ui/src/locales/zh-TW/scenes/skills.json b/src/web-ui/src/locales/zh-TW/scenes/skills.json index fa5b876f8..33e7718ec 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/skills.json +++ b/src/web-ui/src/locales/zh-TW/scenes/skills.json @@ -36,7 +36,8 @@ }, "toolbar": { "searchPlaceholder": "搜索技能...", - "addTooltip": "添加技能" + "addTooltip": "添加技能", + "hideDuplicates": "重複項" }, "filters": { "all": "全部",