From 070a6ce0d782a2e41318ba4d4e6d09de1d25a7c7 Mon Sep 17 00:00:00 2001 From: dolphin Date: Thu, 18 Jun 2026 14:29:02 +0800 Subject: [PATCH 1/2] fix(knowledge): re-entering a visited folder no longer falls back to root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The URL-folder sync effect guarded folder entry behind a consumedFolderIdRef: a folder id was marked consumed on first entry and never cleared on navigating back to root. Re-navigating to that same folder then hit the else branch and reset the content pane to the space root while the URL stayed on /folder/ — the route changed but the view didn't, and the folder became un-enterable (most visible from the sidebar tree, which can target already-visited folders). Drop the guard and sync currentFolderId to the URL folder id directly; the URL is the single source of truth and the effect only runs when it changes. --- .../src/pages/knowledge/hooks/useFileManager.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts b/src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts index 016d982f0..b8c402a0a 100644 --- a/src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts +++ b/src/frontend/client/src/pages/knowledge/hooks/useFileManager.ts @@ -200,9 +200,6 @@ export function useFileManager({ activeSpace, initialFolderId, enabled = true }: setTotal(files.length + (hasMore ? 1 : 0)); }, [files.length, hasMore]); - // Track which initialFolderId has been consumed (value, not boolean) - // so re-navigation to a different folder deep link works correctly. - const consumedFolderIdRef = useRef(undefined); // Bumped on space switch to guarantee the filter effect fires even when // search state was already empty (no dep change otherwise). const [reloadToken, setReloadToken] = useState(0); @@ -215,9 +212,12 @@ export function useFileManager({ activeSpace, initialFolderId, enabled = true }: setSearchTagIds([]); setStatusFilter([]); - // If there's an unconsumed initial folder from URL, navigate there - if (initialFolderId && consumedFolderIdRef.current !== initialFolderId) { - consumedFolderIdRef.current = initialFolderId; + // The URL folder id is the single source of truth — sync the content + // pane to it on every change. A previous "consumed" guard skipped + // re-entering an already-visited folder (its id was still marked + // consumed), which left the URL on /folder/ while the pane reset + // to the space root — folders became un-enterable from the sidebar tree. + if (initialFolderId) { setCurrentFolderId(initialFolderId); // Fetch the breadcrumb path for the URL folder. The API now // includes the folder itself as the leaf (with its real name), From 3ceecea6403b4f9fac4218ab6af999ad71136871 Mon Sep 17 00:00:00 2001 From: Kinyoo Date: Thu, 18 Jun 2026 15:00:07 +0800 Subject: [PATCH 2/2] style(client): animate task-mode workspace panel open/close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync the header workspace entry button with the panel's open state — keep it mounted and collapse width/opacity (with a delayed reappear on close) instead of hard-toggling, so it tracks the panel animation. Polish ChatView / WorkspacePanel and Linsight execution rows (clarify, subagent, step) and add the supporting tailwind animation config. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/Chat/AiChatMessages.tsx | 3 +- .../client/src/components/Chat/ChatView.tsx | 94 +++++++++++++++---- .../src/components/Chat/HeaderTitle.tsx | 28 +++++- .../Linsight/Artifacts/ResultSection.tsx | 2 +- .../Linsight/Artifacts/WorkspacePanel.tsx | 14 +-- .../Linsight/Execution/ClarifyCard.tsx | 27 +++--- .../Linsight/Execution/ExecutionFlow.tsx | 8 +- .../components/Linsight/Execution/StepRow.tsx | 16 ++-- .../Linsight/Execution/SubagentRow.tsx | 62 ++++++++++-- .../Linsight/Execution/TaskTurnPanel.tsx | 11 +-- src/frontend/client/tailwind.config.cjs | 6 ++ 11 files changed, 195 insertions(+), 76 deletions(-) diff --git a/src/frontend/client/src/components/Chat/AiChatMessages.tsx b/src/frontend/client/src/components/Chat/AiChatMessages.tsx index 7acd900cb..26a44e8c9 100644 --- a/src/frontend/client/src/components/Chat/AiChatMessages.tsx +++ b/src/frontend/client/src/components/Chat/AiChatMessages.tsx @@ -330,7 +330,8 @@ export default function AiChatMessages({ hideShare={hideShare} conversation={{ title: headerTitleText, flowId: "", conversationId, flowType: 15 }} onOpenWorkspace={onOpenWorkspace} - hasWorkspaceFiles={hasWorkspaceFiles && !workspaceOpen} + hasWorkspaceFiles={hasWorkspaceFiles} + workspaceOpen={workspaceOpen} /> )}
{ + if (taskArtifacts.fullscreen) { + setFsMounted(true); + // Double rAF: let the collapsed state paint before flipping to expanded, + // otherwise React can batch both and the enter transition is skipped. + let r2 = 0; + const r1 = requestAnimationFrame(() => { + r2 = requestAnimationFrame(() => setFsExpanded(true)); + }); + return () => { + cancelAnimationFrame(r1); + cancelAnimationFrame(r2); + }; + } + setFsExpanded(false); + return undefined; + }, [taskArtifacts.fullscreen]); + const taskLinsight = latestTaskVersionId ? getLinsight(latestTaskVersionId) : null; const taskWorkspaceFiles = useMemo(() => { const uploaded = toUploadedArtifacts(taskLinsight?.files as any[]); @@ -454,7 +481,7 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? isStreaming={isStreaming} shareToken={shareToken} knowledgeChatLayout - contentWidthClassName="w-full max-w-[800px] mx-auto px-4 touch-mobile:max-w-full" + contentWidthClassName="w-full max-w-[800px] mx-auto px-3 touch-mobile:max-w-full" onRegenerate={regenerate} onOpenCitationPanel={onOpenCitationPanel} activeCitationMessageId={activeCitationMessageId} @@ -531,19 +558,36 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? {/* F035: inline workspace panel docked to the right of the chat main for the latest task turn. Opens from the header entry button; the file preview renders in place. Fullscreen is a - separate overlay (below) covering the whole route viewport. */} - {latestTaskVersionId && taskArtifacts.open && !taskArtifacts.fullscreen && ( -
- + separate overlay (below) covering the whole route viewport. + + Kept mounted whenever a task turn exists (gated only on + !fullscreen) so open/close animates the wrapper's width + + opacity instead of hard mount/unmount. Width uses an inline + clamp() (not min/max-w classes) so it interpolates smoothly + 0px → 46% — min-width doesn't transition and would otherwise + snap to 440px on the first frame. The inner box keeps a + min-width so the panel content slides/clips rather than + reflowing while it collapses. */} + {latestTaskVersionId && !taskArtifacts.fullscreen && ( +
+
+ +
)} @@ -628,9 +672,25 @@ const ChatView = ({ id = '', index = 0, shareToken = '' }: { id?: string, index? viewport (chatContainerRef is relative + un-clipped), flush to the edges with no padding. Covers the chat (incl. its header) but not the browser, so the global nav stays. Separate instance from the inline - panel above (toggling fullscreen remounts the preview). */} - {latestTaskVersionId && taskArtifacts.open && taskArtifacts.fullscreen && ( -
+ panel above (only one is ever mounted, so the preview never double + fetches). Scale + opacity transition from the right (where the docked + panel sits) so expanding/collapsing fullscreen animates smoothly + instead of hard mount/unmount; unmounts on transitionEnd after the + collapse so the exit plays too. */} + {latestTaskVersionId && fsMounted && ( +
{ + // Unmount only after the collapse finishes (ignore the expand end + // and bubbled child transitions). + if (e.target === e.currentTarget && e.propertyName === 'transform' && !taskArtifacts.fullscreen) { + setFsMounted(false); + } + }} + > void; hasWorkspaceFiles?: boolean; + /** Workspace panel open state — drives the entry button's fade so it stays + in sync with the panel's open/close animation instead of hard-toggling. */ + workspaceOpen?: boolean; }) { const localize = useLocalize(); const { pathname } = useLocation(); @@ -32,7 +36,11 @@ export default function HeaderTitle({ conversation, readOnly, hideShare = false, } return ( -
+
{/* Left placeholder to balance the center layout */}
@@ -53,16 +61,26 @@ export default function HeaderTitle({ conversation, readOnly, hideShare = false, /> )} {/* F035: task-mode workspace entry — opens the drawer of uploaded sources + - generated deliverables for the latest task turn (ChatView owns the drawer). */} + generated deliverables for the latest task turn (ChatView owns the drawer). + Kept mounted and faded by `workspaceOpen` (not mount/unmounted) so it + tracks the panel's open/close animation. On open it fades out instantly + (the panel covers it); on close it waits `delay-150` so it reappears only + after the panel has mostly collapsed — avoids the button flashing in + mid-animation. */} {hasWorkspaceFiles && onOpenWorkspace && ( )}
diff --git a/src/frontend/client/src/components/Linsight/Artifacts/ResultSection.tsx b/src/frontend/client/src/components/Linsight/Artifacts/ResultSection.tsx index eebfe2cf0..ade3f52cb 100644 --- a/src/frontend/client/src/components/Linsight/Artifacts/ResultSection.tsx +++ b/src/frontend/client/src/components/Linsight/Artifacts/ResultSection.tsx @@ -43,7 +43,7 @@ export function ResultSection({ answer, files, versionId, onPreview }: ResultSec {/* answer summary, markdown rendered */} {answer && ( -
+
)} diff --git a/src/frontend/client/src/components/Linsight/Artifacts/WorkspacePanel.tsx b/src/frontend/client/src/components/Linsight/Artifacts/WorkspacePanel.tsx index 47aff40e0..755a85e18 100644 --- a/src/frontend/client/src/components/Linsight/Artifacts/WorkspacePanel.tsx +++ b/src/frontend/client/src/components/Linsight/Artifacts/WorkspacePanel.tsx @@ -28,7 +28,7 @@ interface WorkspacePanelProps { } const iconBtn = - 'flex h-7 w-7 items-center justify-center rounded-md text-[#8C8C8C] transition-colors hover:bg-gray-100 hover:text-[#335CFF]'; + 'flex h-7 w-7 items-center justify-center rounded-lg text-[#8C8C8C] transition-colors hover:bg-gray-100'; export function WorkspacePanel({ files, @@ -47,7 +47,7 @@ export function WorkspacePanel({ key={file.file_id || file.file_url} role="button" tabIndex={0} - className="group flex cursor-pointer items-center gap-2.5 rounded-lg py-2 hover:bg-[#F7F7F7]" + className="group flex cursor-pointer items-center gap-2.5 rounded-lg px-1 py-2 hover:bg-[#F7F7F7]" onClick={() => onPreview(file)} onKeyDown={(e) => e.key === 'Enter' && onPreview(file)} > @@ -81,7 +81,7 @@ export function WorkspacePanel({ {previewFile ? ( <> {/* preview toolbar */} -
+
@@ -115,18 +115,18 @@ export function WorkspacePanel({ ) : ( <> {/* list header */} -
+
{localize('com_linsight_workspace')}
- {/* list body — overall padding 16px, 16px between rows */} -
+ {/* list body — 4px top, 16px bottom, 12px left/right; 16px between rows */} +
{files.length ? (
{files.map(renderRow)}
) : ( -
+
{localize('com_linsight_workspace_empty')}
)} diff --git a/src/frontend/client/src/components/Linsight/Execution/ClarifyCard.tsx b/src/frontend/client/src/components/Linsight/Execution/ClarifyCard.tsx index 9adde06a3..c9cdd95e1 100644 --- a/src/frontend/client/src/components/Linsight/Execution/ClarifyCard.tsx +++ b/src/frontend/client/src/components/Linsight/Execution/ClarifyCard.tsx @@ -131,7 +131,7 @@ export function ClarifyCard({ data, disabled = false, onSubmit }: ClarifyCardPro return (
@@ -154,7 +154,7 @@ export function ClarifyCard({ data, disabled = false, onSubmit }: ClarifyCardPro {/* Body: Current question */} {q ? ( -
+
{q.question} @@ -174,7 +174,7 @@ export function ClarifyCard({ data, disabled = false, onSubmit }: ClarifyCardPro type="button" disabled={page === 0} onClick={() => setPage(page - 1)} - className="rounded-full p-1 hover:bg-gray-100/80 disabled:opacity-30 transition-colors" + className="rounded-md p-1 hover:bg-gray-100/80 disabled:opacity-30 transition-colors" > @@ -185,14 +185,14 @@ export function ClarifyCard({ data, disabled = false, onSubmit }: ClarifyCardPro type="button" disabled={page === questions.length - 1} onClick={() => setPage(page + 1)} - className="rounded-full p-1 hover:bg-gray-100/80 disabled:opacity-30 transition-colors" + className="rounded-md p-1 hover:bg-gray-100/80 disabled:opacity-30 transition-colors" >
)}
-
    +
      {q.options.map((option, i) => { const active = selected.includes(option); const { title: optTitle, desc: optDesc } = parseOption(option); @@ -203,9 +203,9 @@ export function ClarifyCard({ data, disabled = false, onSubmit }: ClarifyCardPro disabled={disabled || submitted} onClick={() => handleSelect(q, option)} className={cn( - 'flex w-full items-start gap-2 rounded-xl px-4 py-2.5 text-left text-sm transition-all duration-200 select-none border-0', + 'flex h-9 w-full items-center gap-2 rounded-lg px-4 text-left text-sm transition-all duration-200 select-none border-0', active - ? 'bg-[#EDF2FF] text-[#335CFF] font-medium shadow-[0_2px_8px_rgba(51,92,255,0.08)]' + ? 'bg-[#EEE] text-[#212121] font-medium' : 'text-[#1A1A1A] hover:bg-gray-50/80', )} > @@ -213,12 +213,12 @@ export function ClarifyCard({ data, disabled = false, onSubmit }: ClarifyCardPro
      {optTitle} {optDesc && ( - + {optDesc} )}
      - {active && } + {active && } ); @@ -226,7 +226,12 @@ export function ClarifyCard({ data, disabled = false, onSubmit }: ClarifyCardPro {/* Trailing "type your own" entry: inline input */}
    • {q.options.length + 1}. diff --git a/src/frontend/client/src/components/Linsight/Execution/ExecutionFlow.tsx b/src/frontend/client/src/components/Linsight/Execution/ExecutionFlow.tsx index 482bfd08a..b144615ed 100644 --- a/src/frontend/client/src/components/Linsight/Execution/ExecutionFlow.tsx +++ b/src/frontend/client/src/components/Linsight/Execution/ExecutionFlow.tsx @@ -227,14 +227,8 @@ export function ExecutionFlow({ versionId, conversationId, isSharePage = false,
- {/* ── footer: waiting hint + task panel + unified input ─────────── */} + {/* ── footer: task panel + unified input ────────────────────────── */}
- {pendingInput && ( -
- - {localize('com_linsight_waiting_your_input')} -
- )} {/* Design (Figma 12221-40080/40081): card inset 24px each side relative to the input, 12px gap above it. */}
diff --git a/src/frontend/client/src/components/Linsight/Execution/StepRow.tsx b/src/frontend/client/src/components/Linsight/Execution/StepRow.tsx index af0b93a38..99f60882c 100644 --- a/src/frontend/client/src/components/Linsight/Execution/StepRow.tsx +++ b/src/frontend/client/src/components/Linsight/Execution/StepRow.tsx @@ -14,16 +14,16 @@ export function RunningSpinner() { } /** Map a step/tool name to its leading icon (completed state). */ -export function stepTypeIcon(name?: string) { +export function stepTypeIcon(name?: string, size = 16) { const n = (name || '').toLowerCase(); const cls = 'text-[#333]'; - if (/agent|subagent/.test(n)) return ; - if (/knowledge|knowledge_base|space|retrieval|recall|检索|知识/.test(n)) return ; - if (/think|reason|思考/.test(n)) return ; - if (/research|调研/.test(n)) return ; - if (/web[_\s-]?search|websearch|联网|网页|网络搜索/.test(n)) return ; - if (/write|edit|撰写|编写|写入/.test(n)) return ; - return ; // default + if (/agent|subagent/.test(n)) return ; + if (/knowledge|knowledge_base|space|retrieval|recall|检索|知识/.test(n)) return ; + if (/think|reason|思考/.test(n)) return ; + if (/research|调研/.test(n)) return ; + if (/web[_\s-]?search|websearch|联网|网页|网络搜索/.test(n)) return ; + if (/write|edit|撰写|编写|写入/.test(n)) return ; + return ; // default } /** Shared style for expanded detail text blocks. */ diff --git a/src/frontend/client/src/components/Linsight/Execution/SubagentRow.tsx b/src/frontend/client/src/components/Linsight/Execution/SubagentRow.tsx index 3c8985d5e..ed1c1287c 100644 --- a/src/frontend/client/src/components/Linsight/Execution/SubagentRow.tsx +++ b/src/frontend/client/src/components/Linsight/Execution/SubagentRow.tsx @@ -5,11 +5,28 @@ * called inside that agent's namespace; a finished card shows "called N tools". */ import { Outlined } from 'bisheng-icons'; -import { Check, Recycle } from 'lucide-react'; +import { Check } from 'lucide-react'; +import type { CSSProperties } from 'react'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; -import { StepRow } from './StepRow'; -import type { MergedStep, SubagentGroup } from './stepUtils'; +import { StepRow, stepTypeIcon } from './StepRow'; +import type { SubagentGroup } from './stepUtils'; + +/** Dotted base texture, reused verbatim from the clarification card + * (ClarifyCard): a 5px-tiled SVG with a faint #EAEEFF dot. */ +const DOT_BG: CSSProperties = { + backgroundColor: '#FFFFFF', + backgroundImage: + 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' width=\'5\' height=\'5\'%3E%3Ccircle cx=\'0.5\' cy=\'0.5\' r=\'0.5\' fill=\'%23EAEEFF\'/%3E%3C/svg%3E")', + backgroundSize: '5px 5px', +}; +/** Diagonal glint overlaid on the dotted base (Figma 12221:40064). A soft grey + * streak on its own layer; the `animate-sheen-sweep` keyframe slides it across + * the card (clipped by the card's overflow-hidden) so the gradient flows. A + * plain white streak is invisible on the near-white card, so it carries a light + * grey cast to read as motion. */ +const SHEEN = + 'linear-gradient(120deg, transparent 0%, rgba(140,140,140,0.025) 28%, rgba(140,140,140,0.09) 50%, rgba(140,140,140,0.025) 72%, transparent 100%)'; function SubagentCard({ agent }: { agent: SubagentGroup['agents'][number] }) { const localize = useLocalize(); @@ -18,16 +35,41 @@ function SubagentCard({ agent }: { agent: SubagentGroup['agents'][number] }) { const calledCount = children.length; const currentTool = [...children].reverse().find((c) => c.running) || children[children.length - 1]; + // the dotted texture stays in both states; the sweeping sheen is the + // "in-progress" treatment only and stops once the agent finishes. + const running = step.running; + return ( -
-
- - {step.name} -
-
+
+ {/* diagonal glint sweeping above the dots; semi-transparent so the + dot texture still reads through. The static mask fades the streak + near the card's L/R edges, so the overflow-hidden clip lands in an + already-transparent zone instead of cutting the band into a hard + vertical line. Only while the agent is running. */} + {running && ( +
+
+
+ )} + {/* circular white badge carrying the agent's type icon */} + + {stepTypeIcon(step.name, 14)} + + {step.name} +
{step.running ? ( <> - + {currentTool?.name || localize('com_linsight_subagent_running')} ) : ( diff --git a/src/frontend/client/src/components/Linsight/Execution/TaskTurnPanel.tsx b/src/frontend/client/src/components/Linsight/Execution/TaskTurnPanel.tsx index c83d75f19..0bdca5e50 100644 --- a/src/frontend/client/src/components/Linsight/Execution/TaskTurnPanel.tsx +++ b/src/frontend/client/src/components/Linsight/Execution/TaskTurnPanel.tsx @@ -204,13 +204,6 @@ export function TaskTurnPanel({ versionId, conversationId, answer, readOnly = fa
)} - {/* waiting-for-input hint */} - {pendingInput && ( -
- - {localize('com_linsight_waiting_your_input')} -
- )} {/* task checklist progress is rendered by pinned above the input (ChatView) — not inline in the message stream. */} @@ -221,8 +214,8 @@ export function TaskTurnPanel({ versionId, conversationId, answer, readOnly = fa synthesizes get_final_result_file / the report), so the user does not mistake an in-progress task for a finished one. */} {running && !queueing && !planning && !pendingInput && ( -
- +
+ {localize('com_linsight_generating')}
)} diff --git a/src/frontend/client/tailwind.config.cjs b/src/frontend/client/tailwind.config.cjs index 682df5d01..b0a18988a 100644 --- a/src/frontend/client/tailwind.config.cjs +++ b/src/frontend/client/tailwind.config.cjs @@ -40,10 +40,16 @@ module.exports = { '0%': { transform: 'translateX(-100%)' }, '100%': { transform: 'translateX(400%)' }, }, + // diagonal glint sweeping continuously across the subagent card + 'sheen-sweep': { + '0%': { transform: 'translateX(-130%)' }, + '100%': { transform: 'translateX(130%)' }, + }, }, animation: { 'fade-in': 'fadeIn 0.5s ease-out forwards', 'crawl-slide': 'crawl-slide 1.4s linear infinite', + 'sheen-sweep': 'sheen-sweep 2s linear infinite', 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', 'pulse-scale': 'pulse-scale 1s ease-in-out infinite',