Studio: Chat with files X hides the panel, app tooltips, and higher-contrast traces (#13989)

## What changed

**Chat with files panel**

- **The X hides the panel instead of turning RAG off.** Retrieval stays
on and the composer keeps its RAG tag, so the chat still searches its
files. The control is now labelled "Hide files", and the knowledge base
panel's X behaves the same way.
- **Show files brings it back.** While the panel is hidden, the RAG
tag's menu leads with **Show files** and the chat's file count. Choosing
Chat with files in the + menu, picking a source, turning RAG back on, or
opening another chat also shows the panel again.
- **Header controls use the app's tooltip** (Add files, Collapse /
Expand files, Hide files) instead of the browser's `title` popup.
- **Add files is centred in its pill.** Centred by its line box, the
cap-height label sat half a pixel high; it now measures within a third
of a pixel (9.33px above, 9.67px below at 3x).

- **Header padding follows the smaller Add files pill.** The panel's top
padding drops 2px (expanded) so the 28px pill keeps its old distance
from the top edge, and in the collapsed pill view Add files sits 12px
from the top, the same as the file pills from the bottom.

The gaps between Add files, the chevron and the X were measured and are
already equal (28px from the pill edge to the chevron glyph, and from
the chevron glyph to the X glyph), so they are unchanged.

**Thinking and tool traces**

Trace ink sits one shade nearer the answer's ink: lighter in dark mode,
darker in light mode. `--trace-fade` drops from 24% to 13% (dark) and
15% to 7% (light), and inside reasoning, tool and tool-group traces the
muted text and the citation chip outlines move 15% toward the
foreground. Measured on the "Worked for" trigger: OKLab L 0.626 to 0.686
in dark mode, 0.616 to 0.580 in light mode. Hover to the foreground
still wins (the trace rule uses `:where`).

## Verification

- `npm run typecheck`, `npm run build`, full `npm test` (all pass). New
`tests/rag-panel-hide.test.ts`;
`tests/rag-thread-initialization.test.ts` stub gains the new store
fields.
- Isolated Studio: closing the panel leaves the RAG tag in the composer;
Show files (with the file count) and the + menu item bring the panel
back; padding measured in both panel states; tooltips render in the app
style; trace colors measured in both themes with a GGUF model producing
a reasoning trace.

## Not tested here

Knowledge base retrieval end to end (the KB panel's X uses the same hide
path).
This commit is contained in:
Michael Han 2026-10-16 04:12:41 -07:00 • committed by GitHub
parent 6583ae4de8
commit b0e74e7432
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 196 additions and 48 deletions

View file

@ -7114,7 +7114,15 @@ const ComposerToolsMenu: FC<{
className={
ragEnabled && !ragDisabled ? "text-primary font-medium" : undefined
}
onSelect={() => setRagEnabled(!ragEnabled)}
onSelect={() => {
// With the files panel hidden, the item brings it back rather than turning RAG off.
const { ragPanelHidden, setRagPanelHidden } = useChatRuntimeStore.getState();
if (ragEnabled && ragPanelHidden) {
setRagPanelHidden(false);
return;
}
setRagEnabled(!ragEnabled);
}}
>
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
Chat with files

View file

@ -2620,7 +2620,15 @@ export function SharedComposer({
className={
ragEnabled && !ragDisabled ? "text-primary font-medium" : undefined
}
onSelect={() => setRagEnabled(!ragEnabled)}
onSelect={() => {
// As in the main composer: a hidden files panel comes back rather than RAG turning off.
const { ragPanelHidden, setRagPanelHidden } = useChatRuntimeStore.getState();
if (ragEnabled && ragPanelHidden) {
setRagPanelHidden(false);
return;
}
setRagEnabled(!ragEnabled);
}}
>
<HugeiconsIcon icon={FileDatabaseIcon} strokeWidth={2} />
Chat with files
@ -3130,7 +3138,7 @@ export function SharedComposer({
<span>Images</span>
</button>
)}
{showRagPill && <KnowledgeBaseComposerButton side="top" />}
{showRagPill && <KnowledgeBaseComposerButton side="top" hasFilesPanel={false} />}
{showWebFetchPill && (
<button
type="button"

View file

@ -2475,6 +2475,10 @@ type ChatRuntimeStore = {
searchImages: boolean;
mcpEnabledForChat: boolean;
ragEnabled: boolean;
/** The files panel's X: hides the panel while retrieval stays on; turning RAG on or picking a source shows it again. */
ragPanelHidden: boolean;
/** Files the panel lists, for the RAG tag's Show files item while the panel is hidden. */
ragPanelFileCount: number;
ragSource: RagSource;
projectAttachmentTarget: ProjectAttachmentTarget;
/** session-only per-chat target override; leaves other chats unchanged. */
@ -2790,6 +2794,8 @@ type ChatRuntimeStore = {
clearToolConfirmation: (toolCallId: string) => void;
setWebFetchToolsEnabled: (enabled: boolean) => void;
setRagEnabled: (enabled: boolean) => void;
setRagPanelHidden: (hidden: boolean) => void;
setRagPanelFileCount: (count: number) => void;
setRememberParamsPerModel: (enabled: boolean) => void;
setRagSource: (source: RagSource) => void;
setProjectAttachmentTarget: (target: ProjectAttachmentTarget) => void;
@ -4364,6 +4370,8 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
webFetchToolsEnabled: loadBool(CHAT_WEB_FETCH_TOOLS_ENABLED_KEY, false),
// RAG is opt-in per session: always starts off, never restored from storage.
ragEnabled: false,
ragPanelHidden: false,
ragPanelFileCount: 0,
ragSource: loadRagSource(),
projectAttachmentTarget: loadProjectAttachmentTarget(),
projectAttachmentTargetByThread: {},
@ -5811,9 +5819,12 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
if (ragEnabled !== state.ragEnabled) captureThreadScopedEdit("ragEnabled");
return {
ragEnabled,
ragPanelHidden: false,
queuedSettingsEpoch: state.queuedSettingsEpoch + 1,
};
}),
setRagPanelHidden: (ragPanelHidden) => set({ ragPanelHidden }),
setRagPanelFileCount: (ragPanelFileCount) => set({ ragPanelFileCount }),
setProjectAttachmentTarget: (projectAttachmentTarget) =>
set(() => {
saveString(CHAT_PROJECT_ATTACHMENT_TARGET_KEY, projectAttachmentTarget);
@ -5918,6 +5929,7 @@ export const useChatRuntimeStore = create<ChatRuntimeStore>((set, get) => ({
saveRagSource(ragSource);
return {
ragSource,
ragPanelHidden: false,
queuedSettingsEpoch: state.queuedSettingsEpoch + 1,
};
}),

View file

@ -8,6 +8,7 @@ import {
import { Tick02Icon } from "@/lib/tick-icon";
import { HugeiconsIcon } from "@hugeicons/react";
import { FileDatabaseIcon } from "@hugeicons/core-free-icons";
import { FileGlyph } from "@/lib/file-icon";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
@ -34,14 +35,20 @@ import { KnowledgeBaseDialog } from "./knowledge-base-dialog";
// on; dims but stays interactive (so it can be turned off) while the loaded model can't run it.
export function KnowledgeBaseComposerButton({
side = "bottom",
hasFilesPanel = true,
}: {
side?: "top" | "bottom";
/** False in Compare, which renders no files panel to show. */
hasFilesPanel?: boolean;
} = {}) {
const ragEnabled = useChatRuntimeStore((s) => s.ragEnabled);
const setRagEnabled = useChatRuntimeStore((s) => s.setRagEnabled);
const ragDisabled = useRagToolDisabled();
const ragSource = useChatRuntimeStore((s) => s.ragSource);
const setRagSource = useChatRuntimeStore((s) => s.setRagSource);
const ragPanelHidden = useChatRuntimeStore((s) => s.ragPanelHidden);
const setRagPanelHidden = useChatRuntimeStore((s) => s.setRagPanelHidden);
const ragPanelFileCount = useChatRuntimeStore((s) => s.ragPanelFileCount);
// The embedding model is a server setting only the owner can change.
const isOwner = useIsAccountOwner();
@ -162,6 +169,21 @@ export function KnowledgeBaseComposerButton({
<EmbeddingModelMenuList onBack={() => setView("source")} />
) : (
<>
{/* The way back to a panel its X hid, while retrieval carries on. */}
{ragPanelHidden && hasFilesPanel ? (
<>
<DropdownMenuItem onSelect={() => setRagPanelHidden(false)} className="gap-2.5">
<FileGlyph strokeWidth={1.75} />
<span className="truncate">Show files</span>
{ragPanelFileCount > 0 ? (
<span className="inline-flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-muted px-1.5 text-ui-12 font-medium tabular-nums text-muted-foreground">
{ragPanelFileCount}
</span>
) : null}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
) : null}
<DropdownMenuLabel className="flex items-center justify-between gap-3">
<span className="shrink-0">Retrieve from</span>
{isOwner ? <EmbeddingModelMenuChip onOpen={() => setView("embedding")} /> : null}

View file

@ -276,6 +276,17 @@ const HEADER_ICON = { width: 16, height: 16 } as const;
const CompactCardsContext = createContext(false);
function HeaderTip({ label, children }: { label: string; children: ReactElement }) {
return (
<Tooltip>
<TooltipTrigger asChild={true}>{children}</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[calc(260px*var(--ui-space-scale,1))]">
{label}
</TooltipContent>
</Tooltip>
);
}
const PILL =
"inline-flex h-8 max-w-[calc(14rem*var(--ui-space-scale,1))] items-center gap-1.5 rounded-full px-3 text-ui-13 transition-colors";
@ -793,36 +804,38 @@ function ChatFilesPanel({
<div className="ml-auto flex shrink-0 items-center gap-1">
{headerControls}
{children ? (
<button
type="button"
// A narrow glyph: 4px from Add files reads as crowded next to the X.
className="unsloth-files-panel-icon-btn ml-3"
onClick={() => {
setCollapsed(!compact);
setShowAll(false);
}}
aria-expanded={!compact}
aria-label={compact ? "Expand files" : "Collapse files"}
title={compact ? "Expand files" : "Collapse files"}
>
<HugeiconsIcon
// The panel opens upward from the composer, so up expands and down collapses.
icon={compact ? ChevronUpStandardIcon : ChevronDownStandardIcon}
strokeWidth={1.75}
style={HEADER_ICON}
/>
</button>
<HeaderTip label={compact ? "Expand files" : "Collapse files"}>
<button
type="button"
// A narrow glyph: 4px from Add files reads as crowded next to the X.
className="unsloth-files-panel-icon-btn ml-3"
onClick={() => {
setCollapsed(!compact);
setShowAll(false);
}}
aria-expanded={!compact}
aria-label={compact ? "Expand files" : "Collapse files"}
>
<HugeiconsIcon
// The panel opens upward from the composer, so up expands and down collapses.
icon={compact ? ChevronUpStandardIcon : ChevronDownStandardIcon}
strokeWidth={1.75}
style={HEADER_ICON}
/>
</button>
</HeaderTip>
) : null}
{onClose ? (
<button
type="button"
className="unsloth-files-panel-icon-btn"
onClick={onClose}
aria-label={closeLabel ?? "Close"}
title={closeLabel}
>
<XIcon style={HEADER_ICON} />
</button>
<HeaderTip label={closeLabel ?? "Close"}>
<button
type="button"
className="unsloth-files-panel-icon-btn"
onClick={onClose}
aria-label={closeLabel ?? "Close"}
>
<XIcon style={HEADER_ICON} />
</button>
</HeaderTip>
) : null}
</div>
</div>
@ -968,6 +981,8 @@ export function ThreadDocumentsBar({
const ragSource = useChatRuntimeStore((s) => s.ragSource);
const setRagSource = useChatRuntimeStore((s) => s.setRagSource);
const setRagEnabled = useChatRuntimeStore((s) => s.setRagEnabled);
const ragPanelHidden = useChatRuntimeStore((s) => s.ragPanelHidden);
const setRagPanelHidden = useChatRuntimeStore((s) => s.setRagPanelHidden);
const ragToolDisabled = useRagToolDisabled();
const projectAttachmentDefault = useChatRuntimeStore(
(s) => s.projectAttachmentTarget,
@ -1167,6 +1182,11 @@ export function ThreadDocumentsBar({
// Keyed by the list item id, stable while a new chat materializes, so only a real switch drops the queue.
const threadItemId = useAuiState(({ threadListItem }) => threadListItem.id);
// A hidden panel comes back in the next chat.
// biome-ignore lint/correctness/useExhaustiveDependencies: resets on a chat switch
useEffect(() => {
useChatRuntimeStore.getState().setRagPanelHidden(false);
}, [threadItemId]);
const { enqueue: queueAttach, queued } = useUploadQueue<RagUploadItem>(
attach,
uploading || projectUploading,
@ -1304,6 +1324,9 @@ export function ThreadDocumentsBar({
</div>
));
const fileCount = documents.length + projectDocuments.length;
useEffect(() => {
useChatRuntimeStore.getState().setRagPanelFileCount(fileCount);
}, [fileCount]);
const countSuffix = fileCount === 1 ? "1 file" : `${fileCount} files`;
// Every branch, always the first fragment child: an "Add" must open after the source
// moves, and deleting the active KB in it must not remount it.
@ -1327,6 +1350,9 @@ export function ThreadDocumentsBar({
// A KB source uploads via the KB dialog, not here; show which KB is active.
if (ragEnabled && ragSource.type === "kb") {
const kbId = ragSource.kbId;
if (ragPanelHidden) {
return kbDialog;
}
return (
<>
{kbDialog}
@ -1335,8 +1361,8 @@ export function ThreadDocumentsBar({
key="kb"
icon={FileDatabaseIcon}
title={activeKbName ? `Knowledge base: ${activeKbName}` : "Knowledge base"}
onClose={() => setRagEnabled(false)}
closeLabel="Stop chatting with files"
onClose={() => setRagPanelHidden(true)}
closeLabel="Hide files"
headerControls={
<button
ref={kbChipRef}
@ -1400,6 +1426,9 @@ export function ThreadDocumentsBar({
? "Still loading this chat's project. Try again in a moment."
: undefined;
if (ragPanelHidden) {
return kbDialog;
}
return (
<>
{kbDialog}
@ -1412,8 +1441,8 @@ export function ThreadDocumentsBar({
info="Add or drop documents, spreadsheets, slides, e-books, email or code. The model searches them as you chat and cites what it uses."
onDropItems={queueAttach}
dropDisabledReason={busyReason}
onClose={() => setRagEnabled(false)}
closeLabel="Stop chatting with files"
onClose={() => setRagPanelHidden(true)}
closeLabel="Hide files"
note={
ragToolDisabled && fileCount > 0 ? (
<span
@ -1436,20 +1465,23 @@ export function ThreadDocumentsBar({
}
/>
) : null}
<button
type="button"
disabled={busy}
onClick={pickFiles}
className="unsloth-files-panel-action"
title={
<HeaderTip
label={
busyReason ??
(sharesWithProject
? "Add files for retrieval, shared with every chat in this project"
: "Add files for retrieval in this chat")
}
>
Add files
</button>
<button
type="button"
disabled={busy}
onClick={pickFiles}
className="unsloth-files-panel-action"
>
Add files
</button>
</HeaderTip>
</>
}
>

View file

@ -252,7 +252,7 @@
--muted-base: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5486 0 0);
/* Trace ink: muted, pulled toward the page so the answer stands apart. */
--trace-fade: 15%;
--trace-fade: 7%;
/* Hover/active washes are neutral grey (matches the sidebar), not the
brand green; green stays on primary controls only. */
--accent-base: #ececec;
@ -470,7 +470,7 @@
/* Secondary text: settings descriptions, empty states, captions.
Under the ink, still ~7:1 on the dialog surface. */
--muted-foreground: #b0b0b0;
--trace-fade: 24%;
--trace-fade: 13%;
/* Shared hover tone: one step over --popover. */
--accent-base: #3a3a3a;
--accent-foreground-base: #fcfcfc;
@ -6334,7 +6334,8 @@ html[data-find-bar-open] [data-sonner-toaster][data-y-position="top"] {
}
.unsloth-files-panel {
@apply relative mb-2 flex w-full flex-col gap-5 overflow-hidden rounded-[24px] px-4 pt-4 pb-6;
/* pt-3.5: the 28px Add files pill sits in the 32px header row, so this keeps it 16px from the top edge. */
@apply relative mb-2 flex w-full flex-col gap-5 overflow-hidden rounded-[24px] px-4 pt-3.5 pb-6;
container: files-panel / inline-size;
font-family: var(--font-sans);
background-color: #ffffff;
@ -6388,7 +6389,8 @@ html[data-find-bar-open] [data-sonner-toaster][data-y-position="top"] {
display: none;
}
.unsloth-files-panel[data-collapsed="true"] {
@apply gap-2.5 pt-3 pb-3;
/* pt-2.5 + the 32px header row's 2px around the Add files pill = the pills' 12px below. */
@apply gap-2.5 pt-2.5 pb-3;
}
/* Dropdown-surface look. Hover washes over the solid fill, never replaces it (it would turn see-through). */
.unsloth-files-panel-show-all {
@ -6425,7 +6427,8 @@ html[data-find-bar-open] [data-sonner-toaster][data-y-position="top"] {
}
.unsloth-files-panel-action {
@apply inline-flex h-7 shrink-0 items-center rounded-full border px-3 text-ui-12 text-foreground transition-colors disabled:pointer-events-none disabled:opacity-50;
/* pt-px: centred by its line box, the cap-height text sits half a pixel high. */
@apply inline-flex h-7 shrink-0 items-center rounded-full border px-3 pt-px text-ui-12 text-foreground transition-colors disabled:pointer-events-none disabled:opacity-50;
border-color: color-mix(in oklab, var(--foreground) 14%, transparent);
}
.unsloth-files-panel-action:hover {
@ -6459,3 +6462,14 @@ html[data-find-bar-open] [data-sonner-toaster][data-y-position="top"] {
.unsloth-files-panel-icon-btn:hover {
background-color: color-mix(in oklab, var(--foreground) 6%, transparent);
}
/* Inside thinking and tool traces, muted ink and chip edges sit a shade nearer the answer's ink:
lighter in dark mode, darker in light. :where keeps hover:text-foreground winning. */
@layer utilities {
:where(.aui-reasoning-root, .aui-tool-fallback-root, .aui-tool-group-root) .text-muted-foreground {
color: color-mix(in oklab, var(--muted-foreground), var(--foreground) 15%);
}
:where(.aui-reasoning-root, .aui-tool-fallback-root, .aui-tool-group-root) [data-slot="badge"] {
border-color: color-mix(in oklab, var(--border), var(--foreground) 15%);
}
}

View file

@ -72,6 +72,7 @@ function harness() {
"react/jsx-runtime": stubJsxRuntime(),
"lucide-react": {},
"@/lib/tick-icon": {},
"@/lib/file-icon": {},
"@hugeicons/react": {},
"@hugeicons/core-free-icons": {},
"@/components/ui/dropdown-menu": {},

View file

@ -0,0 +1,48 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
// The Chat with files panel's X hides the panel; retrieval and the composer's RAG tag stay.
import assert from "node:assert/strict";
import test from "node:test";
import { readSrc } from "./helpers/kit.ts";
const bar = readSrc("features/rag/components/thread-documents-bar.tsx");
const store = readSrc("features/chat/stores/chat-runtime-store.ts");
const thread = readSrc("components/assistant-ui/thread.tsx");
test("the panel X hides the panel instead of turning RAG off", () => {
assert.doesNotMatch(bar, /onClose=\{\(\) => setRagEnabled\(false\)\}/);
assert.equal(bar.match(/onClose=\{\(\) => setRagPanelHidden\(true\)\}/g)?.length, 2);
assert.match(bar, /if \(ragPanelHidden\) \{\n\s*return kbDialog;\n\s*\}/);
});
test("turning RAG on, picking a source or switching chats shows the panel again", () => {
assert.match(store, /ragEnabled,\n\s*ragPanelHidden: false,/);
assert.match(store, /ragSource,\n\s*ragPanelHidden: false,/);
assert.match(bar, /setRagPanelHidden\(false\);\n\s*\}, \[threadItemId\]\);/);
assert.match(thread, /if \(ragEnabled && ragPanelHidden\) \{\n\s*setRagPanelHidden\(false\);\n\s*return;/);
// Compare's + menu too: the regular view stays mounted behind it.
assert.match(
readSrc("features/chat/shared-composer.tsx"),
/if \(ragEnabled && ragPanelHidden\) \{\n\s*setRagPanelHidden\(false\);\n\s*return;/,
);
});
test("header controls use the app's tooltip, not the browser's title", () => {
const panel = bar.slice(bar.indexOf("function ChatFilesPanel("), bar.indexOf("const ATTACHMENT_TARGETS"));
assert.doesNotMatch(panel, /\btitle=\{/);
assert.match(panel, /<HeaderTip label=\{compact \? "Expand files" : "Collapse files"\}>/);
assert.match(panel, /<HeaderTip label=\{closeLabel \?\? "Close"\}>/);
});
test("the RAG tag's menu offers Show files, with the count, while the panel is hidden", () => {
const menu = readSrc("features/rag/components/knowledge-base-composer-button.tsx");
assert.match(menu, /\{ragPanelHidden && hasFilesPanel \? \([\s\S]*?onSelect=\{\(\) => setRagPanelHidden\(false\)\}[\s\S]*?Show files[\s\S]*?\{ragPanelFileCount\}/);
assert.match(bar, /setRagPanelFileCount\(fileCount\);\n\s*\}, \[fileCount\]\);/);
});
test("Compare, with no files panel of its own, does not offer Show files", () => {
assert.match(readSrc("features/chat/shared-composer.tsx"), /<KnowledgeBaseComposerButton side="top" hasFilesPanel=\{false\} \/>/);
});

View file

@ -58,6 +58,9 @@ function harness(
const storedIds = new Set(options.storedIds ?? []);
const state = {
ragEnabled: true,
ragPanelHidden: false,
setRagPanelHidden() {},
setRagPanelFileCount() {},
ragSource: options.ragSource ?? { type: "thread" },
activeProjectId: null,
projectAttachmentTarget: "thread",