Refine chat workspace navigation and file diffs (#301)

* Refine chat workspace navigation and file diff presentation

* Match workspace empty-state backgrounds
This commit is contained in:
Daniel Kim
2026-09-09 12:29:16 -07:00
committed by GitHub
parent ea7fe8af8c
commit d640fc5609
28 changed files with 1449 additions and 1313 deletions
-1056
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1051
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -49,8 +49,8 @@
html { background: #ffffff; }
html[data-theme="dark"] { background: #0e0c0c; }
</style>
<script type="module" crossorigin src="/assets/index-B9tJoFQc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D8CernQ2.css">
<script type="module" crossorigin src="/assets/index-DSeSaoW_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKPCs98X.css">
</head>
<body>
<div
+8 -11
View File
@@ -1,4 +1,10 @@
{
"workspace_active_experiments": "Active Experiments",
"files_empty_description": "Files in this workspace will appear here. Ask the agent to create a file to get started.",
"experiments_empty_description": "Experiments and their runs will appear here. Ask the agent to set up an experiment to get started.",
"workspace_default_compute": "Default compute",
"workspace_this_worktree": "This worktree",
"workspace_tools_heading": "Workspace",
"chat_show_full_conversation": "Show full conversation",
"settings_key_from": "Key from",
"settings_token_from": "Token from",
@@ -16,7 +22,6 @@
"app_table": "Table",
"app_tree": "Tree",
"artifacts_tab_click_an_artifact_to_view_it": "Click an artifact to view it",
"artifacts_tab_copy_artifacts_directory_path": "Copy artifacts directory path",
"artifacts_tab_delete_artifact": "Delete artifact",
"artifacts_tab_delete_folder": "Delete folder",
"artifacts_tab_failed_to_load": "Failed to load:",
@@ -27,8 +32,7 @@
"artifacts_tab_modified": "Modified",
"artifacts_tab_no_artifacts_yet": "No artifacts yet",
"artifacts_tab_open_raw_in_new_tab": "Open raw in new tab",
"artifacts_tab_storage_settings": "Storage settings",
"artifacts_tab_this_is_the_project_s_durable_output_space": "This is the project's durable output space for reports, figures, images, CSVs, PDFs, and other research artifacts. Ask the agent for a write-up or add your own files:",
"artifacts_tab_this_is_the_project_s_durable_output_space": "This is the project's durable output space for reports, figures, images, CSVs, PDFs, and other research artifacts. Ask the agent for a write-up or add your own files.",
"file_tree_delete_confirm": "Delete “{path}”? This cannot be undone.",
"file_tree_duplicate": "Duplicate",
"file_tree_file_actions": "File actions for {path}",
@@ -45,7 +49,6 @@
"chat_panel_allow": "Allow",
"chat_panel_approval_required": "Approval required",
"chat_panel_archived": "Archived",
"chat_panel_artifacts": "Artifacts",
"chat_panel_ask_about_this": "Ask about this",
"chat_panel_attach_a_pdf_or_image": "Attach a PDF or image",
"chat_panel_browsed_the_web": "Browsed the web",
@@ -71,9 +74,7 @@
"chat_panel_edit_message": "Edit message",
"chat_panel_edited_a_file": "Edited a file",
"chat_panel_exit_plan_mode": "Exit Plan mode",
"chat_panel_experiments": "Experiments",
"chat_panel_failed": "Failed:",
"chat_panel_files": "Files",
"chat_panel_filter_sessions": "Filter sessions",
"chat_panel_is_unavailable": "is unavailable.",
"chat_panel_later_queued_messages_will_wait_until_this_is": "Later queued messages will wait until this is retried or removed.",
@@ -115,7 +116,7 @@
"chat_panel_starter_prompts": "Starter prompts",
"chat_panel_stop": "Stop",
"chat_panel_submit": "Submit",
"chat_panel_task": "Task",
"chat_panel_new_chat": "New Chat",
"chat_panel_tool_failed": "Tool failed",
"chat_panel_used_tools": "Used tools",
"chat_panel_view_full_plan": "View full plan",
@@ -534,10 +535,6 @@
"app_experiment_filter": "Experiment filter: {scope}",
"tab_preview_title": "{label} (double-click or ⌘/Ctrl+K Enter to keep open)",
"tab_preview_aria": "{label} (preview; double-click or Command/Control K, then Enter to keep open)",
"relative_now_short": "now",
"relative_minutes_short": "{value}m",
"relative_hours_short": "{value}h",
"relative_days_short": "{value}d",
"activity_searched_web": "Searched the web for “{query}”",
"activity_searched_images": "Searched images for “{query}”",
"activity_searched_web_page": "Searched a web page for “{pattern}”",
+8 -11
View File
@@ -1,4 +1,10 @@
{
"workspace_active_experiments": "آزمایش‌های فعال",
"files_empty_description": "فایل‌های این فضای کاری اینجا نمایش داده می‌شوند. برای شروع، از عامل بخواهید فایلی ایجاد کند.",
"experiments_empty_description": "آزمایش‌ها و اجراهای آن‌ها اینجا نمایش داده می‌شوند. برای شروع، از عامل بخواهید آزمایشی راه‌اندازی کند.",
"workspace_default_compute": "محاسبات پیش‌فرض",
"workspace_this_worktree": "این درخت کاری",
"workspace_tools_heading": "فضای کاری",
"chat_show_full_conversation": "نمایش کامل گفتگو",
"settings_key_from": "کلید از",
"settings_token_from": "توکن از",
@@ -16,7 +22,6 @@
"app_table": "جدول",
"app_tree": "درخت",
"artifacts_tab_click_an_artifact_to_view_it": "برای مشاهده، یک خروجی را انتخاب کنید",
"artifacts_tab_copy_artifacts_directory_path": "کپی مسیر پوشهٔ خروجی‌ها",
"artifacts_tab_delete_artifact": "حذف خروجی",
"artifacts_tab_delete_folder": "حذف پوشه",
"artifacts_tab_failed_to_load": "بارگیری ناموفق بود:",
@@ -27,8 +32,7 @@
"artifacts_tab_modified": "ویرایش‌شده",
"artifacts_tab_no_artifacts_yet": "هنوز خروجی‌ای وجود ندارد",
"artifacts_tab_open_raw_in_new_tab": "باز کردن فایل خام در زبانهٔ جدید",
"artifacts_tab_storage_settings": "تنظیمات ذخیره‌سازی",
"artifacts_tab_this_is_the_project_s_durable_output_space": "این فضای پایدار خروجی پروژه برای گزارش‌ها، نمودارها، تصاویر، فایل‌های CSV و PDF و دیگر خروجی‌های پژوهشی است. از عامل بخواهید گزارشی بنویسد یا فایل‌های خودتان را اضافه کنید:",
"artifacts_tab_this_is_the_project_s_durable_output_space": "این فضای پایدار خروجی پروژه برای گزارش‌ها، نمودارها، تصاویر، فایل‌های CSV و PDF و دیگر خروجی‌های پژوهشی است. از عامل بخواهید گزارشی بنویسد یا فایل‌های خودتان را اضافه کنید.",
"file_tree_delete_confirm": "«{path}» حذف شود؟ این کار قابل بازگشت نیست.",
"file_tree_duplicate": "ایجاد نسخهٔ تکراری",
"file_tree_file_actions": "عملیات فایل برای {path}",
@@ -45,7 +49,6 @@
"chat_panel_allow": "اجازه دادن",
"chat_panel_approval_required": "نیازمند تأیید",
"chat_panel_archived": "بایگانی‌شده",
"chat_panel_artifacts": "خروجی‌ها",
"chat_panel_ask_about_this": "دربارهٔ این بپرسید",
"chat_panel_attach_a_pdf_or_image": "پیوست PDF یا تصویر",
"chat_panel_browsed_the_web": "وب مرور شد",
@@ -71,9 +74,7 @@
"chat_panel_edit_message": "ویرایش پیام",
"chat_panel_edited_a_file": "فایل ویرایش شد",
"chat_panel_exit_plan_mode": "خروج از حالت طرح",
"chat_panel_experiments": "آزمایش‌ها",
"chat_panel_failed": "ناموفق:",
"chat_panel_files": "فایل‌ها",
"chat_panel_filter_sessions": "فیلتر نشست‌ها",
"chat_panel_is_unavailable": "در دسترس نیست.",
"chat_panel_later_queued_messages_will_wait_until_this_is": "پیام‌های بعدی صف تا تلاش دوباره یا حذف این پیام منتظر می‌مانند.",
@@ -115,7 +116,7 @@
"chat_panel_starter_prompts": "پیشنهادهای شروع",
"chat_panel_stop": "توقف",
"chat_panel_submit": "ارسال",
"chat_panel_task": "وظیفه",
"chat_panel_new_chat": "گفتگوی جدید",
"chat_panel_tool_failed": "ابزار ناموفق بود",
"chat_panel_used_tools": "ابزارها استفاده شد",
"chat_panel_view_full_plan": "مشاهدهٔ طرح کامل",
@@ -534,10 +535,6 @@
"app_experiment_filter": "فیلتر آزمایش: {scope}",
"tab_preview_title": "{label} (برای باز نگه‌داشتن دوبار کلیک کنید یا ⌘/Ctrl+K و سپس Enter را بزنید)",
"tab_preview_aria": "{label} (پیش‌نمایش؛ برای باز نگه‌داشتن دوبار کلیک کنید یا Command/Control K و سپس Enter را بزنید)",
"relative_now_short": "اکنون",
"relative_minutes_short": "{value}د",
"relative_hours_short": "{value}س",
"relative_days_short": "{value}ر",
"activity_searched_web": "وب برای «{query}» جست‌وجو شد",
"activity_searched_images": "تصاویر برای «{query}» جست‌وجو شد",
"activity_searched_web_page": "صفحهٔ وب برای «{pattern}» جست‌وجو شد",
+8 -11
View File
@@ -1,4 +1,10 @@
{
"workspace_active_experiments": "活跃实验",
"files_empty_description": "此工作区中的文件会显示在这里。让智能体创建一个文件即可开始。",
"experiments_empty_description": "实验及其运行会显示在这里。让智能体设置一个实验即可开始。",
"workspace_default_compute": "默认计算目标",
"workspace_this_worktree": "当前工作树",
"workspace_tools_heading": "工作区",
"chat_show_full_conversation": "显示完整对话",
"settings_key_from": "密钥来自",
"settings_token_from": "令牌来自",
@@ -16,7 +22,6 @@
"app_table": "表格",
"app_tree": "树状图",
"artifacts_tab_click_an_artifact_to_view_it": "点击产物即可查看",
"artifacts_tab_copy_artifacts_directory_path": "复制产物目录路径",
"artifacts_tab_delete_artifact": "删除产物",
"artifacts_tab_delete_folder": "删除文件夹",
"artifacts_tab_failed_to_load": "加载失败:",
@@ -27,8 +32,7 @@
"artifacts_tab_modified": "修改时间",
"artifacts_tab_no_artifacts_yet": "尚无产物",
"artifacts_tab_open_raw_in_new_tab": "在新标签页中打开原始文件",
"artifacts_tab_storage_settings": "存储设置",
"artifacts_tab_this_is_the_project_s_durable_output_space": "这里是项目的持久输出空间,用于保存报告、图表、图片、CSV、PDF 和其他研究产物。你可以让智能体撰写报告,也可以自行添加文件:",
"artifacts_tab_this_is_the_project_s_durable_output_space": "这里是项目的持久输出空间,用于保存报告、图表、图片、CSV、PDF 和其他研究产物。你可以让智能体撰写报告,也可以自行添加文件。",
"file_tree_delete_confirm": "删除“{path}”?此操作无法撤销。",
"file_tree_duplicate": "创建副本",
"file_tree_file_actions": "{path} 的文件操作",
@@ -45,7 +49,6 @@
"chat_panel_allow": "允许",
"chat_panel_approval_required": "需要批准",
"chat_panel_archived": "已归档",
"chat_panel_artifacts": "产物",
"chat_panel_ask_about_this": "询问此内容",
"chat_panel_attach_a_pdf_or_image": "附加 PDF 或图片",
"chat_panel_browsed_the_web": "已浏览网页",
@@ -71,9 +74,7 @@
"chat_panel_edit_message": "编辑消息",
"chat_panel_edited_a_file": "已编辑文件",
"chat_panel_exit_plan_mode": "退出计划模式",
"chat_panel_experiments": "实验",
"chat_panel_failed": "失败:",
"chat_panel_files": "文件",
"chat_panel_filter_sessions": "筛选会话",
"chat_panel_is_unavailable": "不可用。",
"chat_panel_later_queued_messages_will_wait_until_this_is": "后续排队的消息会等待此消息重试或移除。",
@@ -115,7 +116,7 @@
"chat_panel_starter_prompts": "入门提示",
"chat_panel_stop": "停止",
"chat_panel_submit": "提交",
"chat_panel_task": "任务",
"chat_panel_new_chat": "新对话",
"chat_panel_tool_failed": "工具失败",
"chat_panel_used_tools": "已使用工具",
"chat_panel_view_full_plan": "查看完整计划",
@@ -534,10 +535,6 @@
"app_experiment_filter": "实验筛选:{scope}",
"tab_preview_title": "{label}(双击或按 ⌘/Ctrl+K 后按 Enter 以保持打开)",
"tab_preview_aria": "{label}(预览;双击或按 Command/Control K 后按 Enter 以保持打开)",
"relative_now_short": "现在",
"relative_minutes_short": "{value} 分钟",
"relative_hours_short": "{value} 小时",
"relative_days_short": "{value} 天",
"activity_searched_web": "已在网页中搜索“{query}”",
"activity_searched_images": "已搜索图片“{query}”",
"activity_searched_web_page": "已在网页中搜索“{pattern}”",
+27 -11
View File
@@ -93,6 +93,7 @@ import {
type ChatMessage,
type UiState,
} from "./api";
import { WorkspaceTools } from "./components/WorkspaceTools";
import { ChatPanel, findPartById, spawnRowTitle } from "./components/ChatPanel";
import { usePopover } from "./components/ModelPicker";
import { SubagentTab } from "./components/SubagentTab";
@@ -221,6 +222,7 @@ type ExperimentsView = "tree" | "table";
/** Floating panel sizing: keep both the panel and the chat column usable. */
const PANEL_MIN_WIDTH = 360;
const PANEL_MARGIN = 10;
const WORKSPACE_CARD_MIN_WIDTH = 1448; // 1420px content plus the body’s 14px gutters.
// Space the rest of the layout needs beside the panel: the 272px rail, the
// chat column's minimum, and the gutters/margins between the three columns
// (app-body padding 14×2, rail inner margin 14, end-pane inner margin 14).
@@ -416,6 +418,8 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
// to (nearly) full screen. Width persists across sessions.
const [panelMax, setPanelMax] = useState(false);
const [panelWidth, setPanelWidth] = useState(initialPanelWidth);
const [workspaceWide, setWorkspaceWide] = useState(() => window.innerWidth >= WORKSPACE_CARD_MIN_WIDTH);
const workspaceCardVisible = mainView === "chat" && !panelOpen && workspaceWide;
// The agents rail is a floating panel too: fixed-width, collapsible.
const [railOpen, setRailOpen] = useState(true);
const [newProjectOpen, setNewProjectOpen] = useState(false);
@@ -782,7 +786,10 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
// Shrinking the window can push a fixed-width panel past its usable max —
// reclamp so it never overflows the viewport.
useEffect(() => {
const onResize = () => setPanelWidth((w) => Math.min(w, panelMaxWidth()));
const onResize = () => {
setPanelWidth((w) => Math.min(w, panelMaxWidth()));
setWorkspaceWide(window.innerWidth >= WORKSPACE_CARD_MIN_WIDTH);
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
@@ -1546,7 +1553,7 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
{runtime.kind === "local" && <OfflineBanner />}
{runtime.kind === "local" && <UpdateBanner status={updateStatus} />}
{workspaceError && <div role="alert" className="flex items-center gap-2 px-4 py-2 text-subtext"><span>{workspaceError}</span><Button onClick={retryWorkspace}>{m.app_retry()}</Button></div>}
<div className="app-body flex flex-1 min-h-0 py-0 px-3.5">
<div className={`app-body workspace-body relative flex flex-1 min-h-0 py-0 px-3.5 ${workspaceCardVisible ? "workspace-card-visible" : ""}`}>
{projectId && (
<ChatPanel
projectId={projectId}
@@ -1556,13 +1563,6 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
onShowRail={() => setRailOpen(true)}
mainView={mainView}
onSelectMainView={selectMainView}
experimentsActive={
mainView === "chat" && panelOpen && rightTab === "experiments"
}
filesActive={mainView === "chat" && panelOpen && rightTab === "files"}
artifactsActive={mainView === "chat" && panelOpen && rightTab === "artifacts"}
onOpenExperiments={() => openExperimentsTab()}
onOpenArtifacts={openArtifactsTab}
onOpenFile={openChatFile}
onOpenRun={openRunLogs}
runExperimentName={runExperimentName}
@@ -1570,7 +1570,6 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
experimentName={experimentName}
onOpenPlan={openPlanTab}
onOpenSubagent={openSubagentTab}
onOpenWorktree={openWorktreeTab}
composerPrefill={
activeProject &&
isDemoProjectId(activeProject.id) &&
@@ -1602,6 +1601,24 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
) : null}
</ChatPanel>
)}
{mainView === "chat" && (
<WorkspaceTools
expanded={workspaceCardVisible}
experiments={experiments}
runs={runs}
onOpenExperiment={(id, runId) => openExperimentTab(id, "overview", "preview", runId)}
rightOffset={panelOpen ? panelWidth + 28 : undefined}
activeView={panelOpen && (rightTab === "files" || rightTab === "artifacts" || rightTab === "experiments") ? rightTab : null}
projectId={activeProject.id}
onCompute={() => selectMainView("compute")}
sessionId={activeSessionId}
busy={sessionsQuery.data?.some((session) => session.id === activeSessionId && session.busy) ?? false}
onChanges={() => { setFilesView("changes"); openWorktreeTab(); }}
onFiles={() => { setFilesView("files"); openWorktreeTab(); }}
onArtifacts={openArtifactsTab}
onExperiments={() => openExperimentsTab()}
/>
)}
{mainView === "chat" && panelOpen && (
<aside
className={`right-pane relative shrink-0 min-w-0 flex flex-col mt-5 me-0 mb-5 ms-3.5 bg-canvas [&.max]:fixed [&.max]:inset-2.5 [&.max]:m-0 [&.max]:z-60 [&.max]:shadow-panel-max border border-border rounded-lg overflow-hidden shadow-elevated ${panelMax ? "max" : ""}`}
@@ -1682,7 +1699,6 @@ export default function App({ runtime, projectId, pane }: { runtime: RuntimeInfo
canRenameFile={(path) => !fileBuffersRef.current.get(
fileScrollKey(activeProject.id, activeSessionId, { path, source: "artifacts" }),
)?.needsProtection}
onOpenStorage={runtime.kind === "ssh" ? undefined : () => selectMainView("storage")}
/>
)}
</TabBody>
+28 -6
View File
@@ -16,16 +16,11 @@
unicode-bidi: plaintext;
}
/* The exception preserves DirFooter's RTL front-ellipsis truncation trick. */
:where(pre, code:not(.path-front-ellipsis), .font-mono, .xterm, .openresearch-diff) {
:where(pre, code, .font-mono, .code-source-text, .xterm, .openresearch-diff) {
direction: ltr;
unicode-bidi: isolate;
}
.path-front-ellipsis {
unicode-bidi: isolate;
}
@keyframes or-pulse {
50% {
opacity: 0.35;
@@ -300,3 +295,30 @@
transform: translateY(0.15em);
}
}
/* Reserve room for the three compact workspace buttons. */
.workspace-body .chat-header {
padding-inline-end: 8.5rem;
}
.workspace-card-visible .chat-pane {
padding-inline-end: 16rem;
}
.workspace-card-visible .chat-header {
padding-inline-end: 1rem;
}
.workspace-card-visible .workspace-tools {
top: 1.25rem;
}
.code-source-text,
.openresearch-diff .diff-line {
@apply font-mono text-sm leading-[1.55] [tab-size:4];
}
.code-source-wrap,
.openresearch-diff .diff-code {
@apply whitespace-pre-wrap break-normal break-words;
}
+14 -1
View File
@@ -1,4 +1,4 @@
import type { ChatMessage, ChatPart } from "./api";
import type { ChatMessage, ChatPart, ChatSession } from "./api";
/** Whether a part paints anything in the transcript. */
export function partIsVisible(part: ChatPart, activePermissionId?: string | null): boolean {
@@ -52,3 +52,16 @@ export function streamTailIsText(messages: ChatMessage[]): boolean {
}
return false;
}
export function unreadAfterBusyChange(
current: ReadonlySet<string>, previousBusy: ReadonlySet<string>, busy: ReadonlySet<string>,
sessions: ChatSession[], visibleSessionId: string | null,
): ReadonlySet<string> {
const finished = [...previousBusy].filter((id) => !busy.has(id)
&& sessions.some((session) => session.id === id) && id !== visibleSessionId);
if (finished.length === 0 && !(visibleSessionId && current.has(visibleSessionId))) return current;
const next = new Set(current);
for (const id of finished) next.add(id);
if (visibleSessionId) next.delete(visibleSessionId);
return next;
}
+2 -2
View File
@@ -6,9 +6,9 @@
// Preflight isn't imported (see tailwind.css), so the UA's
// `pre, code { font-family: monospace }` beats an inherited font — every code
// element needs these spelled out rather than relying on an ancestor.
export const CODE_TEXT_CLASS_NAME = "font-mono text-sm leading-[1.55] [tab-size:4]";
export const CODE_TEXT_CLASS_NAME = "code-source-text";
export const CODE_WRAP_CLASS_NAME = "whitespace-pre-wrap break-words";
export const CODE_WRAP_CLASS_NAME = "code-source-wrap";
export const CODE_GUTTER_CLASS_NAME = "file-view-gutter text-right text-muted select-none";
+6 -52
View File
@@ -1,3 +1,4 @@
import { WorkspaceEmptyState } from "./WorkspaceEmptyState";
import { useQuery } from "@tanstack/react-query";
import { queryClient } from "../queries/client";
import { getArtifactFileTextQuery } from "../queries/files";
@@ -5,14 +6,11 @@ import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { getLocale } from "../paraglide/runtime.js";
import {
Check,
ChevronRight,
Copy,
ExternalLink,
Code,
MousePointerClick,
Package,
Settings2,
Trash2,
} from "lucide-react";
import { useEffect, useRef, useState, type ReactNode } from "react";
@@ -46,9 +44,6 @@ import {
} from "./FileTreeActions";
import { IconButton, IconButtonLink, LoadingRow, showAlert, Spinner } from "./ui";
const TOOLTIP_ICON_BUTTON_CLASS_NAME =
"tip-up [&[data-tip]::after]:top-auto [&[data-tip]::after]:bottom-[calc(100%_+_6px)]";
/** Any href with a URI scheme (https:, mailto:, data:, …) or a
* protocol-relative // — i.e. not an artifact-relative path to resolve. */
function isExternalSrc(src: string): boolean {
@@ -505,39 +500,6 @@ function TreeRows({
);
}
/** The artifacts directory path, copyable in the tree footer. */
function DirFooter({ dir, onOpenStorage }: { dir: string; onOpenStorage?: () => void }) {
const [copied, setCopied] = useState(false);
return (
<div className="ftree-footer shrink-0 flex items-center gap-0.5 py-[5px] px-2 border-t border-t-border-variant [&_code]:flex-1 [&_code]:min-w-0 [&_code]:[direction:rtl] [&_code]:text-left [&_code]:font-mono [&_code]:text-xs [&_code]:text-muted [&_code]:overflow-hidden [&_code]:text-ellipsis [&_code]:whitespace-nowrap" title={ltr(dir)}>
<code className="path-front-ellipsis">{dir}</code>
<IconButton size="small"
className={TOOLTIP_ICON_BUTTON_CLASS_NAME}
data-tip={copied ? m.common_copied() : m.artifacts_copy_path()}
aria-label={m.artifacts_tab_copy_artifacts_directory_path()}
onClick={() => {
void navigator.clipboard?.writeText(dir);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
}}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</IconButton>
{onOpenStorage && (
<IconButton size="small"
className={TOOLTIP_ICON_BUTTON_CLASS_NAME}
data-tip={m.artifacts_tab_storage_settings()}
data-tip-align="end"
aria-label={m.artifacts_tab_storage_settings()}
onClick={onOpenStorage}
>
<Settings2 size={12} />
</IconButton>
)}
</div>
);
}
/** Middle-pane Artifacts tab — a split explorer over the project's durable outputs
* on disk. Tree on the left; the selected entry renders inline on the right
* (markdown as documents, images and PDFs directly, code as highlighted source). */
@@ -546,14 +508,11 @@ export function ArtifactsTab({
artifacts,
onOpenFile,
canRenameFile,
onOpenStorage,
}: {
project: Project;
artifacts: ProjectArtifacts | null;
onOpenFile: (path: string) => void;
canRenameFile: (path: string) => boolean;
/** Navigate to Settings → Storage (where the data dir can be changed). */
onOpenStorage?: () => void;
}) {
const [selected, setSelected] = useState<string | null>(null);
// Folders are open by default — including ones that appear later — so this
@@ -676,16 +635,11 @@ export function ArtifactsTab({
if (artifacts.entries.length === 0) {
return (
<div className="files-tab h-full min-h-0 flex bg-background">
<div className="files-empty-state flex-1 flex flex-col items-center justify-center gap-1.5 p-6 text-center text-muted [&_h3]:mt-1.5 [&_h3]:mx-0 [&_h3]:mb-0 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:text-text [&_p]:m-0 [&_p]:max-w-105 [&_p]:text-sm [&_p]:leading-[1.55] [&_p]:text-subtext [&_.ftree-footer]:mt-2.5 [&_.ftree-footer]:max-w-full [&_.ftree-footer]:border [&_.ftree-footer]:border-border [&_.ftree-footer]:rounded-md [&_.ftree-footer]:py-1.5 [&_.ftree-footer]:px-2.5 [&_.ftree-footer]:bg-background [&_.ftree-footer_code]:max-w-95">
<Package size={28} strokeWidth={1.5} />
<h3>{m.artifacts_tab_no_artifacts_yet()}</h3>
<p>
{m.artifacts_tab_this_is_the_project_s_durable_output_space()}
</p>
<DirFooter dir={artifacts.dir} onOpenStorage={onOpenStorage} />
</div>
</div>
<WorkspaceEmptyState
icon={Package}
title={m.artifacts_tab_no_artifacts_yet()}
description={m.artifacts_tab_this_is_the_project_s_durable_output_space()}
/>
);
}
+39 -85
View File
@@ -44,7 +44,6 @@ import {
MoreHorizontal,
PanelLeft,
Paperclip,
Package,
Pencil,
Plus,
Search,
@@ -110,6 +109,7 @@ import {
import { getLocale } from "../paraglide/runtime.js";
import { activePath, forkPositions } from "../transcriptTree";
import {
unreadAfterBusyChange,
isTurnStatusPart,
partIsVisible,
partsTailToolId,
@@ -747,17 +747,6 @@ const EMPTY_RECOVERY_OVERRIDES = {};
// --- rendering ---------------------------------------------------------------
function relTime(ts: number | undefined): string {
if (!ts) return "";
const seconds = Math.max(0, Math.floor((Date.now() - ts) / 1000));
if (seconds < 60) return m.relative_now_short();
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return m.relative_minutes_short({ value: fmtNumber(minutes) });
const hours = Math.floor(minutes / 60);
if (hours < 24) return m.relative_hours_short({ value: fmtNumber(hours) });
return m.relative_days_short({ value: fmtNumber(Math.floor(hours / 24)) });
}
/** The last path segment, for compact display ("src/a/b.rs" → "b.rs"). */
function baseName(path: string): string {
const trimmed = path.replace(/\/+$/, "");
@@ -2950,7 +2939,7 @@ const Message = memo(function Message({
onFork(message.id, next);
};
return (
<div className="msg-user-group self-end flex w-full max-w-[88%] flex-col items-end gap-1.5">
<div className="msg-user-group ms-auto self-end flex w-full max-w-[88%] flex-col items-end gap-1.5">
<div className="msg-user-edit w-full bg-surface rounded-[16px] py-2.5 px-[15px] flex flex-col gap-2">
<textarea
dir="auto"
@@ -2987,7 +2976,7 @@ const Message = memo(function Message({
);
}
return (
<div className="msg-user-group group/turn self-end flex max-w-[88%] flex-col items-end gap-1.5">
<div className="msg-user-group group/turn ms-auto self-end flex max-w-[88%] flex-col items-end gap-1.5">
{annotations.length > 0 && (
<AnnotationsPopover annotations={annotations} variant="sent" />
)}
@@ -3080,7 +3069,7 @@ function ShellExchangeCard({ part }: { part: ChatPart }) {
? m.chat_bash_exit_code({ code: fmtNumber(exitCode) })
: null;
return (
<div className="msg-shell self-end flex w-full max-w-[88%] flex-col items-stretch gap-1.5">
<div className="msg-shell ms-auto self-end flex w-full max-w-[88%] flex-col items-stretch gap-1.5">
<div dir="ltr" className="max-w-full bg-surface rounded-[16px] py-2.5 px-[15px] text-base">
<div className="flex items-start gap-2 font-mono text-sm text-text whitespace-pre-wrap wrap-anywhere">
<span className="sr-only">{m.chat_panel_bash()} </span>
@@ -3875,7 +3864,7 @@ function SessionRow({
ref={ref}
role="button"
tabIndex={0}
className={`session-row relative flex items-center gap-2 w-full text-start py-[7px] px-2.5 rounded-md text-sm text-text cursor-pointer select-none [&:hover:not(.active)]:bg-surface [&.active]:bg-panel [&.active]:font-medium [&_.session-dot]:w-3.5 [&_.session-dot]:inline-flex [&_.session-dot]:items-center [&_.session-dot]:justify-center [&_.session-dot]:shrink-0 [&_.session-title]:flex-1 [&_.session-title]:min-w-0 [&_.session-title]:overflow-hidden [&_.session-title]:text-ellipsis [&_.session-title]:whitespace-nowrap [&.unread_.session-title]:font-semibold [&_.session-time]:text-xs [&_.session-time]:text-muted [&_.session-time]:shrink-0 [&_.session-menu-btn]:hidden [&_.session-menu-btn]:items-center [&_.session-menu-btn]:justify-center [&_.session-menu-btn]:w-4 [&_.session-menu-btn]:h-4 [&_.session-menu-btn]:-my-0.5 [&_.session-menu-btn]:mx-0 [&_.session-menu-btn]:rounded-sm [&_.session-menu-btn]:text-muted [&_.session-menu-btn]:shrink-0 [&_.session-menu-btn:hover]:text-text [&_.session-menu-btn:hover]:bg-panel [&:hover_.session-menu-btn]:inline-flex [&:focus-within_.session-menu-btn]:inline-flex [&.menu-open_.session-menu-btn]:inline-flex [&:hover_.session-time]:hidden [&:focus-within_.session-time]:hidden [&.menu-open_.session-time]:hidden [&_.busy-dot]:w-[7px] [&_.busy-dot]:h-[7px] [&_.busy-dot]:rounded-full [&_.busy-dot]:bg-primary [&_.busy-dot]:animate-[or-pulse_1.2s_infinite] [&_.busy-dot]:shrink-0 [&_.unread-dot]:w-[7px] [&_.unread-dot]:h-[7px] [&_.unread-dot]:rounded-full [&_.unread-dot]:bg-primary [&_.unread-dot]:shrink-0 [&_.busy-dot.waiting]:animate-none [&_.session-title-input]:flex-1 [&_.session-title-input]:min-w-0 [&_.session-title-input]:py-px [&_.session-title-input]:px-[5px] [&_.session-title-input]:-my-0.5 [&_.session-title-input]:mx-0 [&_.session-title-input]:[font:inherit] [&_.session-title-input]:text-text [&_.session-title-input]:bg-background [&_.session-title-input]:border [&_.session-title-input]:border-primary [&_.session-title-input]:rounded-sm [&_.session-title-input]:outline-none [&.editing]:bg-surface [&.editing]:cursor-default [&.editing_.session-menu-btn]:hidden [&.editing_.session-time]:hidden ${active ? "active" : ""} ${unread ? "unread" : ""} ${open ? "menu-open" : ""} ${
className={`session-row relative flex items-center gap-2 w-full text-start py-[7px] px-2.5 rounded-md text-sm text-text cursor-pointer select-none [&:hover:not(.active)]:bg-surface [&.active]:bg-panel [&.active]:font-medium [&_.session-dot:empty]:hidden [&_.session-dot]:w-4 [&_.session-dot]:inline-flex [&_.session-dot]:items-center [&_.session-dot]:justify-center [&_.session-dot]:shrink-0 [&_.session-title]:flex-1 [&_.session-title]:min-w-0 [&_.session-title]:overflow-hidden [&_.session-title]:[mask-image:linear-gradient(to_right,black_calc(100%_-_16px),transparent)] [&_.session-title]:whitespace-nowrap [&_.session-menu-btn]:hidden [&_.session-menu-btn]:items-center [&_.session-menu-btn]:justify-center [&_.session-menu-btn]:w-4 [&_.session-menu-btn]:h-4 [&_.session-menu-btn]:-my-0.5 [&_.session-menu-btn]:mx-0 [&_.session-menu-btn]:rounded-sm [&_.session-menu-btn]:text-muted [&_.session-menu-btn]:shrink-0 [&_.session-menu-btn:hover]:text-text [&_.session-menu-btn:hover]:bg-panel [&:hover_.session-menu-btn]:inline-flex [&:focus-visible_.session-menu-btn]:inline-flex [&_.session-menu-btn:focus-visible]:inline-flex [&.menu-open_.session-menu-btn]:inline-flex [&:hover_.session-dot]:hidden [&:focus-visible_.session-dot]:hidden [&:has(.session-menu-btn:focus-visible)_.session-dot]:hidden [&.menu-open_.session-dot]:hidden [&_.busy-dot]:w-[7px] [&_.busy-dot]:h-[7px] [&_.busy-dot]:rounded-full [&_.busy-dot]:bg-primary [&_.busy-dot]:animate-[or-pulse_1.2s_infinite] [&_.busy-dot]:shrink-0 [&_.unread-dot]:w-[7px] [&_.unread-dot]:h-[7px] [&_.unread-dot]:rounded-full [&_.unread-dot]:bg-primary [&_.unread-dot]:shrink-0 [&_.busy-dot.waiting]:animate-none [&_.session-title-input]:flex-1 [&_.session-title-input]:min-w-0 [&_.session-title-input]:py-px [&_.session-title-input]:px-[5px] [&_.session-title-input]:-my-0.5 [&_.session-title-input]:mx-0 [&_.session-title-input]:[font:inherit] [&_.session-title-input]:text-text [&_.session-title-input]:bg-background [&_.session-title-input]:border [&_.session-title-input]:border-primary [&_.session-title-input]:rounded-sm [&_.session-title-input]:outline-none [&.editing]:bg-surface [&.editing]:cursor-default [&.editing_.session-menu-btn]:hidden [&.editing_.session-dot]:hidden ${active ? "active" : ""} ${unread ? "unread" : ""} ${open ? "menu-open" : ""} ${
editing ? "editing" : ""
}`}
title={`${HARNESS_LABELS[session.harness]}${session.model ? ` · ${session.model}` : ""}${
@@ -3903,13 +3892,6 @@ function SessionRow({
}
}}
>
<span className="session-dot">
{busy ? (
<span className={`busy-dot ${waiting ? "waiting" : ""}`} />
) : (
unread && <span className="unread-dot" />
)}
</span>
{session.parentSessionId && !editing && (
<Users className="text-muted shrink-0" size={12} aria-hidden />
)}
@@ -3942,7 +3924,13 @@ function SessionRow({
/>
</span>
)}
<span className="session-time">{relTime(session.updatedAt)}</span>
<span className="session-dot">
{busy ? (
<span className={`busy-dot ${waiting ? "waiting" : ""}`} />
) : (
unread && <span className="unread-dot" />
)}
</span>
<button
className="session-menu-btn"
title={m.chat_panel_session_options()}
@@ -4129,11 +4117,6 @@ export function ChatPanel({
onShowRail,
mainView,
onSelectMainView,
experimentsActive,
filesActive,
artifactsActive,
onOpenExperiments,
onOpenArtifacts,
onOpenFile,
onOpenRun,
runExperimentName,
@@ -4141,7 +4124,6 @@ export function ChatPanel({
experimentName,
onOpenPlan,
onOpenSubagent,
onOpenWorktree,
runtime,
onOpenDemoWelcome,
composerPrefill = null,
@@ -4162,11 +4144,6 @@ export function ChatPanel({
/** Settings sections replace chat; Artifacts remains a right-panel tool. */
mainView: "chat" | "skills" | SettingsTab;
onSelectMainView: (view: "chat" | "skills" | SettingsTab) => void;
experimentsActive: boolean;
filesActive: boolean;
artifactsActive: boolean;
onOpenExperiments: () => void;
onOpenArtifacts: () => void;
/** Open a project file in the right pane (chat tool rows are clickable).
* `sessionId` is the chat session the click came from, so relative paths
* can resolve against that session's worktree. */
@@ -4202,8 +4179,6 @@ export function ChatPanel({
label: string | undefined,
intent: TabOpenIntent,
) => void;
/** Open the pinned Files home for the active session. */
onOpenWorktree: () => void;
runtime: RuntimeInfo;
/** Reopen the demo welcome modal from the chat header. */
onOpenDemoWelcome?: () => void;
@@ -4779,6 +4754,16 @@ export function ChatPanel({
const activeLeafId = activeId ? (state.activeLeafBySession[activeId] ?? null) : null;
activeLeafRef.current = activeLeafId;
const messages = useMemo(() => activePath(allMessages, activeLeafId), [allMessages, activeLeafId]);
const previousBusySessions = useRef({ projectId, ids: state.busySessions });
useEffect(() => {
const previous = previousBusySessions.current;
previousBusySessions.current = { projectId, ids: state.busySessions };
if (previous.projectId !== projectId) return;
setUnreadSessionIds((current) => unreadAfterBusyChange(
current, previous.ids, state.busySessions, sessions, mainView === "chat" ? activeId : null,
));
}, [projectId, state.busySessions, sessions, activeId, mainView]);
const busy = activeId ? state.busySessions.has(activeId) : false;
const canFork = !busy && !!activeHarness?.agentReady;
const hasPendingTailTool = busy && streamTailTool(messages) != null;
@@ -5661,7 +5646,7 @@ export function ChatPanel({
const visibleSessions = sessions.filter((s) => matchesFilter(sessionFilter, s.archived));
const isApple = /Mac|iPhone|iPad/.test(navigator.platform);
const newTaskShortcut = isApple ? "⌘ ⇧ Enter" : "Ctrl + Shift + Enter";
const newTaskShortcut = isApple ? "⌘ ⇧ ↵" : "Ctrl + Shift + ↵";
const queueChord = isApple ? "⌘ Enter" : "Ctrl + Enter";
const startNewTask = useCallback(() => {
setSessionFilter("active");
@@ -5695,31 +5680,18 @@ export function ChatPanel({
}, [startNewTask]);
const rail = (
<aside className="session-rail w-68 shrink-0 flex flex-col mt-5 me-3.5 mb-5 ms-0 bg-background min-h-0 [&_.rail-body]:flex-1 [&_.rail-body]:min-h-0 [&_.rail-body]:overflow-y-auto [&_.rail-body]:py-1 [&_.rail-body]:px-2 border border-border rounded-lg overflow-visible shadow-elevated">
<aside className="session-rail w-68 shrink-0 flex flex-col mt-5 me-3.5 mb-5 ms-0 bg-background min-h-0 [&_.rail-body]:flex-1 [&_.rail-body]:min-h-0 [&_.rail-body]:overflow-y-auto [&_.rail-body]:pt-0 [&_.rail-body]:pb-1 [&_.rail-body]:px-2 border border-border rounded-lg overflow-visible shadow-elevated">
{railHeader}
{/* Workspace tools open beside chat; settings sections replace the middle pane. */}
<nav className="rail-nav flex flex-col gap-0.5 p-2 shrink-0">
<button
className={`rail-nav-item flex items-center gap-2.5 py-[7px] px-2.5 text-base text-text rounded-md text-start [&:hover:not(.active)]:bg-surface [&.active]:bg-panel [&.active]:font-medium ${filesActive ? "active" : ""}`}
onClick={onOpenWorktree}
className="rail-nav-item flex items-center gap-2.5 py-[7px] px-2.5 text-base text-text rounded-md text-start hover:bg-surface [&[data-tip]::after]:top-auto [&[data-tip]::after]:bottom-[calc(100%_+_6px)]"
data-onboarding="new-session"
data-tip={newTaskShortcut}
aria-keyshortcuts="Meta+Shift+Enter Control+Shift+Enter"
onClick={startNewTask}
>
<FolderOpen size={15} />
{m.chat_panel_files()}
</button>
<button
className={`rail-nav-item flex items-center gap-2.5 py-[7px] px-2.5 text-base text-text rounded-md text-start [&:hover:not(.active)]:bg-surface [&.active]:bg-panel [&.active]:font-medium ${artifactsActive ? "active" : ""}`}
data-onboarding="nav-artifacts"
onClick={onOpenArtifacts}
>
<Package size={15} />
{m.chat_panel_artifacts()}
</button>
<button
className={`rail-nav-item flex items-center gap-2.5 py-[7px] px-2.5 text-base text-text rounded-md text-start [&:hover:not(.active)]:bg-surface [&.active]:bg-panel [&.active]:font-medium ${experimentsActive ? "active" : ""}`}
onClick={onOpenExperiments}
>
<FlaskConical size={15} />
{m.chat_panel_experiments()}
<Plus size={15} />
{m.chat_panel_new_chat()}
</button>
<button
className={`rail-nav-item flex items-center gap-2.5 py-[7px] px-2.5 text-base text-text rounded-md text-start [&:hover:not(.active)]:bg-surface [&.active]:bg-panel [&.active]:font-medium ${mainView === "skills" ? "active" : ""}`}
@@ -5740,21 +5712,11 @@ export function ChatPanel({
</button>
))}
</nav>
<div className="rail-section-head flex items-center justify-between shrink-0 pt-3.5 pe-2.5 pb-1.5 ps-4.5">
<div className="rail-section-head flex items-center justify-between shrink-0 pt-3.5 pe-2.5 pb-0 ps-4.5">
<div className="rail-section-label p-0 text-sm font-medium text-subtext">
{SESSION_FILTERS.find((f) => f.id === sessionFilter)?.railLabel() ?? m.chat_recents()}
</div>
<div className="rail-section-actions flex items-center gap-0.5">
<button
className="rail-section-new inline-flex items-center gap-1 py-[3px] px-1.5 rounded-sm text-subtext text-sm font-medium [&:hover]:text-text [&:hover]:bg-surface tip-up [&[data-tip]::after]:top-auto [&[data-tip]::after]:bottom-[calc(100%_+_6px)]"
data-onboarding="new-session"
data-tip={newTaskShortcut}
aria-keyshortcuts="Meta+Shift+Enter Control+Shift+Enter"
onClick={startNewTask}
>
<Plus size={13} />
{m.chat_panel_task()}
</button>
<SessionFilterMenu value={sessionFilter} onChange={setSessionFilter} />
</div>
</div>
@@ -5786,12 +5748,6 @@ export function ChatPanel({
});
}
}
setUnreadSessionIds((current) => {
if (!current.has(s.id)) return current;
const next = new Set(current);
next.delete(s.id);
return next;
});
}}
onRename={(title) => rename(s, title)}
onSetArchived={(archived) => setArchived(s, archived)}
@@ -5843,18 +5799,14 @@ export function ChatPanel({
</aside>
);
// With the rail hidden, the header stretches to the full pane width
// (Claude-desktop style): the reopen toggle sits in the window's top-left
// corner with the title beside it, instead of riding the centered readable
// column.
const headerClass = `chat-header flex items-center gap-2 py-0 px-4 bg-background shrink-0 h-12 relative z-4 w-full max-w-readable my-0 mx-auto [&.rail-hidden]:max-w-none [&.rail-hidden]:py-0 [&.rail-hidden]:px-0.5 [&::after]:content-[''] [&::after]:absolute [&::after]:top-full [&::after]:start-0 [&::after]:end-0 [&::after]:h-6 [&::after]:bg-[linear-gradient(to_bottom,_var(--base),_transparent)] [&::after]:pointer-events-none${railOpen ? "" : " rail-hidden"}`;
const headerClass = `chat-header flex items-center gap-2 py-0 px-4 bg-background shrink-0 h-12 relative z-4 w-full max-w-readable my-0 mx-auto [&::after]:content-[''] [&::after]:absolute [&::after]:top-full [&::after]:start-0 [&::after]:end-0 [&::after]:h-6 [&::after]:bg-[linear-gradient(to_bottom,_var(--base),_transparent)] [&::after]:pointer-events-none`;
const railReopen = !railOpen && (
<IconButton
title={m.chat_panel_show_sidebar()}
aria-label={m.chat_panel_show_sidebar()}
onClick={onShowRail}
>
<PanelLeft size={15} />
<PanelLeft size={20} />
</IconButton>
);
@@ -5863,7 +5815,7 @@ export function ChatPanel({
<>
{railOpen && rail}
<section className="chat-pane flex-1 min-w-0 flex flex-col bg-background min-h-0">
{!railOpen && <div className={headerClass}>{railReopen}</div>}
{!railOpen && <div className="flex h-12 shrink-0 items-center">{railReopen}</div>}
<div className="settings-view-scroll flex-1 min-h-0 overflow-y-auto [scrollbar-gutter:stable_both-edges]">{children}</div>
</section>
</>
@@ -5873,11 +5825,12 @@ export function ChatPanel({
return (
<>
{railOpen && rail}
<section className="chat-pane flex-1 min-w-0 flex flex-col bg-background min-h-0">
<section className="chat-pane flex-1 min-w-0 flex flex-col bg-background min-h-0 mt-5">
{/* Header — session title on the left, end-pane view switchers on the
right, fading into the chat below (sessions live in the rail). */}
<div className={headerClass}>
<div className={railOpen ? "contents" : "grid shrink-0 grid-cols-[2rem_minmax(0,1fr)_2rem] items-center"}>
{railReopen}
<div className={headerClass}>
<PaperTitle variant="header"
title={activeSession ? activeSession.title?.trim() || m.chat_untitled() : m.chat_new_session()}
>
@@ -5900,6 +5853,7 @@ export function ChatPanel({
<HelpCircle size={15} />
</IconButton>
)}
</div>
</div>
{historyError ? (
+1 -1
View File
@@ -32,7 +32,7 @@ export function ClosableTab({
}) {
return (
<button
className={`tab [&.closable]:max-w-60 [&.closable]:pe-0.5 [&_.tab-label]:grid [&_.tab-label]:grid-cols-[minmax(0,_1fr)] [&_.tab-label]:min-w-0 [&_.tab-label]:overflow-hidden [&_.tab-label_>_span]:[grid-area:1_/_1] [&_.tab-label_>_span]:overflow-hidden [&_.tab-label_>_span]:text-ellipsis [&_.tab-label_>_span]:whitespace-nowrap [&_.tab-label::after]:[grid-area:1_/_1] [&_.tab-label::after]:overflow-hidden [&_.tab-label::after]:text-ellipsis [&_.tab-label::after]:whitespace-nowrap [&_.tab-label::after]:content-[attr(data-label)] [&_.tab-label::after]:invisible [&_.tab-label::after]:font-medium [&_.tab-close]:inline-flex [&_.tab-close]:items-center [&_.tab-close]:justify-center [&_.tab-close]:w-3.5 [&_.tab-close]:h-3.5 [&_.tab-close]:rounded-xs [&_.tab-close]:text-muted [&_.tab-close]:shrink-0 [&_.tab-close:hover]:bg-hover-strong [&_.tab-close:hover]:text-text relative inline-flex items-center gap-[5px] h-8 py-0 px-2 border border-transparent border-b-0 rounded-[var(--radius-md)_var(--radius-md)_0_0] text-sm font-normal text-subtext whitespace-nowrap select-none min-w-24 [&:hover]:bg-surface [&:hover]:text-text [&:not(.active)_+_.tab:not(.active)::before]:content-[''] [&:not(.active)_+_.tab:not(.active)::before]:absolute [&:not(.active)_+_.tab:not(.active)::before]:top-2.5 [&:not(.active)_+_.tab:not(.active)::before]:bottom-2.5 [&:not(.active)_+_.tab:not(.active)::before]:-start-px [&:not(.active)_+_.tab:not(.active)::before]:w-px [&:not(.active)_+_.tab:not(.active)::before]:bg-border [&.active]:border-border [&.active]:bg-background [&.active]:text-text [&.active]:font-medium [&.active::after]:content-[''] [&.active::after]:absolute [&.active::after]:end-0 [&.active::after]:-bottom-px [&.active::after]:start-0 [&.active::after]:h-px [&.active::after]:bg-background closable ${active ? "active" : ""} ${preview ? PREVIEW_CLASS_NAME : ""}`}
className={`tab [&.closable]:max-w-60 [&.closable]:pe-0.5 [&_.tab-label]:grid [&_.tab-label]:grid-cols-[minmax(0,_1fr)] [&_.tab-label]:min-w-0 [&_.tab-label]:overflow-hidden [&_.tab-label_>_span]:[grid-area:1_/_1] [&_.tab-label_>_span]:overflow-hidden [&_.tab-label_>_span]:text-ellipsis [&_.tab-label_>_span]:whitespace-nowrap [&_.tab-label::after]:[grid-area:1_/_1] [&_.tab-label::after]:overflow-hidden [&_.tab-label::after]:text-ellipsis [&_.tab-label::after]:whitespace-nowrap [&_.tab-label::after]:content-[attr(data-label)] [&_.tab-label::after]:invisible [&_.tab-label::after]:font-medium [&_.tab-close]:inline-flex [&_.tab-close]:items-center [&_.tab-close]:justify-center [&_.tab-close]:w-3.5 [&_.tab-close]:h-3.5 [&_.tab-close]:rounded-xs [&_.tab-close]:text-muted [&_.tab-close]:shrink-0 [&_.tab-close:hover]:bg-hover-strong [&_.tab-close:hover]:text-text relative inline-flex items-center gap-[5px] h-8 py-0 px-2 border border-transparent border-b-0 rounded-[var(--radius-md)_var(--radius-md)_0_0] text-sm font-normal text-subtext whitespace-nowrap select-none min-w-0 [&:hover]:bg-surface [&:hover]:text-text [&:not(.active)_+_.tab:not(.active)::before]:content-[''] [&:not(.active)_+_.tab:not(.active)::before]:absolute [&:not(.active)_+_.tab:not(.active)::before]:top-2.5 [&:not(.active)_+_.tab:not(.active)::before]:bottom-2.5 [&:not(.active)_+_.tab:not(.active)::before]:-start-px [&:not(.active)_+_.tab:not(.active)::before]:w-px [&:not(.active)_+_.tab:not(.active)::before]:bg-border [&.active]:border-border [&.active]:bg-background [&.active]:text-text [&.active]:font-medium [&.active::after]:content-[''] [&.active::after]:absolute [&.active::after]:end-0 [&.active::after]:-bottom-px [&.active::after]:start-0 [&.active::after]:h-px [&.active::after]:bg-background closable ${active ? "active" : ""} ${preview ? PREVIEW_CLASS_NAME : ""}`}
onClick={onSelect}
onDoubleClick={onPromote}
title={preview ? m.tab_preview_title({ label }) : label}
+7 -4
View File
@@ -1,6 +1,7 @@
import { WorkspaceEmptyState } from "./WorkspaceEmptyState";
import { m } from "../paraglide/messages.js";
import { ltr } from "../i18n";
import { CircleStop, FolderTree, GitBranch, Terminal } from "lucide-react";
import { FlaskConical, CircleStop, FolderTree, GitBranch, Terminal } from "lucide-react";
import { useState } from "react";
import { fmtNumber, runDisplayStatus, timeAgo, type Experiment, type Run } from "../api";
import { StatusBadge } from "./StatusBadge";
@@ -44,9 +45,11 @@ export function ExperimentsTable({
if (sortedExperiments.length === 0) {
return (
<div className="empty-state absolute inset-0 flex flex-col items-center justify-center gap-2.5 p-6 text-center text-subtext [&_p]:max-w-[46ch] [&_p]:m-0 [&_p]:leading-normal [&_p]:text-balance [&_p.empty-state-title]:text-2xl [&_p.empty-state-title]:font-normal [&_p.empty-state-title]:text-text [&_p.empty-state-hint]:text-lg [&_p.empty-state-hint]:text-subtext experiments-empty-state [&_p]:text-2xl">
<p>{emptyHint ?? m.experiments_none_yet()}</p>
</div>
<WorkspaceEmptyState
icon={FlaskConical}
title={emptyHint ?? m.experiments_none_yet()}
description={emptyHint ? undefined : m.experiments_empty_description()}
/>
);
}
+10 -17
View File
@@ -42,16 +42,13 @@ const DIFF_CLASS_NAME = [
"[&_.openresearch-diff-file]:[--diff-code-delete-edit-text-color:var(--diff-text-color)]",
"[&_.openresearch-diff-file]:[--diff-code-delete-edit-background-color:var(--color-diff-delete-edit)]",
"[&_.openresearch-diff-file]:[--diff-omit-gutter-line-color:var(--color-diff-omit-gutter)]",
"[&_.openresearch-diff-file]:w-full [&_.openresearch-diff-file]:text-sm",
"[&_.openresearch-diff-file]:leading-[1.55] [&_.openresearch-diff-file.diff-unified]:table-auto",
"[&_.openresearch-diff-file]:w-full",
"[&_.openresearch-diff-file.diff-unified]:table-fixed",
"[&_.openresearch-diff-file.diff-unified_col.diff-gutter-col:first-child]:collapse",
"[&_.openresearch-diff-file.diff-unified_col.diff-gutter-col:first-child]:w-0",
"[&_.openresearch-diff-file.diff-unified_col.diff-gutter-col:nth-child(2)]:w-[1%]",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:first-child]:hidden",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:sticky",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:start-0",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:z-1",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:w-[1%]",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:first-child]:p-0",
"[&_.openresearch-diff-file.diff-unified_col.diff-gutter-col:nth-child(2)]:w-[7ch]",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:w-[7ch]",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pt-0 [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pe-2.5 [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:pb-0 [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:ps-3.5",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:whitespace-nowrap",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:text-end",
@@ -59,13 +56,9 @@ const DIFF_CLASS_NAME = [
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:border-e [&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:border-e-border",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:select-none",
"[&_.openresearch-diff-file.diff-unified_.diff-line_>_td:nth-child(2)]:cursor-default",
"[&_.openresearch-diff-file_.diff-line]:leading-[1.55]",
"[&_.openresearch-diff-file_.diff-line:has(.diff-code-insert)]:bg-diff-insert-code",
"[&_.openresearch-diff-file_.diff-line:has(.diff-code-delete)]:bg-diff-delete-code",
"[&_.openresearch-diff-file_.diff-code]:py-0 [&_.openresearch-diff-file_.diff-code]:px-4",
"[&_.openresearch-diff-file_.diff-code]:whitespace-pre",
"[&_.openresearch-diff-file_.diff-code]:break-normal",
"[&_.openresearch-diff-file_.diff-code]:wrap-normal",
"[&_.openresearch-diff-file_.diff-code]:py-0 [&_.openresearch-diff-file_.diff-code]:ps-[2ch] [&_.openresearch-diff-file_.diff-code]:pe-4",
"[&_.openresearch-diff-file_.diff-hunk_+_.diff-hunk_.diff-line:first-child_>_td]:border-t [&_.openresearch-diff-file_.diff-hunk_+_.diff-hunk_.diff-line:first-child_>_td]:border-t-border",
].join(" ");
@@ -127,7 +120,7 @@ function tokenizeDiffFile(file: FileData): HunkTokens {
return tokenize(file.hunks, { enhancers, highlight: false });
}
function parseDiffFiles(diff: string, partial: boolean): { files: FileData[]; failed: boolean } {
export function parseDiffFiles(diff: string, partial: boolean): { files: FileData[]; failed: boolean } {
if (!diff.trim()) return { files: [], failed: false };
try {
return { files: parseDiff(diff, { nearbySequences: "zip" }), failed: false };
@@ -194,7 +187,7 @@ function DiffFileCard({
return (
<section className={`diff-file-card overflow-hidden border border-border rounded-md bg-background [&.expanded_.diff-file-header]:border-b [&.expanded_.diff-file-header]:border-b-border ${expanded ? "expanded" : ""}`}>
<button
className="diff-file-header sticky top-0 z-10 flex items-center justify-between gap-3 w-full text-start py-2 px-3 bg-canvas cursor-pointer [&_.chev]:text-muted [&_.chev]:text-xs [&_.chev]:shrink-0 [&_.chev]:w-3 [&_.path]:flex [&_.path]:items-center [&_.path]:gap-2 [&_.path]:min-w-0 [&_.path]:flex-1 [&_.path_code]:min-w-0 [&_.path_code]:flex-1 [&_.path_code]:overflow-hidden [&_.path_code]:text-ellipsis [&_.path_code]:whitespace-nowrap [&_.path_code]:font-mono [&_.path_code]:text-xs [&_.path_code]:font-semibold [&_.path_code]:text-text [&_.stats]:flex [&_.stats]:items-center [&_.stats]:gap-2 [&_.stats]:shrink-0 [&_.stats]:font-mono [&_.stats]:text-xs [&_.stats]:font-medium [&_.stats]:tabular-nums"
className="diff-file-header sticky top-0 z-10 flex items-center justify-between gap-3 w-full text-start py-2 px-3 bg-canvas cursor-pointer [&_.chev]:text-muted [&_.chev]:text-xs [&_.chev]:shrink-0 [&_.chev]:w-3 [&_.path]:flex [&_.path]:items-center [&_.path]:gap-2 [&_.path]:min-w-0 [&_.path]:flex-1 [&_.path_code]:min-w-0 [&_.path_code]:flex-1 [&_.path_code]:overflow-hidden [&_.path_code]:text-ellipsis [&_.path_code]:whitespace-nowrap [&_.path_code]:font-mono [&_.path_code]:text-xs [&_.path_code]:font-medium [&_.path_code]:text-text [&_.stats]:flex [&_.stats]:items-center [&_.stats]:gap-2 [&_.stats]:shrink-0 [&_.stats]:font-mono [&_.stats]:text-xs [&_.stats]:font-medium [&_.stats]:tabular-nums"
aria-expanded={expanded}
onClick={() => setExpanded((e) => !e)}
>
@@ -213,7 +206,7 @@ function DiffFileCard({
(file.hunks.length === 0 ? (
<div className="diff-empty py-2 px-3 text-muted text-sm">{m.git_diff_no_textual_diff_for_this_file()}</div>
) : (
<div className="diff-file-body overflow-x-auto bg-background">
<div className="diff-file-body min-w-0 py-3.5 bg-background">
<Diff
className="openresearch-diff-file"
diffType={file.type}
@@ -297,7 +290,7 @@ export function GitDiffExplorer({ diff, partial = false }: { diff: string; parti
return (
<div className="diff-explorer @container">
<div className="diff-explorer-toolbar flex items-center justify-between gap-3 mb-2.5 text-sm [&_button]:py-0.5 [&_button]:px-0 [&_button]:text-muted [&_button]:text-sm [&_button]:font-medium [&_button:hover]:text-text [&_button:hover]:underline [&_button:hover]:underline-offset-2">
<strong>
<strong className="font-medium">
{partial
? items.length === 1 ? m.git_diff_one_file_partial() : m.git_diff_files_partial({ count: fmtNumber(items.length) })
: items.length === 1
+24 -26
View File
@@ -45,29 +45,36 @@ export function RailHeader({
}, [open]);
return (
<div className="rail-brand flex items-center gap-1 h-16 p-2 border-b border-b-border shrink-0 [&_.project-switcher]:relative [&_.project-switcher]:flex-1 [&_.project-switcher]:self-stretch [&_.project-switcher]:min-w-0 [&_.project-back]:shrink-0 [&_.brand]:flex [&_.brand]:items-center [&_.brand]:justify-between [&_.brand]:gap-2 [&_.brand]:w-full [&_.brand]:h-full [&_.brand]:min-w-0 [&_.brand]:font-semibold [&_.brand]:text-base [&_.brand]:text-text [&_.brand]:py-1 [&_.brand]:px-1.5 [&_.brand]:border [&_.brand]:border-transparent [&_.brand]:rounded-sm [&_.brand:hover]:bg-surface [&_.brand:hover]:border-border [&_.brand.open]:bg-surface [&_.brand.open]:border-border [&_.brand_svg]:shrink-0 [&_.brand-project-copy]:flex [&_.brand-project-copy]:flex-col [&_.brand-project-copy]:gap-[3px] [&_.brand-project-copy]:min-w-0 [&_.brand-project-copy]:leading-[1.15] [&_.brand-project-copy]:text-start [&_.brand-project-label]:text-muted [&_.brand-project-label]:text-xs [&_.brand-project-label]:font-medium [&_.brand-project-label]:tracking-[0.04em] [&_.brand-project-label]:uppercase [&_.brand_.brand-project]:min-w-0 [&_.brand_.brand-project]:overflow-hidden [&_.brand_.brand-project]:text-ellipsis [&_.brand_.brand-project]:whitespace-nowrap [&_.brand_.brand-project]:text-xl [&_.project-chevron]:text-muted [&_.project-chevron]:opacity-0 [&_.project-chevron]:transition-transform [&_.project-chevron]:duration-120 [&_.project-chevron]:ease-standard [&_.brand:hover_.project-chevron]:opacity-100 [&_.brand.open_.project-chevron]:opacity-100 [&_.brand.open_.project-chevron]:rotate-180 [&_.project-menu]:start-0 [&_.project-menu]:w-52.5 [&_.project-menu]:z-70">
<IconButton
className="project-back text-text"
aria-label={m.header_all_projects()}
onClick={onHome}
>
<ArrowLeft size={18} />
</IconButton>
<div className="project-switcher" ref={ref}>
<div className="rail-brand px-3 py-1.5 border-b border-border shrink-0">
<div className="project-switcher relative min-w-0" ref={ref}>
<div className="flex h-7 items-center justify-between gap-1 px-0.5">
<IconButton size="small" className="project-back text-text" aria-label={m.header_all_projects()} onClick={onHome}>
<ArrowLeft size={18} />
</IconButton>
<span className="brand-project-label flex-1 text-xs font-medium tracking-wide uppercase text-subtext">{m.header_project()}</span>
{onCollapse && (
<IconButton
size="small"
data-tip={m.header_hide_sidebar()}
data-tip-align="end"
aria-label={m.header_hide_sidebar()}
onClick={onCollapse}
>
<PanelLeft size={18} />
</IconButton>
)}
</div>
<button
ref={projectButtonRef}
className={`brand${open ? " open" : ""}`}
className={`brand group flex h-8 w-full min-w-0 items-center gap-2 rounded-md px-2 text-start text-text hover:bg-surface focus-visible:outline-2 focus-visible:outline-text ${open ? "open bg-surface" : ""}`}
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
>
<span className="brand-project-copy">
<span className="brand-project-label">{m.header_project()}</span>
<span className="brand-project">{projectName}</span>
</span>
<ChevronDown className="project-chevron" size={14} />
<span className="brand-project min-w-0 flex-1 truncate text-xl font-semibold">{projectName}</span>
<ChevronDown className={`project-chevron shrink-0 text-muted transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 ${open ? "rotate-180 opacity-100" : "opacity-0"}`} size={14} />
</button>
{open && (
<div className="option-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-menu z-50 overflow-hidden min-w-47.5 p-1.5 [&.align-right]:start-auto [&.align-right]:end-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:start-auto [&.session-menu]:end-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 drop-down project-menu">
<div className="option-menu absolute bottom-[calc(100%_+_8px)] start-0 max-h-95 flex flex-col bg-background border border-border rounded-lg shadow-menu overflow-hidden min-w-47.5 p-1.5 [&.align-right]:start-auto [&.align-right]:end-0 [&.drop-down]:bottom-auto [&.drop-down]:top-[calc(100%_+_4px)] [&.session-menu]:start-auto [&.session-menu]:end-1.5 [&.session-menu]:top-[calc(100%_-_2px)] [&.session-menu]:min-w-35 drop-down project-menu w-52.5 z-70">
<MenuItem
onClick={() => {
setOpen(false);
@@ -98,16 +105,7 @@ export function RailHeader({
</div>
)}
</div>
{onCollapse && (
<IconButton
data-tip={m.header_hide_sidebar()}
data-tip-align="end"
aria-label={m.header_hide_sidebar()}
onClick={onCollapse}
>
<PanelLeft size={15} />
</IconButton>
)}
</div>
);
}
+1 -12
View File
@@ -1,3 +1,4 @@
import { TARGET_LABELS } from "../computeTargets";
import {
setScopedQueryData,
workspaceScope,
@@ -1332,18 +1333,6 @@ function OpenResearchSection({ remote }: { remote: boolean }) {
// --- compute -----------------------------------------------------------------
const TARGET_LABELS: Record<ComputeTargetId, () => string> = {
local: m.compute_target_local,
tinker: m.compute_target_tinker,
hf: m.compute_target_hf,
modal: m.compute_target_modal,
k8s: m.compute_target_k8s,
ssh: m.compute_target_ssh,
slurm: m.compute_target_slurm,
ray: m.compute_target_ray,
openresearch: m.compute_target_openresearch,
};
const TARGET_CARD_DESCRIPTIONS: Record<ComputeTargetId, () => string> = {
local: m.compute_description_local,
ssh: m.compute_description_ssh,
+15
View File
@@ -0,0 +1,15 @@
import type { LucideIcon } from "lucide-react";
export function WorkspaceEmptyState({ icon: Icon, title, description }: {
icon: LucideIcon;
title: string;
description?: string;
}) {
return (
<div className="flex h-full w-full min-w-0 flex-1 flex-col items-center justify-center gap-1.5 bg-background p-6 text-center text-muted">
<Icon size={36} strokeWidth={1.5} className="shrink-0" />
<h3 className="mt-1.5 mb-0 max-w-full wrap-anywhere text-xl font-medium text-text">{title}</h3>
{description && <p className="m-0 w-full max-w-105 wrap-anywhere text-base leading-[1.55] text-subtext">{description}</p>}
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { type Experiment, type Run, runDisplayStatus, fmtNumber } from "../api";
import { activeWorkspaceRuns } from "../workspaceRuns";
import { statusLabel } from "./StatusBadge";
import { getComputeSettingsQuery } from "../queries/settings";
import { TARGET_LABELS } from "../computeTargets";
import { useEffect, useMemo, useRef } from "react";
import { FlaskConical, FolderOpen, Package, GitBranch, Cpu } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { getSessionWorktreeQuery } from "../queries/files";
import { countChanges, parseDiffFiles } from "./GitDiff";
import { m } from "../paraglide/messages.js";
import { BackendLogo } from "./BackendLogos";
import { IconButton, MenuItem, StatusIndicator } from "./ui";
export function WorkspaceTools({ expanded, experiments, runs, onOpenExperiment, rightOffset, activeView, projectId, onCompute, sessionId, busy, onChanges, onFiles, onArtifacts, onExperiments }: {
expanded: boolean;
experiments: Experiment[];
runs: Run[];
onOpenExperiment: (id: string, runId: string) => void;
rightOffset?: number;
activeView: "files" | "artifacts" | "experiments" | null;
projectId: string;
onCompute: () => void;
sessionId: string | null;
busy: boolean;
onChanges: () => void;
onFiles: () => void;
onArtifacts: () => void;
onExperiments: () => void;
}) {
const experimentRows = activeWorkspaceRuns(experiments, runs, sessionId);
const compute = useQuery({ ...getComputeSettingsQuery(projectId), enabled: expanded });
const defaultBackend = compute.data?.configuredDefaultBackend ?? compute.data?.defaultBackend;
const computeLabel = defaultBackend ? TARGET_LABELS[defaultBackend]() : compute.isPending ? "…" : compute.isError ? m.model_picker_unavailable() : m.settings_not_set();
const items = [
{ id: "files", label: m.app_files(), Icon: FolderOpen, onClick: onFiles },
{ id: "artifacts", label: m.app_artifacts(), Icon: Package, onClick: onArtifacts },
{ id: "experiments", label: m.app_experiments(), Icon: FlaskConical, onClick: onExperiments },
];
return (
<div className="workspace-tools absolute end-3.5 top-7 z-30" style={{ insetInlineEnd: rightOffset }}>
{!expanded ? (
<nav aria-label={m.workspace_tools_heading()} className="flex items-center justify-end gap-3">
{items.map(({ id, label, Icon, onClick }) => (
<IconButton key={id} active={activeView === id} className="text-text [&.active]:text-text" data-tip={label} data-tip-align={id === "experiments" ? "end" : undefined} aria-label={label} aria-pressed={activeView === id} onClick={onClick}>
<Icon size={15} />
</IconButton>
))}
</nav>
) : (
<nav aria-label={m.workspace_tools_heading()} className="workspace-tools-card flex w-60 flex-col gap-0.5 rounded-xl border border-border bg-background px-1.5 py-2 shadow-elevated">
<h2 className="m-0 px-2 pt-1 pb-2 text-sm font-normal text-subtext">{m.workspace_tools_heading()}</h2>
{items.filter((item) => item.id !== "files").map(({ id, label: itemLabel, Icon, onClick }) => (
<MenuItem key={id} className="min-h-7 py-1"
data-onboarding={id === "artifacts" ? "nav-artifacts" : undefined}
onClick={onClick}>
<span className="flex items-center gap-4"><Icon size={15} />{itemLabel}</span>
</MenuItem>
))}
<MenuItem className="py-1" onClick={onCompute}>
<span className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-center gap-4 text-sm text-text"><Cpu size={15} className="shrink-0" />{m.workspace_default_compute()}</span>
<span className="flex items-center gap-1.5 ps-[31px] text-menu text-subtext">
{defaultBackend && <BackendLogo kind={`${defaultBackend}_job`} size={12} />}
<span className="wrap-anywhere">{computeLabel}</span>
</span>
</span>
</MenuItem>
<div className="mt-2 border-t border-border/50 pt-3">
<h2 className="m-0 px-2 pt-1 pb-2 text-sm font-normal text-subtext">{m.workspace_this_worktree()}</h2>
<MenuItem className="min-h-7 py-1" onClick={onFiles}>
<span className="flex items-center gap-4"><FolderOpen size={15} />{m.app_files()}</span>
</MenuItem>
{sessionId && <ChatBranch key={sessionId} sessionId={sessionId} busy={busy} onChanges={onChanges} />}
{experimentRows.length > 0 && (
<div>
<h2 className="m-0 flex items-center gap-4 px-2 py-1 text-sm font-normal text-text">
<span className="flex w-[15px] shrink-0 justify-center"><StatusIndicator tone="success" live /></span>
<span>{m.workspace_active_experiments()}</span>
</h2>
{experimentRows.map((row) => (
<MenuItem key={row.run.id} className="min-h-7 py-1" onClick={() => onOpenExperiment(row.experiment.id, row.run.id)}>
<span className="min-w-0 truncate ps-[31px] text-menu text-subtext" title={`${row.experiment.title || row.experiment.slug} · ${statusLabel(runDisplayStatus(row.run))}`}>{row.experiment.title || row.experiment.slug}</span>
</MenuItem>
))}
</div>
)}
</div>
</nav>
)}
</div>
);
}
function ChatBranch({ sessionId, busy, onChanges }: { sessionId: string; busy: boolean; onChanges: () => void }) {
const worktree = useQuery({ ...getSessionWorktreeQuery(sessionId), refetchInterval: busy ? 5_000 : false });
const wasBusy = useRef(busy);
const { refetch } = worktree;
useEffect(() => {
if (wasBusy.current && !busy) void refetch();
wasBusy.current = busy;
}, [busy, refetch]);
const wt = worktree.data;
const counts = useMemo(() => {
if (!wt?.diff || wt.diff.truncated) return null;
const parsed = parseDiffFiles(wt.diff.diff, false);
if (parsed.failed) return null;
return parsed.files.map(countChanges).reduce((total, file) => ({
additions: total.additions + file.additions,
deletions: total.deletions + file.deletions,
}), { additions: 0, deletions: 0 });
}, [wt?.diff]);
if (!wt?.exists) return null;
return (
<MenuItem className="min-h-7 py-1" onClick={onChanges} title={wt.branch ?? m.settings_detached()}>
<span className="flex min-w-0 items-center gap-4">
<GitBranch size={15} className="shrink-0" />
<span>{m.code_browser_header_changes()}</span>
</span>
{counts ? (
<span className="flex shrink-0 gap-1 text-xs tabular-nums">
<span className="text-accent-green">+{fmtNumber(counts.additions)}</span>
<span className="text-accent-red">−{fmtNumber(counts.deletions)}</span>
</span>
) : (
<span className="shrink-0 text-xs text-subtext">{wt.files?.length === 1 ? m.git_diff_one_changed_file() : m.git_diff_changed_file_count({ count: fmtNumber(wt.files?.length ?? 0) })}</span>
)}
</MenuItem>
);
}
+7 -1
View File
@@ -1,3 +1,5 @@
import { FolderOpen } from "lucide-react";
import { WorkspaceEmptyState } from "./WorkspaceEmptyState";
import { isImmutableQuery } from "../queries/invalidation";
import { useQuery } from "@tanstack/react-query";
import { listChatSessionsQuery } from "../queries/chat";
@@ -187,7 +189,11 @@ export function WorktreeTab({
{!filesTree ? (
<CodeTabNote>{m.worktree_tab_loading()}</CodeTabNote>
) : filesTree.dirs.size === 0 && filesTree.files.length === 0 ? (
<CodeTabNote>{m.worktree_tab_no_files()}</CodeTabNote>
<WorkspaceEmptyState
icon={FolderOpen}
title={m.worktree_tab_no_files()}
description={m.files_empty_description()}
/>
) : (
<div className="file-tree py-1.5 px-0 text-sm">
<TreeLevel
-1
View File
@@ -6,7 +6,6 @@ const BASE = [
"transition-[background,color] duration-120 ease-standard",
"focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-text focus-visible:outline-offset-2",
"disabled:cursor-default disabled:opacity-45",
"[.chat-header.rail-hidden_>_&:first-child]:me-3",
].join(" ");
type IconButtonSize = "default" | "small";
+14
View File
@@ -0,0 +1,14 @@
import type { ComputeTargetId } from "./api";
import { m } from "./paraglide/messages.js";
export const TARGET_LABELS: Record<ComputeTargetId, () => string> = {
local: m.compute_target_local,
tinker: m.compute_target_tinker,
hf: m.compute_target_hf,
modal: m.compute_target_modal,
k8s: m.compute_target_k8s,
ssh: m.compute_target_ssh,
slurm: m.compute_target_slurm,
ray: m.compute_target_ray,
openresearch: m.compute_target_openresearch,
};
+10
View File
@@ -0,0 +1,10 @@
import type { Experiment, Run } from "./api";
export function activeWorkspaceRuns(experiments: Experiment[], runs: Run[], sessionId: string | null) {
if (!sessionId) return [];
return runs.flatMap((run) => {
if (run.status !== "starting" && run.status !== "running") return [];
const experiment = experiments.find((item) => item.id === run.experimentId && item.chatSessionId === sessionId);
return experiment ? [{ experiment, run }] : [];
});
}
+12
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
unreadAfterBusyChange,
partIsVisible,
partsTailToolId,
streamTailIsText,
@@ -43,3 +44,14 @@ test("thinking replaces a text tail while steer and status parts do not", () =>
assert.equal(streamTailIsText([message(text, { id: "steer", type: "steer" })]), true);
assert.equal(streamTailIsText([message(text, { id: "turn-retry", type: "tool" })]), true);
});
test("completion marks only unseen existing chats unread and opening clears the dot", () => {
const sessions = [{ id: "active" }, { id: "background" }];
const busy = new Set(["active", "background", "deleted"]);
const initial = new Set();
assert.equal(unreadAfterBusyChange(initial, busy, busy, sessions, "active"), initial);
const finished = unreadAfterBusyChange(initial, busy, new Set(), sessions, "active");
assert.deepEqual([...finished], ["background"]);
assert.deepEqual([...unreadAfterBusyChange(finished, new Set(), new Set(), sessions, "background")], []);
assert.deepEqual([...unreadAfterBusyChange(initial, new Set(["active"]), new Set(), sessions, null)], ["active"]);
});
+3 -2
View File
@@ -5,11 +5,12 @@ import test from "node:test";
const source = (path) => readFile(new URL(path, import.meta.url), "utf8");
test("Tinker is always visible in compute and environment settings", async () => {
const [settings, logos] = await Promise.all([
const [settings, logos, targets] = await Promise.all([
source("../src/components/SettingsPage.tsx"),
source("../src/components/BackendLogos.tsx"),
source("../src/computeTargets.ts"),
]);
assert.match(settings, /tinker: m\.compute_target_tinker/);
assert.match(targets, /tinker: m\.compute_target_tinker/);
assert.match(settings, /target\.id === "tinker"/);
assert.match(settings, /"TINKER_API_KEY", "HF_TOKEN", "WANDB_API_KEY"/);
assert.match(logos, /case "tinker_job":\s+return <TinkerLogo/);
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import test from "node:test";
import { activeWorkspaceRuns } from "../src/workspaceRuns.ts";
test("active workspace runs stay scoped to an existing chat and retain the run destination", () => {
const experiments = [
{ id: "mine", chatSessionId: "chat" },
{ id: "other", chatSessionId: "other-chat" },
{ id: "cli", chatSessionId: null },
];
const runs = [
{ id: "starting", experimentId: "mine", status: "starting" },
{ id: "running", experimentId: "mine", status: "running" },
{ id: "done", experimentId: "mine", status: "done" },
{ id: "other", experimentId: "other", status: "running" },
{ id: "cli", experimentId: "cli", status: "running" },
{ id: "missing", experimentId: "missing", status: "running" },
];
assert.deepEqual(activeWorkspaceRuns(experiments, runs, null), []);
assert.deepEqual(activeWorkspaceRuns(experiments, runs, "chat").map(({ experiment, run }) => [experiment.id, run.id]), [["mine", "starting"], ["mine", "running"]]);
});