From e94490ecbcfd09d379c6cac584df2e08fd0dc379 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Mon, 1 Jun 2026 08:27:34 -0700 Subject: [PATCH 01/23] Bump install.sh / install.ps1 pin to unsloth>=2026.5.10 (#5931) Co-authored-by: Daniel Han --- install.ps1 | 10 +++++----- install.sh | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index d3942bf2fc..47c72bcdc1 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1566,7 +1566,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.9" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo } if ($baseInstallExit -eq 0) { # Resolve pydantic WITH deps so pip pins pydantic-core # to the matching version (no-torch-runtime.txt below @@ -1580,7 +1580,7 @@ shell.Run cmd, 0, False } } } else { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.9" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --reinstall-package unsloth --reinstall-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo } } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red @@ -1627,7 +1627,7 @@ shell.Run cmd, 0, False if ($SkipTorch) { # No-torch: install unsloth + unsloth-zoo with --no-deps, then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.9" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --no-deps --upgrade-package unsloth --upgrade-package unsloth-zoo "unsloth>=2026.5.10" unsloth-zoo } if ($baseInstallExit -eq 0) { # Same pydantic-with-deps trick as the migrated branch. $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython pydantic } @@ -1639,7 +1639,7 @@ shell.Run cmd, 0, False } } } elseif ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.9" unsloth-zoo } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth "unsloth>=2026.5.10" unsloth-zoo } } else { $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython --upgrade-package unsloth -- "$PackageName" } } @@ -1667,7 +1667,7 @@ shell.Run cmd, 0, False Write-TauriLog "STEP" "Installing unsloth" substep "installing unsloth (this may take a few minutes)..." if ($StudioLocalInstall) { - $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.9" --torch-backend=auto } + $baseInstallExit = Invoke-InstallCommand { uv pip install --python $VenvPython unsloth-zoo "unsloth>=2026.5.10" --torch-backend=auto } if ($baseInstallExit -ne 0) { Write-Host "[ERROR] Failed to install unsloth (exit code $baseInstallExit)" -ForegroundColor Red return (Exit-InstallFailure "Failed to install unsloth (exit code $baseInstallExit)" $baseInstallExit) diff --git a/install.sh b/install.sh index 89c506bc9d..15755c589f 100755 --- a/install.sh +++ b/install.sh @@ -2083,7 +2083,7 @@ if [ "$_MIGRATED" = true ]; then # to prevent transitive torch resolution. run_install_cmd "install unsloth (migrated no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.5.9" unsloth-zoo + "unsloth>=2026.5.10" unsloth-zoo # Resolve pydantic WITH deps so pip pins pydantic-core to the # matching version (no-torch-runtime.txt below is --no-deps). # All transitive deps are torch-free. @@ -2096,7 +2096,7 @@ if [ "$_MIGRATED" = true ]; then else run_install_cmd "install unsloth (migrated)" uv pip install --python "$_VENV_PY" \ --reinstall-package unsloth --reinstall-package unsloth-zoo \ - "unsloth>=2026.5.9" unsloth-zoo + "unsloth>=2026.5.10" unsloth-zoo fi if [ "$STUDIO_LOCAL_INSTALL" = true ]; then substep "overlaying local repo (editable)..." @@ -2300,7 +2300,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then # runtime deps (typer, safetensors, transformers, etc.) with --no-deps. run_install_cmd "install unsloth (no-torch)" uv pip install --python "$_VENV_PY" --no-deps \ --upgrade-package unsloth --upgrade-package unsloth-zoo \ - "unsloth>=2026.5.9" unsloth-zoo + "unsloth>=2026.5.10" unsloth-zoo # Same pydantic-with-deps trick as the migrated branch. run_install_cmd "install pydantic (with deps for compatible core)" \ uv pip install --python "$_VENV_PY" pydantic @@ -2318,7 +2318,7 @@ elif [ -n "$TORCH_INDEX_URL" ]; then fi elif [ "$STUDIO_LOCAL_INSTALL" = true ]; then run_install_cmd "install unsloth (local)" uv pip install --python "$_VENV_PY" \ - --upgrade-package unsloth "unsloth>=2026.5.9" unsloth-zoo + --upgrade-package unsloth "unsloth>=2026.5.10" unsloth-zoo substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." @@ -2350,7 +2350,7 @@ else tauri_log "STEP" "Installing Unsloth" substep "installing unsloth (this may take a few minutes)..." if [ "$STUDIO_LOCAL_INSTALL" = true ]; then - run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.9" --torch-backend=auto + run_install_cmd "install unsloth (auto torch backend)" uv pip install --python "$_VENV_PY" unsloth-zoo "unsloth>=2026.5.10" --torch-backend=auto substep "overlaying local repo (editable)..." run_install_cmd "overlay local repo" uv pip install --python "$_VENV_PY" -e "$_REPO_ROOT" --no-deps substep "overlaying unsloth-zoo from git main..." From 21865773a61b37aa5b308376dcbaab2338caad31 Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Mon, 1 Jun 2026 17:49:27 +0100 Subject: [PATCH 02/23] Studio: support connected models in compare mode (#5824) * fix: support connected models in compare mode * fix: support connected models in compare mode * studio/frontend: wait for compare prompt append --------- Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> --- .../frontend/src/features/chat/chat-page.tsx | 11 ++ .../features/chat/chat-providers-dialog.tsx | 17 +- .../src/features/chat/shared-composer.tsx | 184 ++++++++++++++++-- 3 files changed, 191 insertions(+), 21 deletions(-) diff --git a/studio/frontend/src/features/chat/chat-page.tsx b/studio/frontend/src/features/chat/chat-page.tsx index 9133cdf102..f727b3dfd3 100644 --- a/studio/frontend/src/features/chat/chat-page.tsx +++ b/studio/frontend/src/features/chat/chat-page.tsx @@ -367,6 +367,7 @@ const CompareContent = memo(function CompareContent({ pairId, models, loraModels, + externalModels, onFoldersChange, onModelsChange, deleteDisabled, @@ -374,6 +375,7 @@ const CompareContent = memo(function CompareContent({ pairId: string; models: ModelOption[]; loraModels: LoraModelOption[]; + externalModels: ExternalModelOption[]; onFoldersChange?: () => void; onModelsChange?: (deletedModel?: DeletedModelRef) => void; deleteDisabled?: boolean; @@ -387,6 +389,7 @@ const CompareContent = memo(function CompareContent({ pairId={pairId} models={models} loraModels={loraModels} + externalModels={externalModels} onFoldersChange={onFoldersChange} onModelsChange={onModelsChange} deleteDisabled={deleteDisabled} @@ -557,6 +560,7 @@ const LoraCompareContent = memo(function LoraCompareContent({ function GeneralCompareHeader({ models, loraModels, + externalModels, value, onValueChange, onFoldersChange, @@ -566,6 +570,7 @@ function GeneralCompareHeader({ }: { models: ModelOption[]; loraModels: LoraModelOption[]; + externalModels: ExternalModelOption[]; value: string; onValueChange: ( id: string, @@ -586,6 +591,7 @@ function GeneralCompareHeader({ void; onModelsChange?: (deletedModel?: DeletedModelRef) => void; deleteDisabled?: boolean; @@ -691,6 +699,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ side="left" models={models} loraModels={loraModels} + externalModels={externalModels} value={model1.id} onValueChange={(id, meta) => setModel1({ @@ -716,6 +725,7 @@ const GeneralCompareContent = memo(function GeneralCompareContent({ side="right" models={models} loraModels={loraModels} + externalModels={externalModels} value={model2.id} onValueChange={(id, meta) => setModel2({ @@ -1824,6 +1834,7 @@ export function ChatPage(): ReactElement { pairId={view.pairId} models={models} loraModels={loraModels} + externalModels={externalModels} onFoldersChange={refreshLocalModels} onModelsChange={refreshModelLists} deleteDisabled={modelOperationInProgress} diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index 3ffb3a1441..c2e4eaab79 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -391,9 +391,22 @@ export function ChatProvidersSettings({ const updatedAt = Number.isFinite(Date.parse(config.updated_at)) ? Date.parse(config.updated_at) : Date.now(); + const registryEntry = + registryRows.find((entry) => entry.provider_type === uiProviderType) ?? + registryRows.find((entry) => entry.provider_type === config.provider_type); + const defaultModels = pruneProviderModelIds( + uiProviderType, + registryEntry?.default_models ?? [], + ); + const savedModels = existing?.models ?? []; + const savedAvailableModels = existing?.availableModels ?? []; const existingModels = pruneProviderModelIds( uiProviderType, - existing?.models ?? [], + savedModels.length > 0 ? savedModels : defaultModels, + ); + const existingAvailableModels = pruneProviderModelIds( + uiProviderType, + savedAvailableModels.length > 0 ? savedAvailableModels : defaultModels, ); return { id: config.id, @@ -401,7 +414,7 @@ export function ChatProvidersSettings({ name: config.display_name, baseUrl: config.base_url ?? "", models: existingModels, - availableModels: existing?.availableModels ?? [], + availableModels: existingAvailableModels, enablePromptCaching: supportsProviderPromptCaching(uiProviderType) ? (existing?.enablePromptCaching ?? true) : undefined, diff --git a/studio/frontend/src/features/chat/shared-composer.tsx b/studio/frontend/src/features/chat/shared-composer.tsx index b6dbffd5d0..772b217117 100644 --- a/studio/frontend/src/features/chat/shared-composer.tsx +++ b/studio/frontend/src/features/chat/shared-composer.tsx @@ -52,6 +52,7 @@ import { getExternalReasoningCapabilities, providerSupportsBuiltinCodeExecution, providerSupportsBuiltinImageGeneration, + providerSupportsBuiltinWebSearch, providerSupportsBuiltinWebFetch, } from "./provider-capabilities"; import { @@ -76,9 +77,9 @@ export type CompareMessagePart = export interface CompareHandle { append: (content: CompareMessagePart[]) => void; /** Append a user message without triggering generation. */ - appendMessage: (content: CompareMessagePart[]) => void; + appendMessage: (content: CompareMessagePart[]) => Promise; /** Trigger generation on the current thread (after appendMessage). */ - startRun: () => void; + startRun: (parentId?: string | null) => void; cancel: () => void; isRunning: () => boolean; /** Returns a promise that resolves when the current or next run finishes. */ @@ -87,6 +88,7 @@ export interface CompareHandle { const IMAGE_ACCEPT = "image/jpeg,image/png,image/webp,image/gif"; const MAX_IMAGE_SIZE = 20 * 1024 * 1024; +const COMPARE_APPEND_MESSAGE_TIMEOUT_MS = 10_000; function isNativeComposing(event: Event) { return "isComposing" in event && (event as InputEvent).isComposing === true; @@ -230,6 +232,7 @@ export function RegisterCompareHandle({ }): ReactElement | null { const handlesRef = useContext(CompareHandlesContext); const aui = useAui(); + const pendingAppendWaitersRef = useRef void>>(new Set()); useEffect(() => { if (!handlesRef) { @@ -242,19 +245,76 @@ export function RegisterCompareHandle({ aui .thread() .append({ role: "user", content, createdAt: new Date() } as never), - appendMessage: (content) => - aui - .thread() - .append({ - role: "user", - content, - createdAt: new Date(), - startRun: false, - } as never), - startRun: () => { + appendMessage: (content) => { + const thread = aui.thread(); + const beforeIds = new Set( + thread.getState().messages.map((message) => message.id), + ); + thread.append({ + role: "user", + content, + createdAt: new Date(), + startRun: false, + } as never); + + const findAppendedUserMessageId = () => { + const messages = thread.getState().messages; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (beforeIds.has(message.id) || message.role !== "user") { + continue; + } + return message.id; + } + return null; + }; + + const appendedId = findAppendedUserMessageId(); + if (appendedId) { + return Promise.resolve(appendedId); + } + + return new Promise((resolve) => { + const startedAt = Date.now(); + let settled = false; + let timer: number | null = null; + let cancel: (() => void) | null = null; + const cleanup = () => { + if (timer !== null) { + window.clearTimeout(timer); + timer = null; + } + if (cancel) { + pendingAppendWaitersRef.current.delete(cancel); + } + }; + const finish = (messageId: string | null) => { + if (settled) return; + settled = true; + cleanup(); + resolve(messageId); + }; + cancel = () => finish(null); + const poll = () => { + timer = null; + const messageId = findAppendedUserMessageId(); + if ( + messageId || + Date.now() - startedAt >= COMPARE_APPEND_MESSAGE_TIMEOUT_MS + ) { + finish(messageId); + return; + } + timer = window.setTimeout(poll, 16); + }; + pendingAppendWaitersRef.current.add(cancel); + timer = window.setTimeout(poll, 0); + }); + }, + startRun: (parentId) => { const msgs = aui.thread().getState().messages; - const lastId = msgs.length > 0 ? msgs[msgs.length - 1].id : null; - aui.thread().startRun({ parentId: lastId }); + const fallbackId = msgs.length > 0 ? msgs[msgs.length - 1].id : null; + aui.thread().startRun({ parentId: parentId ?? fallbackId }); }, cancel: () => aui.thread().cancelRun(), isRunning: () => aui.thread().getState().isRunning, @@ -272,6 +332,10 @@ export function RegisterCompareHandle({ }), }; return () => { + for (const cancel of pendingAppendWaitersRef.current) { + cancel(); + } + pendingAppendWaitersRef.current.clear(); delete currentHandles[name]; }; }, [handlesRef, name, aui]); @@ -706,6 +770,8 @@ export function SharedComposer({ : null; function modelDisplayName(id: string): string { + const external = parseExternalModelId(id); + if (external) return external.modelId; const parts = id.split("/"); return parts[parts.length - 1] || id; } @@ -714,6 +780,76 @@ export function SharedComposer({ async function ensureModelLoaded( sel: CompareModelSelection, ): Promise { + const external = parseExternalModelId(sel.id); + if (external) { + const externalStore = useExternalProvidersStore.getState(); + if (!externalStore.connectionsEnabled) { + throw new Error( + "Connections are disabled. Turn on Enable connections in Settings -> Connections to use hosted models.", + ); + } + const provider = externalStore.providers.find( + (p) => p.id === external.providerId, + ); + if (!provider) { + throw new Error( + "Connection not found. Open Settings -> Connections and add it again.", + ); + } + + const reasoningCaps = getExternalReasoningCapabilities( + provider.providerType, + external.modelId, + { + isReasoningProvider: provider.isReasoningModel === true, + baseUrl: provider.baseUrl ?? null, + }, + ); + const supportsBuiltinWebSearch = providerSupportsBuiltinWebSearch( + provider.providerType, + external.modelId, + provider.baseUrl, + ); + const supportsBuiltinCodeExecution = + providerSupportsBuiltinCodeExecution( + provider.providerType, + external.modelId, + provider.baseUrl, + ); + const supportsBuiltinImageGeneration = + providerSupportsBuiltinImageGeneration( + provider.providerType, + external.modelId, + provider.baseUrl, + ); + const supportsBuiltinWebFetch = providerSupportsBuiltinWebFetch( + provider.providerType, + ); + const currentStore = useChatRuntimeStore.getState(); + currentStore.setCheckpoint(sel.id, null); + useChatRuntimeStore.setState({ + activeGgufVariant: null, + ggufContextLength: null, + ggufMaxContextLength: null, + ggufNativeContextLength: null, + activeNativePathToken: null, + supportsReasoning: reasoningCaps.supportsReasoning, + reasoningAlwaysOn: reasoningCaps.reasoningAlwaysOn, + reasoningStyle: reasoningCaps.reasoningStyle, + supportsReasoningOff: reasoningCaps.supportsReasoningOff, + reasoningEffortLevels: reasoningCaps.reasoningEffortLevels, + supportsPreserveThinking: false, + supportsTools: false, + supportsBuiltinWebSearch, + supportsBuiltinCodeExecution, + supportsBuiltinImageGeneration, + supportsBuiltinWebFetch, + loadedIsMultimodal: + providerTypeSupportsVision(provider.providerType) === true, + }); + return "external"; + } + const currentStore = useChatRuntimeStore.getState(); const isAlreadyActive = currentStore.params.checkpoint === sel.id && @@ -796,9 +932,19 @@ export function SharedComposer({ const handle1 = handlesRef.current["model1"]; const handle2 = handlesRef.current["model2"]; - // Show user messages immediately on both sides - if (handle1) handle1.appendMessage(content); - if (handle2) handle2.appendMessage(content); + // Show user messages immediately on both sides and keep the ids + // so delayed model loads can start the run from the intended turn. + const [parentId1, parentId2] = await Promise.all([ + handle1 ? handle1.appendMessage(content) : Promise.resolve(null), + handle2 ? handle2.appendMessage(content) : Promise.resolve(null), + ]); + if ((handle1 && !parentId1) || (handle2 && !parentId2)) { + toast.error("Compare failed", { + description: + "The prompt could not be added to both compare panes. Try sending it again.", + }); + return; + } const name1 = model1?.id ? modelDisplayName(model1.id) : ""; const name2 = model2?.id ? modelDisplayName(model2.id) : ""; @@ -820,7 +966,7 @@ export function SharedComposer({ duration: Infinity, }); const done = handle1.waitForRunEnd(); - handle1.startRun(); + handle1.startRun(parentId1); await done; } @@ -843,7 +989,7 @@ export function SharedComposer({ duration: Infinity, }); const done = handle2.waitForRunEnd(); - handle2.startRun(); + handle2.startRun(parentId2); await done; } From e0ff6a1404e92f91d483a0e48e8785700814a97b Mon Sep 17 00:00:00 2001 From: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Date: Mon, 1 Jun 2026 19:09:16 +0100 Subject: [PATCH 03/23] Studio: manage chat history with projects (#5725) * feat: align project sidebar UX with ChatGPT * feat: align project sidebar UX with ChatGPT * feat(chat): load stored project list * feat(chat): add project sidebar workflows * fix: stabilize project page navigation * fix: projects chat loading * fix: show project chat thread * style: sidebar project spacing and hover clipping * style: add expandable project chat history and move-to-project submenu * feat: polish project sidebar * feat: persist project sandbox paths * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: only create sandbox project workspace dir * feat: add optional project workspace deletion from delete dialog * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix: stabilize chat projects CI failures * fix: polish project chat navigation * Studio: manage chat history with projects Group chats into projects with a dedicated projects page and route. Sidebar shows recents with per-row actions and a vertical more-vertical menu, and the sidebar scrollbar stays hidden so rows never shift on hover. Includes chat settings and composer refinements. * Studio: projects sidebar and breadcrumb polish Sidebar: - Remove the Compare nav item. - Widen the sidebar to match the projects layout. - Replace the scroll-gated bottom fade with a static fade pinned above the profile box, so it no longer attaches to Recents or lags the collapse and expand animation. Topbar breadcrumb (chat-page): - On a project landing show "Projects" linking to the projects list. - Inside a project chat show the project name and chat title, with the project name linking back to that specific project page. - Drop the divider between the model selector and the breadcrumb. * Studio: make project workspace delete test cross-platform test_chat_project_delete_files_removes_workspace rooted the project under pytest tmp_path, which resolves to /private/tmp on macOS. The workspace delete guard refuses paths under the system denylist by design, so the test passed on Linux CI but failed on macOS. Add a workspace_projects_home fixture that keeps tmp_path on Linux and Windows (CI unchanged) and falls back to a home subdir only when the temp root is on the platform denylist. Derive the workspace path from the created project so it tracks the projects home. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: satisfy import-hoist check for new path re-exports documents_root and project_workspaces_root are re-exported from utils.paths but only referenced as __all__ string literals, which the import-hoist safety net does not count as a use. It flagged the two newly added re-exports as unused imports and failed Source lint. Name-load both via a module-level _REEXPORTED tuple so the check sees them used. No behaviour change; consumers still import them from utils.paths. * fix: avoid projects empty-state flash * fix: batch chat search indexing * Studio: polish chat sidebar, run settings, and search - Use the native OS scrollbar for the chat sidebar, Run settings panel, and chat search list instead of a custom scrollbar - Highlight the active run in the sidebar and keep chat search available during training - Stop the training log view from replaying when navigating back to a run - Rename the chat settings panel to Run settings and align its toggle icon and position - Tighten heading and sidebar letter spacing and lighten the Train and Recents labels - Match the search dialog corner style across light and dark and drop the stray border - Make the MCP Servers section header plain text instead of a link - Remove a stray .orig backup file * studio/frontend: restore Compare entry point in the sidebar The chat-projects sidebar redesign dropped the Compare nav item and moved it to thread-sidebar.tsx, which is not imported or rendered anywhere. That left no way for a user to start a new model comparison (enterCompare only fired from the guided tour and the training handoff), and broke the Compare/Recipes/Export UI smoke test that clicks [data-tour="chat-compare"]. Re-add the Compare NavItem to the New Chat / Search group, carrying data-tour="chat-compare" and the same new-comparison navigation as before. * studio/frontend: use Unsloth green for the fallback profile avatar Switch the initials-avatar background from blue to #14b789 so the sidebar and edit-profile avatar match the Unsloth brand colour. * studio/frontend: turn project breadcrumb into a project switcher dropdown * studio/frontend: stop project card kebab clicks from opening the project * studio/frontend: hide project switcher outside projects * studio/frontend: stabilize project switcher loading * style: project switcher alignment --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com> Co-authored-by: Unsloth Co-authored-by: Roland Tannous Co-authored-by: Roland Tannous <115670425+rolandtannous@users.noreply.github.com> --- studio/backend/core/inference/tools.py | 42 +- studio/backend/routes/chat_history.py | 117 +++ studio/backend/routes/inference.py | 13 +- studio/backend/storage/studio_db.py | 263 ++++++- .../tests/test_chat_history_storage.py | 91 ++- studio/backend/utils/paths/__init__.py | 8 + studio/backend/utils/paths/storage_roots.py | 32 + studio/frontend/package-lock.json | 6 +- studio/frontend/package.json | 2 +- studio/frontend/src/app/router.tsx | 2 + studio/frontend/src/app/routes/__root.tsx | 9 + studio/frontend/src/app/routes/chat.tsx | 2 + studio/frontend/src/app/routes/projects.tsx | 21 + .../frontend/src/components/app-sidebar.tsx | 709 ++++++++++++++---- .../src/components/assistant-ui/thread.tsx | 33 +- .../src/components/ui/dropdown-menu.tsx | 8 +- studio/frontend/src/components/ui/sidebar.tsx | 4 +- .../frontend/src/components/ui/terminal.tsx | 34 +- .../src/features/chat/api/chat-adapter.ts | 61 +- .../src/features/chat/api/chat-api.ts | 78 +- .../frontend/src/features/chat/chat-page.tsx | 460 +++++++++++- .../src/features/chat/chat-settings-sheet.tsx | 101 ++- .../chat/components/chat-search-dialog.tsx | 20 +- .../chat/components/project-switcher.tsx | 120 +++ .../features/chat/hooks/use-chat-projects.ts | 97 +++ .../chat/hooks/use-chat-search-index.ts | 38 +- .../chat/hooks/use-chat-sidebar-items.ts | 23 +- studio/frontend/src/features/chat/index.ts | 9 + .../src/features/chat/projects-page.tsx | 401 ++++++++++ .../src/features/chat/runtime-provider.tsx | 95 ++- .../src/features/chat/shared-composer.tsx | 8 +- .../chat/stores/chat-runtime-store.ts | 4 + .../src/features/chat/thread-sidebar.tsx | 6 +- studio/frontend/src/features/chat/types.ts | 25 +- .../chat/utils/chat-history-storage.ts | 83 +- .../features/profile/utils/avatar-initials.ts | 4 +- .../src/features/studio/studio-page.tsx | 11 + .../studio/training-start-overlay.tsx | 20 +- .../training/stores/training-runtime-store.ts | 4 + .../src/features/training/types/runtime.ts | 4 + studio/frontend/src/index.css | 154 ++-- 41 files changed, 2865 insertions(+), 357 deletions(-) create mode 100644 studio/frontend/src/app/routes/projects.tsx create mode 100644 studio/frontend/src/features/chat/components/project-switcher.tsx create mode 100644 studio/frontend/src/features/chat/hooks/use-chat-projects.ts create mode 100644 studio/frontend/src/features/chat/projects-page.tsx diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index eecb84ca27..0f5dbfa237 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -421,6 +421,35 @@ _workdirs: dict[str, str] = {} # Non-matching session_ids collapse to ``_invalid`` to block cross-session escapes. _SESSION_ID_RE = re.compile(r"\A[A-Za-z0-9_\-]{1,64}\Z") +_PROJECT_SESSION_PREFIX = "project-" + + +def _get_project_workdir(session_id: str) -> str | None: + if not session_id.startswith(_PROJECT_SESSION_PREFIX): + return None + project_id = session_id[len(_PROJECT_SESSION_PREFIX) :] + if not project_id or not _SESSION_ID_RE.match(project_id): + return None + try: + from storage.studio_db import ensure_chat_project_workspace + + project = ensure_chat_project_workspace(project_id) + except Exception: + logger.warning( + "Failed to resolve project sandbox for %s", session_id, exc_info = True + ) + return None + if not project: + return None + root_path = project.get("rootPath") + sandbox_path = project.get("sandboxPath") + if not root_path or not sandbox_path: + return None + root_real = os.path.realpath(root_path) + sandbox_real = os.path.realpath(sandbox_path) + if sandbox_real != root_real and not sandbox_real.startswith(root_real + os.sep): + return None + return sandbox_real def _get_workdir(session_id: str | None = None) -> str: @@ -430,7 +459,14 @@ def _get_workdir(session_id: str | None = None) -> str: if key not in _workdirs or not os.path.isdir(_workdirs[key]): home = os.path.expanduser("~") sandbox_root = os.path.join(home, "studio_sandbox") - if session_id and _SESSION_ID_RE.match(session_id): + project_workdir = ( + _get_project_workdir(session_id) + if session_id and _SESSION_ID_RE.match(session_id) + else None + ) + if project_workdir: + workdir = project_workdir + elif session_id and _SESSION_ID_RE.match(session_id): workdir = os.path.join(sandbox_root, session_id) if not os.path.realpath(workdir).startswith( os.path.realpath(sandbox_root) + os.sep @@ -453,6 +489,10 @@ def _get_workdir(session_id: str | None = None) -> str: return _workdirs[key] +def get_sandbox_workdir(session_id: str | None = None) -> str: + return _get_workdir(session_id) + + WEB_SEARCH_TOOL = { "type": "function", "function": { diff --git a/studio/backend/routes/chat_history.py b/studio/backend/routes/chat_history.py index e64e5ca5c7..8e24096233 100644 --- a/studio/backend/routes/chat_history.py +++ b/studio/backend/routes/chat_history.py @@ -17,15 +17,21 @@ from storage.studio_db import ( clear_chat_history, count_chat_threads, delete_chat_threads, + delete_chat_project, + ensure_chat_project_workspace, + get_chat_project, get_chat_thread, get_chat_message, + list_chat_projects, list_chat_legacy_imports, list_chat_settings, list_chat_messages, list_chat_messages_for_threads, list_chat_threads, sync_chat_messages, + update_chat_project, update_chat_thread, + upsert_chat_project, upsert_chat_legacy_imports, upsert_chat_message, upsert_chat_settings_merge, @@ -41,6 +47,7 @@ class ChatThread(BaseModel): modelType: Literal["base", "lora", "model1", "model2"] modelId: str = "" pairId: Optional[str] = None + projectId: Optional[str] = None archived: bool = False createdAt: int openaiCodeExecContainerId: Optional[str] = None @@ -52,6 +59,7 @@ class ChatThreadPatch(BaseModel): modelType: Optional[Literal["base", "lora", "model1", "model2"]] = None modelId: Optional[str] = None pairId: Optional[str] = None + projectId: Optional[str] = None archived: Optional[bool] = None createdAt: Optional[int] = None openaiCodeExecContainerId: Optional[str] = None @@ -69,10 +77,33 @@ class ChatMessage(BaseModel): createdAt: int +class ChatProject(BaseModel): + id: str + name: str + instructions: str = "" + rootPath: Optional[str] = None + sandboxPath: Optional[str] = None + archived: bool = False + createdAt: int + updatedAt: int + + +class ChatProjectPatch(BaseModel): + name: Optional[str] = None + instructions: Optional[str] = None + archived: Optional[bool] = None + createdAt: Optional[int] = None + updatedAt: Optional[int] = None + + class ChatThreadListResponse(BaseModel): threads: list[ChatThread] +class ChatProjectListResponse(BaseModel): + projects: list[ChatProject] + + class ChatMessageListResponse(BaseModel): messages: list[ChatMessage] @@ -94,6 +125,7 @@ class ChatExportResponse(BaseModel): exportedAt: str version: int threadCount: int + projects: list[ChatProject] = Field(default_factory = list) threads: list[ChatThread] messages: list[ChatMessage] @@ -177,12 +209,14 @@ class ChatImportLedgerRecordResponse(BaseModel): async def list_threads( model_type: Optional[str] = Query(None), pair_id: Optional[str] = Query(None), + project_id: Optional[str] = Query(None), include_archived: bool = Query(True), current_subject: str = Depends(get_current_subject), ): threads = list_chat_threads( model_type = model_type, pair_id = pair_id, + project_id = project_id, include_archived = include_archived, ) return ChatThreadListResponse(threads = [ChatThread(**t) for t in threads]) @@ -193,6 +227,11 @@ async def save_thread( payload: ChatThread, current_subject: str = Depends(get_current_subject), ): + if payload.projectId and get_chat_project(payload.projectId) is None: + raise HTTPException( + status_code = 404, + detail = f"Project {payload.projectId} not found", + ) return ChatThread(**upsert_chat_thread(payload.model_dump())) @@ -217,6 +256,11 @@ async def patch_thread( for field in ("title", "modelType", "modelId", "archived", "createdAt"): if field in patch and patch[field] is None: raise HTTPException(status_code = 400, detail = f"{field} cannot be null") + if patch.get("projectId") and get_chat_project(patch["projectId"]) is None: + raise HTTPException( + status_code = 404, + detail = f"Project {patch['projectId']} not found", + ) thread = update_chat_thread( thread_id, patch, @@ -235,6 +279,77 @@ async def delete_threads( return {"status": "deleted"} +@router.get("/projects", response_model = ChatProjectListResponse) +async def list_projects( + include_archived: bool = Query(False), + current_subject: str = Depends(get_current_subject), +): + return ChatProjectListResponse( + projects = [ + ChatProject(**(ensure_chat_project_workspace(project["id"]) or project)) + for project in list_chat_projects(include_archived = include_archived) + ] + ) + + +@router.post("/projects", response_model = ChatProject) +async def save_project( + payload: ChatProject, + current_subject: str = Depends(get_current_subject), +): + return ChatProject(**upsert_chat_project(payload.model_dump())) + + +@router.get("/projects/{project_id}", response_model = ChatProject) +async def get_project( + project_id: str, + current_subject: str = Depends(get_current_subject), +): + project = ensure_chat_project_workspace(project_id) + if project is None: + raise HTTPException( + status_code = 404, + detail = f"Project {project_id} not found", + ) + return ChatProject(**project) + + +@router.patch("/projects/{project_id}", response_model = ChatProject) +async def patch_project( + project_id: str, + payload: ChatProjectPatch, + current_subject: str = Depends(get_current_subject), +): + patch = payload.model_dump(exclude_unset = True) + for field in ("name", "archived", "createdAt", "updatedAt"): + if field in patch and patch[field] is None: + raise HTTPException(status_code = 400, detail = f"{field} cannot be null") + project = update_chat_project(project_id, patch) + if project is not None: + project = ensure_chat_project_workspace(project_id) + if project is None: + raise HTTPException( + status_code = 404, + detail = f"Project {project_id} not found", + ) + return ChatProject(**project) + + +@router.delete("/projects/{project_id}", response_model = ChatProject) +async def delete_project( + project_id: str, + delete_files: bool = Query(False), + current_subject: str = Depends(get_current_subject), +): + project = delete_chat_project(project_id, delete_files = delete_files) + if project is None: + raise HTTPException( + status_code = 404, + detail = f"Project {project_id} not found", + ) + return ChatProject(**project) + + @router.get("/threads/{thread_id}/messages", response_model = ChatMessageListResponse) async def get_thread_messages( thread_id: str, @@ -389,11 +504,13 @@ async def export_history(current_subject: str = Depends(get_current_subject)): from datetime import datetime, timezone threads = list_chat_threads(include_archived = True) + projects = list_chat_projects(include_archived = True) messages = list_chat_messages_for_threads([thread["id"] for thread in threads]) return ChatExportResponse( exportedAt = datetime.now(timezone.utc).isoformat(), version = 1, threadCount = len(threads), + projects = [ChatProject(**project) for project in projects], threads = [ChatThread(**thread) for thread in threads], messages = [ChatMessage(**message) for message in messages], ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index fbe8cf6a92..2964a0801f 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -3837,16 +3837,11 @@ async def serve_sandbox_file( ) # ── Path containment check ────────────────────────────────── - home = os.path.expanduser("~") - sandbox_root = os.path.realpath(os.path.join(home, "studio_sandbox")) - safe_session = os.path.basename(session_id.replace("..", "")) - if not safe_session: - raise HTTPException(status_code = 404, detail = "Not found") + from core.inference.tools import get_sandbox_workdir - file_path = os.path.realpath( - os.path.join(sandbox_root, safe_session, safe_filename) - ) - if not file_path.startswith(sandbox_root + os.sep): + sandbox_dir = os.path.realpath(get_sandbox_workdir(session_id)) + file_path = os.path.realpath(os.path.join(sandbox_dir, safe_filename)) + if file_path != sandbox_dir and not file_path.startswith(sandbox_dir + os.sep): raise HTTPException( status_code = status.HTTP_403_FORBIDDEN, detail = "Access denied", diff --git a/studio/backend/storage/studio_db.py b/studio/backend/storage/studio_db.py index de89b6cbd2..0887eb9ceb 100644 --- a/studio/backend/storage/studio_db.py +++ b/studio/backend/storage/studio_db.py @@ -14,15 +14,18 @@ import json import logging import os import platform +import re +import shutil import sqlite3 import threading from datetime import datetime, timezone +from pathlib import Path logger = logging.getLogger(__name__) from typing import Any, Iterable, Optional -from utils.paths import studio_db_path, ensure_dir +from utils.paths import project_workspaces_root, studio_db_path, ensure_dir def _denied_path_prefixes() -> list[str]: @@ -55,6 +58,77 @@ def _denied_path_prefixes() -> list[str]: _schema_lock = threading.Lock() _schema_ready = False _SQLITE_IN_CHUNK_SIZE = 900 +_PROJECT_WORKSPACE_SUBDIRS = ("sandbox",) + + +def _project_slug(name: str) -> str: + slug = re.sub(r"[^A-Za-z0-9._-]+", "-", name.strip()).strip(".-_") + return slug[:48] or "project" + + +def _default_project_root(project: dict) -> str: + project_id = str(project["id"]) + suffix = re.sub(r"[^A-Za-z0-9_-]+", "-", project_id)[:8].strip("-_") or "project" + folder_name = f"{_project_slug(str(project.get('name') or 'Project'))}-{suffix}" + return str(project_workspaces_root() / folder_name) + + +def _ensure_project_workspace(root_path: str) -> str: + root = Path(root_path).expanduser() + root_resolved = ensure_dir(root).resolve() + for subdir in _PROJECT_WORKSPACE_SUBDIRS: + ensure_dir(root_resolved / subdir) + return str(root_resolved) + + +def _delete_project_workspace(project: dict) -> None: + root_path = project.get("rootPath") + if not root_path: + return + root = Path(root_path).expanduser() + try: + root_resolved = root.resolve(strict = False) + except (OSError, RuntimeError, ValueError): + logger.warning( + "Skipping project workspace delete for invalid path %r", root_path + ) + return + + project_id = str(project["id"]) + suffix = re.sub(r"[^A-Za-z0-9_-]+", "-", project_id)[:8].strip("-_") or "project" + if not root_resolved.name.endswith(f"-{suffix}"): + logger.warning( + "Skipping project workspace delete for unexpected project path %s", + root_resolved, + ) + return + if root_resolved.parent == root_resolved or root_resolved == Path.home().resolve(): + logger.warning( + "Skipping project workspace delete for unsafe project path %s", + root_resolved, + ) + return + check = ( + os.path.normcase(str(root_resolved)) + if platform.system() == "Windows" + else str(root_resolved) + ) + for prefix in _denied_path_prefixes(): + if check == prefix or check.startswith(prefix + os.sep): + logger.warning( + "Skipping project workspace delete under denied path %s", + root_resolved, + ) + return + if not root_resolved.exists(): + return + if root_resolved.is_symlink() or not root_resolved.is_dir(): + logger.warning( + "Skipping project workspace delete for non-directory path %s", + root_resolved, + ) + return + shutil.rmtree(root_resolved) def _ensure_schema(conn: sqlite3.Connection) -> None: @@ -119,6 +193,27 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS chat_projects ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + instructions TEXT, + root_path TEXT, + archived INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + chat_project_cols = { + row[1] for row in conn.execute("PRAGMA table_info(chat_projects)").fetchall() + } + if "root_path" not in chat_project_cols: + conn.execute("ALTER TABLE chat_projects ADD COLUMN root_path TEXT") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_projects_archived_updated_at ON chat_projects(archived, updated_at)" + ) conn.execute( """ CREATE TABLE IF NOT EXISTS chat_threads ( @@ -127,16 +222,20 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: model_type TEXT NOT NULL, model_id TEXT, pair_id TEXT, + project_id TEXT, archived INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, openai_code_exec_container_id TEXT, - anthropic_code_exec_container_id TEXT + anthropic_code_exec_container_id TEXT, + FOREIGN KEY(project_id) REFERENCES chat_projects(id) ON DELETE CASCADE ) """ ) chat_thread_cols = { row[1] for row in conn.execute("PRAGMA table_info(chat_threads)").fetchall() } + if "project_id" not in chat_thread_cols: + conn.execute("ALTER TABLE chat_threads ADD COLUMN project_id TEXT") if "openai_code_exec_container_id" not in chat_thread_cols: conn.execute( "ALTER TABLE chat_threads ADD COLUMN openai_code_exec_container_id TEXT" @@ -165,6 +264,9 @@ def _ensure_schema(conn: sqlite3.Connection) -> None: conn.execute( "CREATE INDEX IF NOT EXISTS idx_chat_threads_pair_id ON chat_threads(pair_id)" ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_chat_threads_project_id ON chat_threads(project_id)" + ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_chat_messages_thread_id_created_at ON chat_messages(thread_id, created_at)" ) @@ -681,6 +783,7 @@ def _chat_thread_from_row(row: sqlite3.Row) -> dict: "modelType": data["model_type"], "modelId": data.get("model_id") or "", "pairId": data.get("pair_id") or None, + "projectId": data.get("project_id") or None, "archived": bool(data["archived"]), "createdAt": data["created_at"], "openaiCodeExecContainerId": data.get("openai_code_exec_container_id"), @@ -688,6 +791,21 @@ def _chat_thread_from_row(row: sqlite3.Row) -> dict: } +def _chat_project_from_row(row: sqlite3.Row) -> dict: + data = dict(row) + root_path = data.get("root_path") + return { + "id": data["id"], + "name": data["name"], + "instructions": data.get("instructions") or "", + "rootPath": root_path or None, + "sandboxPath": os.path.join(root_path, "sandbox") if root_path else None, + "archived": bool(data["archived"]), + "createdAt": data["created_at"], + "updatedAt": data["updated_at"], + } + + def _chat_message_from_row(row: sqlite3.Row) -> dict: data = dict(row) message = { @@ -713,13 +831,14 @@ def upsert_chat_thread(thread: dict) -> dict: conn.execute( """ INSERT INTO chat_threads - (id, title, model_type, model_id, pair_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (id, title, model_type, model_id, pair_id, project_id, archived, created_at, openai_code_exec_container_id, anthropic_code_exec_container_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET title = excluded.title, model_type = excluded.model_type, model_id = excluded.model_id, pair_id = excluded.pair_id, + project_id = excluded.project_id, archived = excluded.archived, created_at = excluded.created_at, openai_code_exec_container_id = excluded.openai_code_exec_container_id, @@ -731,6 +850,7 @@ def upsert_chat_thread(thread: dict) -> dict: thread["modelType"], thread.get("modelId") or "", thread.get("pairId"), + thread.get("projectId"), 1 if thread.get("archived") else 0, int(thread["createdAt"]), thread.get("openaiCodeExecContainerId"), @@ -749,6 +869,7 @@ def update_chat_thread(id: str, patch: dict) -> Optional[dict]: "modelType": ("model_type", patch.get("modelType")), "modelId": ("model_id", patch.get("modelId")), "pairId": ("pair_id", patch.get("pairId")), + "projectId": ("project_id", patch.get("projectId")), "archived": ("archived", 1 if patch.get("archived") else 0), "createdAt": ("created_at", patch.get("createdAt")), "openaiCodeExecContainerId": ( @@ -794,6 +915,7 @@ def get_chat_thread(id: str) -> Optional[dict]: def list_chat_threads( model_type: str | None = None, pair_id: str | None = None, + project_id: str | None = None, include_archived: bool = True, ) -> list[dict]: clauses = [] @@ -804,6 +926,9 @@ def list_chat_threads( if pair_id is not None: clauses.append("pair_id = ?") values.append(pair_id) + if project_id is not None: + clauses.append("project_id = ?") + values.append(project_id) if not include_archived: clauses.append("archived = 0") where = f"WHERE {' AND '.join(clauses)}" if clauses else "" @@ -846,6 +971,136 @@ def count_chat_threads() -> int: conn.close() +def upsert_chat_project(project: dict) -> dict: + existing = get_chat_project(project["id"]) + root_path = existing.get("rootPath") if existing else None + if not root_path: + root_path = _default_project_root(project) + root_path = _ensure_project_workspace(root_path) + conn = get_connection() + try: + conn.execute( + """ + INSERT INTO chat_projects + (id, name, instructions, root_path, archived, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + instructions = excluded.instructions, + root_path = COALESCE(chat_projects.root_path, excluded.root_path), + archived = excluded.archived, + created_at = excluded.created_at, + updated_at = excluded.updated_at + """, + ( + project["id"], + project["name"], + project.get("instructions") or "", + root_path, + 1 if project.get("archived") else 0, + int(project["createdAt"]), + int(project["updatedAt"]), + ), + ) + conn.commit() + return get_chat_project(project["id"]) or project + finally: + conn.close() + + +def update_chat_project(id: str, patch: dict) -> Optional[dict]: + allowed = { + "name": ("name", patch.get("name")), + "instructions": ("instructions", patch.get("instructions")), + "archived": ("archived", 1 if patch.get("archived") else 0), + "createdAt": ("created_at", patch.get("createdAt")), + "updatedAt": ("updated_at", patch.get("updatedAt")), + } + assignments = [] + values = [] + for key, (column, value) in allowed.items(): + if key in patch: + assignments.append(f"{column} = ?") + values.append(value) + if not assignments: + return get_chat_project(id) + + conn = get_connection() + try: + conn.execute( + f"UPDATE chat_projects SET {', '.join(assignments)} WHERE id = ?", + (*values, id), + ) + conn.commit() + row = conn.execute("SELECT * FROM chat_projects WHERE id = ?", (id,)).fetchone() + return _chat_project_from_row(row) if row is not None else None + finally: + conn.close() + + +def ensure_chat_project_workspace(id: str) -> Optional[dict]: + project = get_chat_project(id) + if project is None: + return None + root_path = project.get("rootPath") or _default_project_root(project) + root_path = _ensure_project_workspace(root_path) + if project.get("rootPath") == root_path: + return project + conn = get_connection() + try: + conn.execute( + "UPDATE chat_projects SET root_path = ? WHERE id = ?", + (root_path, id), + ) + conn.commit() + finally: + conn.close() + return get_chat_project(id) + + +def get_chat_project(id: str) -> Optional[dict]: + conn = get_connection() + try: + row = conn.execute("SELECT * FROM chat_projects WHERE id = ?", (id,)).fetchone() + return _chat_project_from_row(row) if row is not None else None + finally: + conn.close() + + +def list_chat_projects(include_archived: bool = False) -> list[dict]: + conn = get_connection() + try: + where = "" if include_archived else "WHERE archived = 0" + rows = conn.execute( + f"SELECT * FROM chat_projects {where} ORDER BY updated_at DESC" + ).fetchall() + return [_chat_project_from_row(row) for row in rows] + finally: + conn.close() + + +def delete_chat_project(id: str, delete_files: bool = False) -> Optional[dict]: + conn = get_connection() + try: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute("SELECT * FROM chat_projects WHERE id = ?", (id,)).fetchone() + if row is None: + conn.rollback() + return None + project = _chat_project_from_row(row) + conn.execute("DELETE FROM chat_threads WHERE project_id = ?", (id,)) + conn.execute("DELETE FROM chat_projects WHERE id = ?", (id,)) + conn.commit() + if delete_files: + _delete_project_workspace(project) + return project + except Exception: + conn.rollback() + raise + finally: + conn.close() + + class ChatMessageConflictError(RuntimeError): """Raised when a chat message id already belongs to another thread.""" diff --git a/studio/backend/tests/test_chat_history_storage.py b/studio/backend/tests/test_chat_history_storage.py index 123dbf1b96..e88cac3276 100644 --- a/studio/backend/tests/test_chat_history_storage.py +++ b/studio/backend/tests/test_chat_history_storage.py @@ -1,18 +1,49 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 +import os +import platform +import shutil import threading +import uuid +from pathlib import Path import pytest from storage import studio_db -def _reset_studio_db(tmp_path, monkeypatch): +def _reset_studio_db(tmp_path, monkeypatch, projects_home = None): monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path)) + monkeypatch.setenv( + "UNSLOTH_STUDIO_PROJECTS_HOME", + str(projects_home if projects_home is not None else tmp_path / "Projects"), + ) monkeypatch.setattr(studio_db, "_schema_ready", False) +@pytest.fixture +def workspace_projects_home(tmp_path): + """Projects root outside the platform delete denylist. + + tmp_path resolves under /private/tmp on macOS, which the workspace + delete guard refuses by design. Linux/Windows tmp is not denied and is + used as-is; only the denied case falls back to a home subdir. + """ + candidate = tmp_path / "Projects" + resolved = str(candidate.resolve()) + check = os.path.normcase(resolved) if platform.system() == "Windows" else resolved + denied = studio_db._denied_path_prefixes() + if any(check == p or check.startswith(p + os.sep) for p in denied): + candidate = Path.home() / ".unsloth-studio-tests" / uuid.uuid4().hex + candidate.mkdir(parents = True, exist_ok = True) + try: + yield candidate + finally: + if ".unsloth-studio-tests" in candidate.parts: + shutil.rmtree(candidate, ignore_errors = True) + + def _thread(thread_id: str = "thread-1") -> dict: return { "id": thread_id, @@ -41,6 +72,17 @@ def _message( } +def _project(project_id: str = "project-1") -> dict: + return { + "id": project_id, + "name": "Research", + "instructions": "Use terse answers.", + "archived": False, + "createdAt": 1_700_000_000_000, + "updatedAt": 1_700_000_000_000, + } + + def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch): _reset_studio_db(tmp_path, monkeypatch) studio_db.upsert_chat_thread(_thread()) @@ -63,6 +105,53 @@ def test_sync_chat_messages_upserts_without_pruning(tmp_path, monkeypatch): assert by_id["msg-2"]["content"] == [{"type": "text", "text": "updated text"}] +def test_chat_projects_delete_cascades_threads_and_messages( + tmp_path, + monkeypatch, +): + _reset_studio_db(tmp_path, monkeypatch) + project = studio_db.upsert_chat_project(_project()) + assert project["rootPath"].startswith(str(tmp_path / "Projects")) + assert (tmp_path / "Projects" / "Research-project").exists() + assert (tmp_path / "Projects" / "Research-project" / "sandbox").is_dir() + assert not (tmp_path / "Projects" / "Research-project" / "chats").exists() + assert not (tmp_path / "Projects" / "Research-project" / "files").exists() + assert not (tmp_path / "Projects" / "Research-project" / "exports").exists() + studio_db.upsert_chat_thread({**_thread(), "projectId": "project-1"}) + studio_db.upsert_chat_message(_message("msg-1", 1, "delete with project")) + + [thread] = studio_db.list_chat_threads(project_id = "project-1") + assert thread["projectId"] == "project-1" + + deleted = studio_db.delete_chat_project("project-1") + + assert deleted is not None + assert deleted["id"] == "project-1" + assert studio_db.get_chat_project("project-1") is None + assert studio_db.list_chat_threads(project_id = "project-1") == [] + assert studio_db.get_chat_thread("thread-1") is None + assert studio_db.list_chat_messages("thread-1") == [] + assert (tmp_path / "Projects" / "Research-project").exists() + + +def test_chat_project_delete_files_removes_workspace( + tmp_path, monkeypatch, workspace_projects_home +): + _reset_studio_db(tmp_path, monkeypatch, projects_home = workspace_projects_home) + project = studio_db.upsert_chat_project(_project()) + # Derive root from the created project so it tracks the projects home. + root = Path(project["rootPath"]) + marker = root / "sandbox" / "marker.txt" + marker.write_text("created by code execution", encoding = "utf-8") + + deleted = studio_db.delete_chat_project(project["id"], delete_files = True) + + assert deleted is not None + assert deleted["rootPath"] == project["rootPath"] + assert not root.exists() + assert studio_db.get_chat_project(project["id"]) is None + + def test_sync_chat_messages_prunes_when_requested(tmp_path, monkeypatch): _reset_studio_db(tmp_path, monkeypatch) studio_db.upsert_chat_thread(_thread()) diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py index 92191dccdd..eba7a9de6c 100644 --- a/studio/backend/utils/paths/__init__.py +++ b/studio/backend/utils/paths/__init__.py @@ -25,6 +25,8 @@ from .storage_roots import ( auth_root, auth_db_path, studio_db_path, + documents_root, + project_workspaces_root, tmp_root, seed_uploads_root, unstructured_seed_cache_root, @@ -44,6 +46,10 @@ from .storage_roots import ( resolve_dataset_path, ) +# Re-export shim: name-load the project-path helpers so the import-hoist +# safety net sees them used here, not just listed in __all__ as strings. +_REEXPORTED = (documents_root, project_workspaces_root) + __all__ = [ "normalize_path", "is_local_path", @@ -62,6 +68,8 @@ __all__ = [ "auth_root", "auth_db_path", "studio_db_path", + "documents_root", + "project_workspaces_root", "tmp_root", "seed_uploads_root", "unstructured_seed_cache_root", diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py index 763d18bf3e..6319452ed2 100644 --- a/studio/backend/utils/paths/storage_roots.py +++ b/studio/backend/utils/paths/storage_roots.py @@ -96,6 +96,38 @@ def studio_db_path() -> Path: return studio_root() / "studio.db" +def _xdg_user_dir(key: str) -> Path | None: + config = Path.home() / ".config" / "user-dirs.dirs" + try: + lines = config.read_text(encoding = "utf-8").splitlines() + except OSError: + return None + prefix = f"{key}=" + for line in lines: + line = line.strip() + if not line.startswith(prefix): + continue + value = line[len(prefix) :].strip().strip('"') + if not value: + return None + return Path(value.replace("$HOME", str(Path.home()))).expanduser() + return None + + +def documents_root() -> Path: + override = (os.environ.get("UNSLOTH_STUDIO_DOCUMENTS_HOME") or "").strip() + if override: + return Path(override).expanduser() + return _xdg_user_dir("XDG_DOCUMENTS_DIR") or (Path.home() / "Documents") + + +def project_workspaces_root() -> Path: + override = (os.environ.get("UNSLOTH_STUDIO_PROJECTS_HOME") or "").strip() + if override: + return Path(override).expanduser() + return documents_root() / "Unsloth Studio" / "Projects" + + def tmp_root() -> Path: return Path(tempfile.gettempdir()) / "unsloth-studio" diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json index 80f5d0a701..4afee8a916 100644 --- a/studio/frontend/package-lock.json +++ b/studio/frontend/package-lock.json @@ -13229,9 +13229,9 @@ } }, "node_modules/react-is": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", - "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", + "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", "license": "MIT", "peer": true }, diff --git a/studio/frontend/package.json b/studio/frontend/package.json index b43e174889..66b821d6c7 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -88,8 +88,8 @@ "@eslint/js": "^9.39.1", "@types/canvas-confetti": "^1.9.0", "@types/js-yaml": "^4.0.9", - "@types/node-forge": "^1.3.14", "@types/node": "^25.5.2", + "@types/node-forge": "^1.3.14", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx index f0a417638d..a0ca1e8cdb 100644 --- a/studio/frontend/src/app/router.tsx +++ b/studio/frontend/src/app/router.tsx @@ -13,6 +13,7 @@ import { Route as gridTestRoute } from "./routes/grid-test"; import { Route as indexRoute } from "./routes/index"; import { Route as loginRoute } from "./routes/login"; import { Route as onboardingRoute } from "./routes/onboarding"; +import { Route as projectsRoute } from "./routes/projects"; import { Route as changePasswordRoute } from "./routes/change-password"; import { Route as settingsRoute } from "./routes/settings"; import { Route as studioRoute } from "./routes/studio"; @@ -26,6 +27,7 @@ const routeTree = rootRoute.addChildren([ settingsRoute, studioRoute, chatRoute, + projectsRoute, exportRoute, dataRecipesRoute, dataRecipeRoute, diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 57ed233d51..da74a9e8a7 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -6,6 +6,7 @@ import { Navbar } from "@/components/navbar"; import { fetchDeviceType, usePlatformStore } from "@/config/env"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SettingsDialog, useSettingsDialogStore } from "@/features/settings"; +import { useChatRuntimeStore } from "@/features/chat"; import { useTrainingUnloadGuard } from "@/features/training"; import { useSidebarPin } from "@/hooks/use-sidebar-pin"; import { useT, type TranslationKey } from "@/i18n"; @@ -40,6 +41,7 @@ function RouteFallback() { const CHAT_ONLY_ALLOWED = new Set([ "/", "/chat", + "/projects", "/login", "/signup", "/change-password", @@ -110,6 +112,13 @@ function RootLayout() { return () => window.removeEventListener("keydown", handler); }, []); + useEffect(() => { + if (isChatRoute) return; + const chatRuntime = useChatRuntimeStore.getState(); + chatRuntime.setActiveProjectId(null); + chatRuntime.setActiveThreadId(null); + }, [isChatRoute]); + return ( diff --git a/studio/frontend/src/app/routes/chat.tsx b/studio/frontend/src/app/routes/chat.tsx index 98c73aa7e0..a5514cdee1 100644 --- a/studio/frontend/src/app/routes/chat.tsx +++ b/studio/frontend/src/app/routes/chat.tsx @@ -10,6 +10,7 @@ export type ChatSearch = { thread?: string; compare?: string; new?: string; + project?: string; }; export const Route = createRoute({ @@ -21,6 +22,7 @@ export const Route = createRoute({ thread: typeof search.thread === "string" ? search.thread : undefined, compare: typeof search.compare === "string" ? search.compare : undefined, new: typeof search.new === "string" ? search.new : undefined, + project: typeof search.project === "string" ? search.project : undefined, }), component: ChatPage, }); diff --git a/studio/frontend/src/app/routes/projects.tsx b/studio/frontend/src/app/routes/projects.tsx new file mode 100644 index 0000000000..c63b1d5838 --- /dev/null +++ b/studio/frontend/src/app/routes/projects.tsx @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const ProjectsPage = lazy(() => + import("@/features/chat/projects-page").then((m) => ({ + default: m.ProjectsPage, + })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/projects", + staticData: { title: "Projects" }, + beforeLoad: () => requireAuth(), + component: ProjectsPage, +}); diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 4e4a6130cd..16292bd6e4 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -26,6 +26,9 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -38,6 +41,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { useAnimatedThemeToggle } from "@/components/ui/animated-theme-toggler"; import { cn } from "@/lib/utils"; import { @@ -47,9 +51,12 @@ import { Delete02Icon, DownloadSquare01Icon, Edit03Icon, + FolderAddIcon, + Folder01Icon, Globe02Icon, HelpCircleIcon, Logout05Icon, + MoreVerticalIcon, Search01Icon, PowerIcon, PencilEdit02Icon, @@ -68,11 +75,17 @@ import { ChevronDown, ChevronsUpDown, MoreHorizontalIcon, Moon, Sun } from "luci import { Link, useNavigate, useRouterState } from "@tanstack/react-router"; import { ChatSearchDialog, + createChatProject, + deleteChatProject, deleteChatItem, + moveChatItemToProject, renameChatItem, + renameChatProject, useChatRuntimeStore, + useChatProjects, useChatSearchStore, useChatSidebarItems, + type ProjectRecord, type SidebarItem, } from "@/features/chat"; import { useSettingsDialogStore } from "@/features/settings"; @@ -90,7 +103,7 @@ import { useTrainingRuntimeStore, } from "@/features/training"; import type { TrainingRunSummary } from "@/features/training"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { toast } from "@/lib/toast"; import { ShutdownDialog } from "@/components/shutdown-dialog"; import { translate, useT, type TranslationKey } from "@/i18n"; @@ -185,7 +198,7 @@ function NavItem({ active: boolean; disabled?: boolean; onClick: () => void; - children?: React.ReactNode; + children?: ReactNode; dataTour?: string; }) { return ( @@ -197,7 +210,7 @@ function NavItem({ onClick={onClick} isActive={active} data-tour={dataTour} - className="sidebar-nav-btn h-[35px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto" + className="sidebar-nav-btn h-[33px] rounded-[10px] gap-[8.5px] px-2.5 font-medium group-data-[collapsible=icon]:!w-[32px] group-data-[collapsible=icon]:!rounded-[10px] group-data-[collapsible=icon]:mx-auto" > {label} @@ -231,22 +244,53 @@ export function AppSidebar() { const isChatRoute = pathname.startsWith("/chat"); const isStudioRoute = pathname === "/studio" || pathname.startsWith("/studio/"); + const [chatOpen, setChatOpen] = useState(true); + const [trainOpen, setTrainOpen] = useState(true); + const [runsOpen, setRunsOpen] = useState(true); + + useEffect(() => { + if (!isChatRoute) return; + queueMicrotask(() => setChatOpen(true)); + }, [isChatRoute]); + useEffect(() => { + if (!isStudioRoute) return; + queueMicrotask(() => setRunsOpen(true)); + }, [isStudioRoute]); const scrollRef = useRef(null); const [scrolled, setScrolled] = useState(false); - useEffect(() => { - const el = scrollRef.current; - if (!el) return; - const handler = () => setScrolled(el.scrollTop > 0); - handler(); - el.addEventListener("scroll", handler, { passive: true }); - return () => el.removeEventListener("scroll", handler); - }, []); + // Bottom fade hides at the very bottom (and for short, non-scrolling lists) + // so the last row isn't washed out - Gemini-style. + const [canScrollDown, setCanScrollDown] = useState(false); + // Driven only from onScroll + a content-change effect below. Deliberately NO + // ResizeObserver: its callback-driven setState created a render loop (React + // #185). Both setters bail out when unchanged, so neither path can loop. + const syncScrollState = (el: HTMLDivElement) => { + const nextScrolled = el.scrollTop > 0; + setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled)); + const nextCanScrollDown = + el.scrollHeight - el.scrollTop - el.clientHeight > 1; + setCanScrollDown((prev) => + prev === nextCanScrollDown ? prev : nextCanScrollDown, + ); + }; const isRecipesRoute = pathname.startsWith("/data-recipes"); const { displayTitle, avatarDataUrl } = useEffectiveProfile(); - const { items: chatItems } = useChatSidebarItems(); + const { projects } = useChatProjects(); + const activeProjectId = isChatRoute + ? ((search.project as string | undefined) ?? null) + : null; + const { items: allChatItems } = useChatSidebarItems({ + enabled: !isStudioRoute, + requireMessages: false, + }); + const recentChatItems = useMemo( + () => allChatItems.filter((item) => !item.projectId), + [allChatItems], + ); + const chatItems = allChatItems; const storeThreadId = useChatRuntimeStore((s) => s.activeThreadId); const setActiveThreadId = useChatRuntimeStore((s) => s.setActiveThreadId); const activeThreadId = isChatRoute @@ -261,33 +305,87 @@ export function AppSidebar() { !chatOnly && isStudioRoute, ); const activeJobId = useTrainingRuntimeStore((s) => s.jobId); + const currentRunViewActive = useTrainingRuntimeStore((s) => s.currentRunViewActive); const selectedHistoryRunId = useTrainingRuntimeStore((s) => s.selectedHistoryRunId); const setSelectedHistoryRunId = useTrainingRuntimeStore((s) => s.setSelectedHistoryRunId); + // Recompute the bottom-fade state on mount and whenever the list height can + // change (items load, sections collapse/expand, route switches the visible + // list) - onScroll never fires for short, non-scrolling lists. Guarded + // setState below means this can't loop even if a dep is a fresh reference. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const next = el.scrollHeight - el.scrollTop - el.clientHeight > 1; + setCanScrollDown((prev) => (prev === next ? prev : next)); + }, [ + recentChatItems.length, + runItems.length, + projects.length, + chatOpen, + trainOpen, + runsOpen, + isStudioRoute, + ]); + const chatDisabled = isTrainingRunning; + function chatSearchForProject(projectId: string | null) { + if (projectId) { + return { project: projectId }; + } + return { + new: createNavigationNonce(), + }; + } + + function openNewChat(projectId = activeProjectId) { + if (chatDisabled) return; + setActiveThreadId(null); + useChatRuntimeStore.getState().setActiveProjectId(projectId); + navigate({ to: "/chat", search: chatSearchForProject(projectId) }); + closeMobileIfOpen(); + } + + function openProject(projectId: string) { + if (chatDisabled) return; + setActiveThreadId(null); + useChatRuntimeStore.getState().setActiveProjectId(projectId); + navigate({ to: "/chat", search: { project: projectId } }); + closeMobileIfOpen(); + } + async function handleDeleteThread(item: Parameters[0]) { await deleteChatItem(item, activeThreadId, (view) => { navigate({ to: "/chat", - search: { new: view.newThreadNonce }, + search: item.projectId + ? { project: item.projectId } + : { new: view.newThreadNonce }, }); }); } type RenameTarget = | { kind: "chat"; item: SidebarItem; current: string } + | { kind: "project"; project: ProjectRecord; current: string } | { kind: "run"; run: TrainingRunSummary; current: string }; const [renamingTarget, setRenamingTarget] = useState( null, ); const [renameDraft, setRenameDraft] = useState(""); + const [creatingProject, setCreatingProject] = useState(false); + const [projectNameDraft, setProjectNameDraft] = useState(""); + const [projectCreateMoveTarget, setProjectCreateMoveTarget] = + useState(null); const renameTrimmed = renameDraft.trim(); const nextRunDisplayName = renameTrimmed.length > 0 ? renameTrimmed : null; const renameDirty = renamingTarget !== null && (renamingTarget.kind === "chat" ? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current + : renamingTarget.kind === "project" + ? renameTrimmed.length > 0 && renameTrimmed !== renamingTarget.current : renameTrimmed.length > 0 ? renameTrimmed !== renamingTarget.current : renamingTarget.run.display_name != null); @@ -315,6 +413,16 @@ export function AppSidebar() { } return; } + if (target.kind === "project") { + try { + await renameChatProject(target.project.id, renameTrimmed); + } catch (err) { + toast.error("Failed to rename project", { + description: err instanceof Error ? err.message : undefined, + }); + } + return; + } try { const updated = await renameTrainingRun(target.run.id, nextRunDisplayName); emitTrainingRunUpdated(updated); @@ -327,13 +435,23 @@ export function AppSidebar() { type DeleteTarget = | { kind: "chat"; item: SidebarItem } + | { kind: "project"; project: ProjectRecord } | { kind: "run"; run: TrainingRunSummary }; const [confirmingDelete, setConfirmingDelete] = useState(null); + const [deleteProjectFiles, setDeleteProjectFiles] = useState(false); + + useEffect(() => { + if (confirmingDelete?.kind !== "project") { + setDeleteProjectFiles(false); + } + }, [confirmingDelete]); async function commitDelete() { const target = confirmingDelete; if (!target) return; + const shouldDeleteProjectFiles = + target.kind === "project" && deleteProjectFiles; setConfirmingDelete(null); if (target.kind === "chat") { try { @@ -345,6 +463,22 @@ export function AppSidebar() { } return; } + if (target.kind === "project") { + try { + await deleteChatProject(target.project.id, { + deleteFiles: shouldDeleteProjectFiles, + }); + if (activeProjectId === target.project.id) { + useChatRuntimeStore.getState().setActiveProjectId(null); + navigate({ to: "/chat", search: { new: createNavigationNonce() } }); + } + } catch (err) { + toast.error("Failed to delete project", { + description: err instanceof Error ? err.message : undefined, + }); + } + return; + } if (target.run.status === "running") { toast.error(t("shell.toast.cannotDeleteRunningRun")); return; @@ -362,6 +496,168 @@ export function AppSidebar() { } } + async function commitCreateProject() { + const name = projectNameDraft.trim(); + if (!name) return; + const moveTarget = projectCreateMoveTarget; + try { + const project = await createChatProject(name); + if (moveTarget) { + await moveChatItemToProject(moveTarget, project.id); + if (activeThreadId === moveTarget.id) { + useChatRuntimeStore.getState().setActiveProjectId(project.id); + } + } + setCreatingProject(false); + setProjectNameDraft(""); + setProjectCreateMoveTarget(null); + if (moveTarget) { + return; + } else { + openProject(project.id); + } + } catch (err) { + toast.error(moveTarget ? "Failed to create and move chat" : "Failed to create project", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + async function moveChatToProject(item: SidebarItem, projectId: string | null) { + if (item.projectId === projectId) return; + try { + await moveChatItemToProject(item, projectId); + if (activeThreadId === item.id) { + useChatRuntimeStore.getState().setActiveProjectId(projectId); + } + } catch (err) { + toast.error("Failed to move chat", { + description: err instanceof Error ? err.message : undefined, + }); + } + } + + function renderChatSidebarItem( + item: SidebarItem, + variant: "project" | "recent", + ) { + const itemClass = + variant === "project" + ? "group/project-chat-item relative" + : "group/recent-item relative"; + const actionClass = + variant === "project" + ? "sidebar-row-action group-hover/project-chat-item:opacity-100 group-hover/project-chat-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto" + : "sidebar-row-action group-hover/recent-item:opacity-100 group-hover/recent-item:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto"; + const buttonClass = cn( + "sidebar-nav-btn h-[33px] cursor-pointer rounded-[10px] pr-4 text-[14.5px] leading-[19px] tracking-nav font-medium", + variant === "project" ? "pl-[37px]" : "pl-2.5", + variant === "project" + ? "group-hover/project-chat-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/project-chat-item:pr-8" + : "group-hover/recent-item:pr-8 group-has-[.sidebar-row-action[data-state=open]]/recent-item:pr-8", + ); + + return ( + + { + navigate({ + to: "/chat", + search: + item.type === "single" + ? { + thread: item.id, + ...(item.projectId ? { project: item.projectId } : {}), + } + : { + compare: item.id, + ...(item.projectId ? { project: item.projectId } : {}), + }, + }); + closeMobileIfOpen(); + }} + > + {item.title} + + + + + + + openRenameChat(item)}> + + Rename + + + + + Move to project + + + { + setProjectCreateMoveTarget(item); + setProjectNameDraft(""); + setCreatingProject(true); + }} + > + + New project + + void moveChatToProject(item, null)} + > + Recents + + {projects.map((project) => ( + void moveChatToProject(item, project.id)} + > + + {project.name} + + ))} + + + setConfirmingDelete({ kind: "chat", item })} + > + + Delete + + + + + ); + } + return ( <> - + {/* Expanded: compact logo + close toggle */}
{ event.preventDefault(); if (chatDisabled) return; - setActiveThreadId(null); - closeMobileIfOpen(); - void navigate({ - to: "/chat", - search: { new: createNavigationNonce() }, - }); + openNewChat(null); }} className="flex items-center gap-[6px] select-none" aria-label={t("shell.aria.home")} @@ -392,7 +683,7 @@ export function AppSidebar() { alt="Unsloth" className="h-[34px] w-[34px] rounded-full object-cover" /> - + unsloth @@ -405,7 +696,7 @@ export function AppSidebar() { - - - openRenameChat(item)}> - - {t("common.rename")} - - setConfirmingDelete({ kind: "chat", item })} - > - - {t("common.delete")} - - - - - ))} - - + + + { + if (chatOnly) return; + navigate({ to: "/studio" }); + closeMobileIfOpen(); + }} + /> + { + navigate({ to: "/data-recipes" }); + closeMobileIfOpen(); + }} + /> + { + if (chatOnly) return; + navigate({ to: "/export" }); + closeMobileIfOpen(); + }} + /> + + + + + {!isStudioRoute && ( + + + + + {t("shell.navigation.recents")} + + + + + + + {recentChatItems.map((item) => + renderChatSidebarItem(item, "recent"), + )} + + + + )} {isStudioRoute && runItems.length > 0 && !chatOnly && ( - + - - + + {t("shell.navigation.recents")} - + {runItems.map((run) => { + // An explicit sidebar selection wins. Otherwise highlight + // the active job only while the "Current Run" tab is the + // view - that covers a live run (it auto-switches there) and + // a just-finished/errored run you're still viewing, while + // keeping the Configure tab unhighlighted even though + // `activeJobId` stays pinned to the last job. const isActiveRun = - selectedHistoryRunId === run.id || activeJobId === run.id; + selectedHistoryRunId != null + ? run.id === selectedHistoryRunId + : currentRunViewActive && run.id === activeJobId; return ( - + + {/* Fade above the profile box, shown only while there's more list below + the fold; at the very bottom (or for short lists) it fades out so the + last row shows fully (Gemini-style). `right-2` keeps it clear of the + 8px scrollbar gutter so the scrollbar isn't faded out. */} +