mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-02 16:51:13 +08:00
Merge pull request #45 from 706412584/worktree-solo-council-collapse-approval
feat(desktop): polish chat UI visuals and Solo Council panel
This commit is contained in:
commit
7ef95ef552
@ -223,9 +223,10 @@ function CodeArea({ code, language, showLineNumbers }: { code: string; language?
|
|||||||
<div
|
<div
|
||||||
data-code-viewer-content=""
|
data-code-viewer-content=""
|
||||||
data-highlight-engine="shiki"
|
data-highlight-engine="shiki"
|
||||||
|
className="transition-opacity duration-150 ease-in-out"
|
||||||
style={
|
style={
|
||||||
loaded
|
loaded
|
||||||
? { padding: CODE_AREA_PADDING }
|
? { padding: CODE_AREA_PADDING, opacity: 1 }
|
||||||
: {
|
: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
|
|||||||
@ -93,7 +93,7 @@ const diffStyles = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
diffContainer: {
|
diffContainer: {
|
||||||
borderRadius: '0',
|
borderRadius: '0 0 var(--radius-lg) var(--radius-lg)',
|
||||||
fontSize: '12px',
|
fontSize: '12px',
|
||||||
lineHeight: '1.45',
|
lineHeight: '1.45',
|
||||||
fontFamily: 'var(--font-mono)',
|
fontFamily: 'var(--font-mono)',
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import React, { useMemo, useState } from 'react'
|
||||||
import { useShallow } from 'zustand/react/shallow'
|
import { useShallow } from 'zustand/react/shallow'
|
||||||
import { useTranslation, type TranslationKey } from '../../i18n'
|
import { useTranslation, type TranslationKey } from '../../i18n'
|
||||||
import { useChatStore } from '../../stores/chatStore'
|
import { useChatStore } from '../../stores/chatStore'
|
||||||
@ -304,7 +304,7 @@ export function SoloCouncilPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={compact ? 'mt-2' : 'mt-3'} data-testid="solo-council-panel">
|
<div className={compact ? 'mt-2' : 'mt-3'} data-testid="solo-council-panel">
|
||||||
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-3 shadow-sm">
|
<div className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-3 shadow-md">
|
||||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
|
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||||
@ -346,9 +346,9 @@ export function SoloCouncilPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!collapsed ? (
|
{!collapsed ? (
|
||||||
<div id="solo-council-panel-body" data-testid="solo-council-panel-body">
|
<div id="solo-council-panel-body" data-testid="solo-council-panel-body" className="fade-in">
|
||||||
<SoloCouncilFlow rows={rows} hasSynthesis={Boolean(synthesis)} />
|
<SoloCouncilFlow rows={rows} hasSynthesis={Boolean(synthesis)} />
|
||||||
<div className="grid gap-2 md:grid-cols-3">
|
<div className="grid gap-2 grid-cols-1 sm:grid-cols-3">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<SoloCouncilCard key={`${row.role}-${row.task?.taskId ?? `standby-${row.role}`}`} row={row} />
|
<SoloCouncilCard key={`${row.role}-${row.task?.taskId ?? `standby-${row.role}`}`} row={row} />
|
||||||
))}
|
))}
|
||||||
@ -408,7 +408,7 @@ function SoloCouncilCard({ row }: { row: SoloCouncilRow }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={`solo-council-card-${row.role}`}
|
data-testid={`solo-council-card-${row.role}`}
|
||||||
className={`min-w-0 rounded-[var(--radius-md)] border px-3 py-2.5 ${tone.className}`}
|
className={`min-w-0 rounded-[var(--radius-md)] border px-3 py-2.5 transition-colors duration-300 ${tone.className}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@ -483,15 +483,54 @@ function SoloCouncilStructuredReview({ row, artifact }: { row: SoloCouncilRow; a
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSynthesisText(text: string) {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
const elements: React.ReactNode[] = []
|
||||||
|
let listItems: string[] = []
|
||||||
|
let listKey = 0
|
||||||
|
|
||||||
|
const flushList = () => {
|
||||||
|
if (listItems.length > 0) {
|
||||||
|
elements.push(
|
||||||
|
<ul key={`list-${listKey++}`} className="mt-1 list-disc space-y-0.5 pl-4">
|
||||||
|
{listItems.map((item, i) => <li key={i}>{item}</li>)}
|
||||||
|
</ul>,
|
||||||
|
)
|
||||||
|
listItems = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (trimmed.startsWith('## ')) {
|
||||||
|
flushList()
|
||||||
|
elements.push(<h4 key={`h-${elements.length}`} className="mt-2 text-[12px] font-semibold text-[var(--color-text-primary)]">{trimmed.slice(3)}</h4>)
|
||||||
|
} else if (trimmed.startsWith('# ')) {
|
||||||
|
flushList()
|
||||||
|
elements.push(<h4 key={`h-${elements.length}`} className="mt-2 text-[12px] font-bold text-[var(--color-text-primary)]">{trimmed.slice(2)}</h4>)
|
||||||
|
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||||
|
listItems.push(trimmed.slice(2))
|
||||||
|
} else if (trimmed === '') {
|
||||||
|
flushList()
|
||||||
|
} else {
|
||||||
|
flushList()
|
||||||
|
elements.push(<p key={`p-${elements.length}`}>{trimmed}</p>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flushList()
|
||||||
|
return elements
|
||||||
|
}
|
||||||
|
|
||||||
function SoloCouncilSynthesis({ text }: { text: string }) {
|
function SoloCouncilSynthesis({ text }: { text: string }) {
|
||||||
const t = useTranslation()
|
const t = useTranslation()
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const canExpand = text.length > SOLO_COUNCIL_SYNTHESIS_COLLAPSE_THRESHOLD
|
const canExpand = text.length > SOLO_COUNCIL_SYNTHESIS_COLLAPSE_THRESHOLD
|
||||||
|
const renderedContent = useMemo(() => renderSynthesisText(text), [text])
|
||||||
const outputClassName = canExpand
|
const outputClassName = canExpand
|
||||||
? expanded
|
? expanded
|
||||||
? 'mt-2 max-h-80 overflow-auto whitespace-pre-wrap break-words rounded-[var(--radius-sm)] border border-[var(--color-border)]/60 bg-[var(--color-surface-container-lowest)]/50 p-2 text-[11px] leading-relaxed text-[var(--color-text-secondary)]'
|
? 'mt-2 max-h-80 overflow-auto rounded-[var(--radius-sm)] border border-[var(--color-border)]/60 bg-[var(--color-surface-container-lowest)]/50 p-2 text-[11px] leading-relaxed text-[var(--color-text-secondary)]'
|
||||||
: 'mt-2 line-clamp-5 whitespace-pre-wrap break-words text-[11px] leading-relaxed text-[var(--color-text-secondary)]'
|
: 'mt-2 line-clamp-[8] text-[11px] leading-relaxed text-[var(--color-text-secondary)]'
|
||||||
: 'mt-2 whitespace-pre-wrap break-words text-[11px] leading-relaxed text-[var(--color-text-secondary)]'
|
: 'mt-2 text-[11px] leading-relaxed text-[var(--color-text-secondary)]'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="solo-council-synthesis" className="mt-3 rounded-[var(--radius-md)] border border-[var(--color-primary)]/25 bg-[var(--color-primary)]/6 px-3 py-2.5">
|
<div data-testid="solo-council-synthesis" className="mt-3 rounded-[var(--radius-md)] border border-[var(--color-primary)]/25 bg-[var(--color-primary)]/6 px-3 py-2.5">
|
||||||
@ -504,7 +543,7 @@ function SoloCouncilSynthesis({ text }: { text: string }) {
|
|||||||
{t('soloCouncil.synthesis.approvalHint')}
|
{t('soloCouncil.synthesis.approvalHint')}
|
||||||
</div>
|
</div>
|
||||||
<div id="solo-council-synthesis-output" data-testid="solo-council-synthesis-output" className={outputClassName}>
|
<div id="solo-council-synthesis-output" data-testid="solo-council-synthesis-output" className={outputClassName}>
|
||||||
{text}
|
{renderedContent}
|
||||||
</div>
|
</div>
|
||||||
{canExpand ? (
|
{canExpand ? (
|
||||||
<button
|
<button
|
||||||
@ -534,7 +573,7 @@ function getFlowState(row: SoloCouncilRow | undefined): 'muted' | 'running' | 's
|
|||||||
function flowStateClassName(state: 'muted' | 'running' | 'success' | 'warning') {
|
function flowStateClassName(state: 'muted' | 'running' | 'success' | 'warning') {
|
||||||
if (state === 'success') return 'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
if (state === 'success') return 'border-[var(--color-success)]/30 bg-[var(--color-success)]/10 text-[var(--color-success)]'
|
||||||
if (state === 'warning') return 'border-[var(--color-warning)]/35 bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
|
if (state === 'warning') return 'border-[var(--color-warning)]/35 bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
|
||||||
if (state === 'running') return 'border-[var(--color-primary)]/35 bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
|
if (state === 'running') return 'border-[var(--color-primary)]/35 bg-[var(--color-primary)]/10 text-[var(--color-primary)] animate-pulse'
|
||||||
return 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]'
|
return 'border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]'
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -545,21 +584,28 @@ function getVerdictKey(verdict: SoloCouncilVerdict) {
|
|||||||
return 'soloCouncil.verdict.pending'
|
return 'soloCouncil.verdict.pending'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRoleAccent(role: SoloCouncilRole): string {
|
||||||
|
if (role === 'planner') return 'border-l-[3px] border-l-[var(--color-primary)]'
|
||||||
|
if (role === 'reviewer') return 'border-l-[3px] border-l-[var(--color-success)]'
|
||||||
|
return 'border-l-[3px] border-l-[var(--color-warning)]'
|
||||||
|
}
|
||||||
|
|
||||||
function getCardTone(row: SoloCouncilRow) {
|
function getCardTone(row: SoloCouncilRow) {
|
||||||
|
const roleAccent = getRoleAccent(row.role)
|
||||||
if (row.displayStatus === 'failed' || row.verdict === 'changes-needed') {
|
if (row.displayStatus === 'failed' || row.verdict === 'changes-needed') {
|
||||||
return {
|
return {
|
||||||
className: 'border-[var(--color-warning)]/45 bg-[var(--color-warning)]/8',
|
className: `${roleAccent} border-[var(--color-warning)]/45 bg-[var(--color-warning)]/8`,
|
||||||
badgeClassName: 'bg-[var(--color-warning)]/15 text-[var(--color-warning)]',
|
badgeClassName: 'bg-[var(--color-warning)]/15 text-[var(--color-warning)]',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (row.displayStatus === 'completed' && row.verdict !== 'pending') {
|
if (row.displayStatus === 'completed' && row.verdict !== 'pending') {
|
||||||
return {
|
return {
|
||||||
className: 'border-[var(--color-success)]/30 bg-[var(--color-success)]/7',
|
className: `${roleAccent} border-[var(--color-success)]/30 bg-[var(--color-success)]/7`,
|
||||||
badgeClassName: 'bg-[var(--color-success)]/12 text-[var(--color-success)]',
|
badgeClassName: 'bg-[var(--color-success)]/12 text-[var(--color-success)]',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
className: 'border-[var(--color-border)] bg-[var(--color-surface-container-low)]',
|
className: `${roleAccent} border-[var(--color-border)] bg-[var(--color-surface-container-low)]`,
|
||||||
badgeClassName: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]',
|
badgeClassName: 'bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)]',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,7 +103,7 @@ export function StreamingIndicator() {
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1"
|
className="mb-2 flex w-fit items-center gap-2 rounded-full border border-[var(--color-border)]/40 bg-[var(--color-surface-container-low)] px-3 py-1"
|
||||||
>
|
>
|
||||||
<span className="text-[var(--color-brand)] animate-shimmer text-xs">✦</span>
|
<span className="material-symbols-outlined text-[14px] text-[var(--color-brand)] animate-shimmer" aria-hidden="true">auto_awesome</span>
|
||||||
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{verb}...</span>
|
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{verb}...</span>
|
||||||
{elapsedSeconds > 0 && (
|
{elapsedSeconds > 0 && (
|
||||||
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
<span className="text-[10px] text-[var(--color-text-tertiary)]">
|
||||||
|
|||||||
@ -17,7 +17,6 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<style>{thinkingStyles}</style>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setExpanded((v) => !v)}
|
onClick={() => setExpanded((v) => !v)}
|
||||||
@ -51,37 +50,3 @@ export function ThinkingBlock({ content, isActive = false }: { content: string;
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const thinkingStyles = `
|
|
||||||
@keyframes thinking-cursor-blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0; }
|
|
||||||
}
|
|
||||||
@keyframes thinking-dots {
|
|
||||||
0%, 20% { content: ''; }
|
|
||||||
40% { content: '.'; }
|
|
||||||
60% { content: '..'; }
|
|
||||||
80%, 100% { content: '...'; }
|
|
||||||
}
|
|
||||||
.thinking-cursor {
|
|
||||||
display: inline-block;
|
|
||||||
width: 2px;
|
|
||||||
height: 1em;
|
|
||||||
background: var(--color-text-tertiary);
|
|
||||||
vertical-align: middle;
|
|
||||||
margin-left: 1px;
|
|
||||||
animation: thinking-cursor-blink 1s step-end infinite;
|
|
||||||
}
|
|
||||||
.thinking-dots::after {
|
|
||||||
content: '';
|
|
||||||
animation: thinking-dots 1.4s steps(1, end) infinite;
|
|
||||||
}
|
|
||||||
.thinking-markdown > :first-child,
|
|
||||||
.thinking-markdown > :first-child > :first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
.thinking-markdown > :last-child,
|
|
||||||
.thinking-markdown > :last-child > :last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|||||||
@ -115,7 +115,7 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{expandable && expanded && (
|
{expandable && expanded && (
|
||||||
<div className="space-y-2.5 border-t border-[var(--color-border)]/60 px-3 py-3">
|
<div className="fade-in space-y-2.5 border-t border-[var(--color-border)]/60 px-3 py-3">
|
||||||
{preview}
|
{preview}
|
||||||
{details}
|
{details}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1247,11 +1247,55 @@ button, input, textarea, select, a, [role="button"] {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fade-in animation for expand/appear transitions */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.fade-in {
|
||||||
|
animation: fade-in 200ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thinking block animations */
|
||||||
|
@keyframes thinking-cursor-blink {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
@keyframes thinking-dots {
|
||||||
|
0%, 20% { content: ''; }
|
||||||
|
40% { content: '.'; }
|
||||||
|
60% { content: '..'; }
|
||||||
|
80%, 100% { content: '...'; }
|
||||||
|
}
|
||||||
|
.thinking-cursor {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1em;
|
||||||
|
background: var(--color-text-tertiary);
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 1px;
|
||||||
|
animation: thinking-cursor-blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
.thinking-dots::after {
|
||||||
|
content: '';
|
||||||
|
animation: thinking-dots 1.4s steps(1, end) infinite;
|
||||||
|
}
|
||||||
|
.thinking-markdown > :first-child,
|
||||||
|
.thinking-markdown > :first-child > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.thinking-markdown > :last-child,
|
||||||
|
.thinking-markdown > :last-child > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Scrollbar */
|
/* Scrollbar */
|
||||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: var(--color-outline); border-radius: 9999px; opacity: 0.3; }
|
::-webkit-scrollbar-thumb { background: var(--color-outline); border-radius: 9999px; opacity: 0.3; }
|
||||||
::-webkit-scrollbar-thumb:hover { opacity: 0.6; }
|
::-webkit-scrollbar-thumb:hover { opacity: 0.6; }
|
||||||
|
.dark ::-webkit-scrollbar-thumb { background: var(--color-on-surface); opacity: 0.15; }
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover { opacity: 0.35; }
|
||||||
|
|
||||||
.sidebar-scroll-area {
|
.sidebar-scroll-area {
|
||||||
padding-bottom: 72px;
|
padding-bottom: 72px;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user