mirror of
https://github.com/alphaXiv/OpenResearch.git
synced 2026-11-11 09:59:34 +08:00
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:
Vendored
-1056
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
-1
File diff suppressed because one or more lines are too long
Vendored
+1051
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 }] : [];
|
||||
});
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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"]]);
|
||||
});
|
||||
Reference in New Issue
Block a user