+
{props.traits ?
: null}
-
+ {props.permissions ? (
+
+ ) : null}
);
}
diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx
index 2e47b4c2fe3b4..6f6c8a77eb38a 100644
--- a/apps/web/src/components/chat/ProviderModelPicker.tsx
+++ b/apps/web/src/components/chat/ProviderModelPicker.tsx
@@ -5,9 +5,10 @@ import {
type ProviderDriverKind,
type ResolvedKeybindingsConfig,
} from "@t3tools/contracts";
-import { createContext, memo, type ReactNode, useRef, useState } from "react";
+import { createContext, memo, type MouseEvent, type ReactNode, useRef, useState } from "react";
import { Badge } from "../ui/badge";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
+import { SelectButton } from "../ui/select";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import { cn } from "~/lib/utils";
import { ModelPickerContent, resolveModelPickerSelectedModel } from "./ModelPickerContent";
@@ -94,6 +95,8 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
open?: boolean;
popupAlign?: "start" | "end";
triggerClassName?: string;
+ /** `field` draws the trigger as a full-width form select instead of a composer chip. */
+ triggerVariant?: "composer" | "field";
triggerLabel?: string;
triggerAriaLabel?: string;
onOpenChange?: (open: boolean) => void;
@@ -287,6 +290,28 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: {
/>
);
+ const isField = props.triggerVariant === "field";
+ const triggerProps = {
+ ref: triggerRef,
+ "aria-label": [
+ props.triggerAriaLabel ?? props.triggerLabel ?? allModelNames ?? triggerLabel,
+ props.triggerDetail,
+ ]
+ .filter(Boolean)
+ .join(", "),
+ "data-chat-provider-model-picker": "true",
+ "data-composer-shortcut": props.triggerShortcuts,
+ disabled: props.disabled,
+ onClick: (event: MouseEvent
) => {
+ if (!isMenuOpen || !docked) return;
+ const target = takeOpenTarget(triggerRef.current);
+ if (!target) return;
+ // A shortcut aimed at the open picker moves within it instead of closing it.
+ (event as typeof event & { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.();
+ openTarget(target);
+ },
+ };
+
return (
{
- if (!isMenuOpen || !docked) return;
- const target = takeOpenTarget(triggerRef.current);
- if (!target) return;
- // A shortcut aimed at the open picker moves within it instead of closing it.
- (
- event as typeof event & { preventBaseUIHandler?: () => void }
- ).preventBaseUIHandler?.();
- openTarget(target);
- }}
- className={cn(
- "min-w-0 shrink justify-between whitespace-nowrap",
- props.compact
- ? "max-w-42 shrink-0"
- : !props.isComposerOwned && "max-w-48 sm:max-w-56",
- props.triggerClassName,
- )}
- disabled={props.disabled}
- />
+ isField ? (
+
+ ) : (
+
+ )
}
>
) : null}
- {props.isComposerOwned ? (
+ {props.isComposerOwned || isField ? (
{triggerText}
) : (
diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx
index 4c3326df8c635..bf97600db08ea 100644
--- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx
+++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx
@@ -60,6 +60,9 @@ function taskScheduleLine(task: ScheduledTask) {
export function ThreadAutomationsPanel(props: {
readonly environmentId: EnvironmentId;
readonly threadId: ThreadId;
+ readonly title?: string;
+ /** Shown instead of hiding the section when the thread has no tasks. */
+ readonly emptyText?: string;
}) {
const canOperate = useAtomValue(
serverEnvironment.runScheduledTaskNow.permissionAtom(props.environmentId),
@@ -80,7 +83,8 @@ export function ThreadAutomationsPanel(props: {
(task) => task.threadId === props.threadId,
);
- if (tasksQuery.error === null && boundTasks.length === 0) return null;
+ if (tasksQuery.error === null && boundTasks.length === 0 && props.emptyText === undefined)
+ return null;
const reportFailure = (title: string, error: unknown) => {
toastManager.add(
@@ -129,7 +133,7 @@ export function ThreadAutomationsPanel(props: {
return (
@@ -155,6 +159,10 @@ export function ThreadAutomationsPanel(props: {
}
>
+ {boundTasks.length === 0 && tasksQuery.error === null ? (
+ {props.emptyText}
+ ) : null}
+
{boundTasks.map((task) => (
-
{(density) => {
+ if (project !== null && bot !== null && bot.threadId === props.threadId) {
+ return ;
+ }
// Section flags follow the rows that will render, so no empty section steals the divider rule.
const show = {
environment: density === "full" && visible.environment,
diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx
index 8a95cfecb56d5..3c704b512dfc2 100644
--- a/apps/web/src/components/files/AttachmentFilePreview.tsx
+++ b/apps/web/src/components/files/AttachmentFilePreview.tsx
@@ -14,7 +14,7 @@ import { ChevronRightIcon, DownloadIcon, Trash2Icon, WrapTextIcon, XIcon } from
import { Check, Code2, Copy, Eye, Table2 } from "lucide";
import { lazy, Suspense, useEffect, useRef, useState } from "react";
-import { useAssetUrlRefresh } from "~/assets/assetUrls";
+import { saveBlobAsFile, saveUrlAsFile, useAssetUrlRefresh } from "~/assets/assetUrls";
import ChatMarkdown from "~/components/ChatMarkdown";
import { maskMarkdownFrontmatter } from "@t3tools/shared/markdownFrontmatter";
import { MorphIcon } from "~/components/MorphIcon";
@@ -236,22 +236,13 @@ export function AttachmentFilePreview(props: {
const save = () => {
setSaving(true);
void settlePromise(async () => {
- let file = props.file;
- if (!file) {
- const target = await prepareDownload();
- if (!target) throw new Error("Reconnect to the environment and try again.");
- const response = await fetch(target);
- if (!response.ok) throw new Error("The file could not be loaded. Try again.");
- file = await response.blob();
+ if (props.file) {
+ saveBlobAsFile(props.file, props.name);
+ return;
}
- // Blob downloads keep desktop's custom app scheme from navigating to an external browser.
- const downloadUrl = URL.createObjectURL(file);
- // Scheduled before the click so the URL is revoked even if the click throws.
- setTimeout(() => URL.revokeObjectURL(downloadUrl), 30_000);
- const anchor = document.createElement("a");
- anchor.href = downloadUrl;
- anchor.download = props.name;
- anchor.click();
+ const target = await prepareDownload();
+ if (!target) throw new Error("Reconnect to the environment and try again.");
+ await saveUrlAsFile(target, props.name);
}).then((result) => {
if (!AsyncResult.isSuccess(result)) {
const cause = Cause.squash(result.cause);
diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx
index b88d92fc4edcc..e9f4d16ace123 100644
--- a/apps/web/src/components/files/FilePreviewPanel.tsx
+++ b/apps/web/src/components/files/FilePreviewPanel.tsx
@@ -50,7 +50,7 @@ import * as Schema from "effect/Schema";
import { lazy, Suspense, useEffect, useRef, useState } from "react";
import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview";
-import { useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
+import { saveUrlAsFile, useAssetUrlRefresh, useAssetUrlState } from "~/assets/assetUrls";
import { OpenInPicker } from "~/components/chat/OpenInPicker";
import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer";
import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions";
@@ -1640,14 +1640,7 @@ export function WorkspaceLargeTextPreview(props: {
void (async () => {
const url = await refreshAssetUrl();
if (!url) throw new Error("Reconnect to the environment and try again.");
- const response = await fetch(url);
- if (!response.ok) throw new Error("The file could not be loaded. Try again.");
- const downloadUrl = URL.createObjectURL(await response.blob());
- setTimeout(() => URL.revokeObjectURL(downloadUrl), 30_000);
- const anchor = document.createElement("a");
- anchor.href = downloadUrl;
- anchor.download = props.name.split(/[\\/]/).at(-1) || props.name;
- anchor.click();
+ await saveUrlAsFile(url, props.name.split(/[\\/]/).at(-1) || props.name);
})()
.catch(showError)
.finally(() => setSaving(false));
diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx
index dd41ddd2a0213..eda556fc15d78 100644
--- a/apps/web/src/components/sidebar/SidebarChrome.tsx
+++ b/apps/web/src/components/sidebar/SidebarChrome.tsx
@@ -1,9 +1,9 @@
+import { useAtom, useAtomValue } from "@effect/atom-react";
import { ArrowLeftIcon, ArrowRightIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react";
import type { ReactNode } from "react";
import { memo } from "react";
-import { Link, useLocation, useNavigate } from "@tanstack/react-router";
+import { Link, useLocation, useNavigate, useRouter } from "@tanstack/react-router";
-import { useAtomValue } from "@effect/atom-react";
import { useEnvironmentIdentificationMode } from "../../hooks/useSettings";
import { shortcutLabelForCommand } from "../../keybindings";
import {
@@ -13,7 +13,12 @@ import {
} from "../../navigationHistory";
import { primaryServerKeybindingsAtom } from "../../state/server";
import { cn } from "../../lib/utils";
+import { botListAtom, sidebarModeAtom, unreadBotAtom } from "../../state/bots";
+import { isThreadRoutePathname } from "../../threadRoutes";
import { usePullRequestsSupported } from "../../state/environments";
+import { appAtomRegistry } from "../../rpc/atomRegistry";
+import { BotAvatar } from "../bots/BotAvatar";
+import { BotFaceIcon } from "../bots/BotFaceIcon";
import { T3Wordmark } from "../T3Wordmark";
import {
resolveEnvironmentIdentificationPillLabel,
@@ -42,8 +47,10 @@ import { observeResize } from "~/lib/observeResize";
export const SidebarChromeHeader = memo(function SidebarChromeHeader({
isElectron,
+ actions,
}: {
isElectron: boolean;
+ actions?: ReactNode;
}) {
const stageLabel = useEnvironmentStageLabel();
const environmentIdentificationMode = useEnvironmentIdentificationMode();
@@ -89,6 +96,9 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
) : null}
+ {actions ? (
+