From 65989c1fd52b1f65651cba8649c5de7d3fcbb62a Mon Sep 17 00:00:00 2001 From: tmoonlight Date: Mon, 7 Sep 2026 15:32:31 +0800 Subject: [PATCH] feat(webui): add sidebar session time preference and richer hover cards --- CMakeLists.txt | 2 +- src/config/config.cpp | 10 +++ src/config/config.hpp | 3 + src/desktop/main.cpp | 1 + src/web/routes/routes_misc.cpp | 11 +++ src/web/server_helpers.cpp | 1 + .../config/config_web_ui_preferences_test.cpp | 68 +++++++++++++++++++ tests/web/web_server_smoke_test.cpp | 38 +++++++++++ vcpkg.json | 36 +++++----- web/src/App.jsx | 12 +++- web/src/components/SettingsPage.jsx | 18 +++++ web/src/components/Sidebar.jsx | 54 +++++++++++---- web/src/lib/appearancePreferences.js | 13 ++++ web/src/lib/appearancePreferences.test.js | 15 ++++ web/src/lib/sessionHoverDetails.js | 15 ++-- web/src/lib/sessionHoverDetails.test.js | 55 ++++++++++++--- web/src/lib/uiPrefs.js | 10 ++- web/src/styles/globals.css | 7 ++ 18 files changed, 320 insertions(+), 49 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d0f1791..622bc516 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ if(CMAKE_HOST_WIN32 AND DEFINED CMAKE_TOOLCHAIN_FILE AND CMAKE_TOOLCHAIN_FILE MA set(VCPKG_TARGET_TRIPLET "x64-windows-static" CACHE STRING "Vcpkg target triplet" FORCE) endif() -project(acecode VERSION 0.9.12 LANGUAGES C CXX) +project(acecode VERSION 0.9.13 LANGUAGES C CXX) option(ACECODE_TUI_INPUT_TRACE "Enable verbose TUI input event trace logging." OFF) diff --git a/src/config/config.cpp b/src/config/config.cpp index 8af9c031..400c9b63 100644 --- a/src/config/config.cpp +++ b/src/config/config.cpp @@ -992,6 +992,14 @@ static AppConfig load_config_from_path_once( LOG_WARN("[config] invalid 'web_ui.font_size', using 'medium'"); } } + if (uij.contains("sidebar_session_time")) { + if (uij["sidebar_session_time"].is_boolean()) { + cfg.web_ui.sidebar_session_time = + uij["sidebar_session_time"].get(); + } else { + LOG_WARN("[config] invalid 'web_ui.sidebar_session_time', using true"); + } + } } } if (j.contains("models_dev") && j["models_dev"].is_object()) { @@ -2123,6 +2131,8 @@ nlohmann::json build_config_json(const AppConfig& cfg) { web_uij["color_theme"] = cfg.web_ui.color_theme; if (cfg.web_ui.font_size != web_ui_d.font_size) web_uij["font_size"] = cfg.web_ui.font_size; + if (cfg.web_ui.sidebar_session_time != web_ui_d.sidebar_session_time) + web_uij["sidebar_session_time"] = cfg.web_ui.sidebar_session_time; if (!web_uij.empty()) j["web_ui"] = std::move(web_uij); MemoryConfig mem_d; diff --git a/src/config/config.hpp b/src/config/config.hpp index c64340ec..5b335ae3 100644 --- a/src/config/config.hpp +++ b/src/config/config.hpp @@ -181,6 +181,9 @@ struct WebUiPreferencesConfig { std::string theme = "system"; // system | light | dark std::string color_theme = "blue"; // blue | orange std::string font_size = "medium"; // small | medium | large + // Sidebar session rows show a relative timestamp. Product default is on; + // turning it off leaves the time visible only in the row hover card. + bool sidebar_session_time = true; }; struct ModelsDevConfig { diff --git a/src/desktop/main.cpp b/src/desktop/main.cpp index 0cad604e..bbfdee1a 100644 --- a/src/desktop/main.cpp +++ b/src/desktop/main.cpp @@ -2162,6 +2162,7 @@ int main(int argc, char** argv) { {"theme", desktop_cfg.web_ui.theme}, {"color_theme", desktop_cfg.web_ui.color_theme}, {"font_size", desktop_cfg.web_ui.font_size}, + {"sidebar_session_time", desktop_cfg.web_ui.sidebar_session_time}, }.dump(); const std::string startup_bootstrap = startup_timeline.snapshot_json(); host.init_script(acecode::desktop::locale_bootstrap_script( diff --git a/src/web/routes/routes_misc.cpp b/src/web/routes/routes_misc.cpp index 0db28526..817c92ef 100644 --- a/src/web/routes/routes_misc.cpp +++ b/src/web/routes/routes_misc.cpp @@ -2249,6 +2249,13 @@ void WebServer::Impl::register_ui_preferences() { "font_size must be small, medium, or large"); } } + if (body.contains("sidebar_session_time")) { + has_supported_field = true; + if (!body["sidebar_session_time"].is_boolean()) { + return json_err(400, "BAD_REQUEST", + "sidebar_session_time must be a boolean"); + } + } if (!has_supported_field) { return json_err(400, "BAD_REQUEST", "no supported UI preference field was provided"); @@ -2269,6 +2276,10 @@ void WebServer::Impl::register_ui_preferences() { deps.app_config->web_ui.font_size = body["font_size"].get(); } + if (body.contains("sidebar_session_time")) { + deps.app_config->web_ui.sidebar_session_time = + body["sidebar_session_time"].get(); + } try { if (!deps.config_path.empty()) { save_config(*deps.app_config, deps.config_path); diff --git a/src/web/server_helpers.cpp b/src/web/server_helpers.cpp index 99eb158e..d08e45c9 100644 --- a/src/web/server_helpers.cpp +++ b/src/web/server_helpers.cpp @@ -77,6 +77,7 @@ json ui_preferences_to_json(const WebUiPreferencesConfig& cfg) { {"theme", cfg.theme}, {"color_theme", cfg.color_theme}, {"font_size", cfg.font_size}, + {"sidebar_session_time", cfg.sidebar_session_time}, }; } diff --git a/tests/config/config_web_ui_preferences_test.cpp b/tests/config/config_web_ui_preferences_test.cpp index c34bd726..857af3b6 100644 --- a/tests/config/config_web_ui_preferences_test.cpp +++ b/tests/config/config_web_ui_preferences_test.cpp @@ -144,6 +144,74 @@ TEST(ConfigWebUiPreferencesSave, NonDefaultAppearanceRoundTrips) { std::filesystem::remove(path, ec); } +// 场景:产品对「侧栏是否显示任务时间」有分歧,做成可配置项。期望:默认开, +// 只有显式 false 才关,非布尔值保留默认,默认值不写进 config.json(稀疏落盘 +// 约定),非默认值能完整往返。 +TEST(ConfigWebUiPreferencesSidebarSessionTime, DefaultsToShown) { + WebUiPreferencesConfig prefs; + EXPECT_TRUE(prefs.sidebar_session_time); + + AppConfig cfg; + EXPECT_TRUE(cfg.web_ui.sidebar_session_time); +} + +TEST(ConfigWebUiPreferencesSidebarSessionTime, ExplicitFalseLoads) { + const auto path = temp_config_path("session-time-false"); + write_json(path, nlohmann::json{ + {"web_ui", {{"sidebar_session_time", false}}}, + }); + + AppConfig cfg = load_config_from_path(path.string()); + EXPECT_FALSE(cfg.web_ui.sidebar_session_time); + + std::error_code ec; + std::filesystem::remove(path, ec); +} + +TEST(ConfigWebUiPreferencesSidebarSessionTime, NonBooleanKeepsDefault) { + const auto path = temp_config_path("session-time-invalid"); + write_json(path, nlohmann::json{ + {"web_ui", {{"sidebar_session_time", "no"}}}, + }); + + AppConfig cfg = load_config_from_path(path.string()); + EXPECT_TRUE(cfg.web_ui.sidebar_session_time); + + std::error_code ec; + std::filesystem::remove(path, ec); +} + +TEST(ConfigWebUiPreferencesSidebarSessionTime, OnlyNonDefaultIsPersisted) { + const auto path = temp_config_path("session-time-roundtrip"); + std::error_code ec; + std::filesystem::remove(path, ec); + + AppConfig cfg; + // 默认值不落盘:整个 web_ui 块应当仍然缺席。 + save_config(cfg, path.string()); + { + std::ifstream ifs(path); + ASSERT_TRUE(ifs.is_open()); + const auto saved = nlohmann::json::parse(ifs); + EXPECT_FALSE(saved.contains("web_ui")); + } + + cfg.web_ui.sidebar_session_time = false; + save_config(cfg, path.string()); + { + std::ifstream ifs(path); + ASSERT_TRUE(ifs.is_open()); + const auto saved = nlohmann::json::parse(ifs); + ASSERT_TRUE(saved.contains("web_ui")); + EXPECT_EQ(saved["web_ui"]["sidebar_session_time"], false); + } + + AppConfig loaded = load_config_from_path(path.string()); + EXPECT_FALSE(loaded.web_ui.sidebar_session_time); + + std::filesystem::remove(path, ec); +} + TEST(ConfigWebUiPreferencesValidation, AcceptsOnlyCanonicalValues) { EXPECT_TRUE(is_valid_web_ui_theme("system")); EXPECT_TRUE(is_valid_web_ui_theme("light")); diff --git a/tests/web/web_server_smoke_test.cpp b/tests/web/web_server_smoke_test.cpp index ec68854f..385b875c 100644 --- a/tests/web/web_server_smoke_test.cpp +++ b/tests/web/web_server_smoke_test.cpp @@ -7126,6 +7126,44 @@ TEST(WebServerHttp, PutUiPreferencesPersistsCompleteAppearance) { EXPECT_EQ(saved.web_ui.font_size, "large"); } +// 场景:侧栏任务时间开关经 ui-preferences 往返。期望:GET 默认返回 true; +// PUT false 时内存与磁盘同步落到 false,且不碰其它外观字段;非布尔值 400 拒绝。 +// 该字段对旧前端是纯增量,所以只做类型校验、不引入枚举白名单。 +TEST(WebServerHttp, UiPreferencesSidebarSessionTimeRoundTrips) { + WebServerFixture fx; + { + auto get = cpr::Get(cpr::Url{fx.url("/api/config/ui-preferences")}); + ASSERT_EQ(get.status_code, 200) << get.text; + EXPECT_EQ(json::parse(get.text)["sidebar_session_time"], true); + } + + json req = {{"sidebar_session_time", false}}; + auto put = cpr::Put(cpr::Url{fx.url("/api/config/ui-preferences")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{req.dump()}); + ASSERT_EQ(put.status_code, 200) << put.text; + EXPECT_EQ(json::parse(put.text)["sidebar_session_time"], false); + EXPECT_FALSE(fx.cfg.web_ui.sidebar_session_time); + // 单字段 PUT 不能顺手把其它外观字段冲回默认。 + EXPECT_EQ(fx.cfg.web_ui.theme, "system"); + EXPECT_EQ(fx.cfg.web_ui.color_theme, "blue"); + EXPECT_EQ(fx.cfg.web_ui.font_size, "medium"); + + const auto saved = acecode::load_config_from_path( + (fx.tmp_dir / "config.json").string()); + EXPECT_FALSE(saved.web_ui.sidebar_session_time); +} + +TEST(WebServerHttp, UiPreferencesSidebarSessionTimeRejectsNonBoolean) { + WebServerFixture fx; + json req = {{"sidebar_session_time", "no"}}; + auto put = cpr::Put(cpr::Url{fx.url("/api/config/ui-preferences")}, + cpr::Header{{"Content-Type", "application/json"}}, + cpr::Body{req.dump()}); + EXPECT_EQ(put.status_code, 400) << put.text; + EXPECT_TRUE(fx.cfg.web_ui.sidebar_session_time); +} + TEST(WebServerHttp, PutUiPreferencesPartialUpdatePreservesOtherAppearanceFields) { WebServerFixture fx; fx.server->with_app_config_lock([&] { diff --git a/vcpkg.json b/vcpkg.json index 104dfc84..bc4d98bd 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,20 +1,20 @@ { - "name": "acecode", - "version-semver": "0.9.12", - "dependencies": [ - "cpr", - "crow", - "ftxui", - "libzip", - "nlohmann-json", - "sqlite3" - ], - "features": { - "tests": { - "description": "Dependencies required only by ACECode unit tests", - "dependencies": [ - "gtest" - ] - } - } + "name": "acecode", + "version-semver": "0.9.13", + "dependencies": [ + "cpr", + "crow", + "ftxui", + "libzip", + "nlohmann-json", + "sqlite3" + ], + "features": { + "tests": { + "description": "Dependencies required only by ACECode unit tests", + "dependencies": [ + "gtest" + ] + } + } } diff --git a/web/src/App.jsx b/web/src/App.jsx index c847f818..e021ed8e 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -62,6 +62,7 @@ import { import { DEFAULT_UI_PREFS, effectiveFontSize, + effectiveSidebarSessionTime, effectiveSidePanelListCollapsed, UI_PREFS_STORAGE_KEY, validateUiPrefs, @@ -262,6 +263,7 @@ export function App() { const initialUiPrefs = useMemo(() => ({ ...DEFAULT_UI_PREFS, fontSize: initialAppearance.fontSize, + sidebarSessionTime: initialAppearance.sidebarSessionTime, }), [initialAppearance]); const [uiPrefs, setUiPrefs] = usePreference( UI_PREFS_STORAGE_KEY, initialUiPrefs, validateUiPrefs); @@ -290,10 +292,14 @@ export function App() { // grid4/grid9 入口暂时隐藏:主界面固定单会话,避免旧 localStorage 把用户卡在未完善视图。 const view = 'single'; const fontSize = effectiveFontSize(uiPrefs); + const sidebarSessionTime = effectiveSidebarSessionTime(uiPrefs); const applyAppearance = useCallback((next) => { setTheme(effectiveAppearanceTheme(next.theme)); setColorTheme(next.colorTheme); - setUiPrefs({ fontSize: next.fontSize }); + setUiPrefs({ + fontSize: next.fontSize, + sidebarSessionTime: next.sidebarSessionTime, + }); }, [setColorTheme, setTheme, setUiPrefs]); const appearanceControllerRef = useRef(null); if (!appearanceControllerRef.current) { @@ -302,6 +308,7 @@ export function App() { theme: bootstrapAppearance?.theme || theme, colorTheme, fontSize, + sidebarSessionTime, }, apply: applyAppearance, save: (payload) => api.setUiPreferences(payload), @@ -2016,6 +2023,7 @@ export function App() { onOpenExpertComponents={openExpertComponents} pendingPermissionSessionIds={pendingPermissionSessionIdsForSidebar} pendingQuestionSessionIds={pendingQuestionSessionIdsForSidebar} + showSessionTime={sidebarSessionTime} /> {view === 'single' && !sidebarCollapsed && (
changeAppearance({ fontSize: nextFontSize })} + sidebarSessionTime={sidebarSessionTime} + onSidebarSessionTimeChange={(next) => changeAppearance({ sidebarSessionTime: next })} /> )} {}, + sidebarSessionTime = true, + onSidebarSessionTimeChange = () => {}, }) { const { theme, @@ -282,6 +284,8 @@ export function SettingsPage({ setColorTheme={setColorTheme} fontSize={fontSize} onFontSizeChange={onFontSizeChange} + sidebarSessionTime={sidebarSessionTime} + onSidebarSessionTimeChange={onSidebarSessionTimeChange} /> )} {activeNavKey === 'config' && } @@ -1162,6 +1166,8 @@ function SectionAppearance({ setColorTheme, fontSize, onFontSizeChange, + sidebarSessionTime, + onSidebarSessionTimeChange, }) { return ( <> @@ -1230,6 +1236,18 @@ function SectionAppearance({ ); })}
+
+
侧边栏
+
+
+
显示任务时间
+
在任务列表每一行右侧显示最近活动时间,关闭后仍可在悬停卡片里查看
+
+ onSidebarSessionTimeChange(enabled)} + /> +
); } diff --git a/web/src/components/Sidebar.jsx b/web/src/components/Sidebar.jsx index a83e6844..482a9157 100644 --- a/web/src/components/Sidebar.jsx +++ b/web/src/components/Sidebar.jsx @@ -154,6 +154,10 @@ const SessionHoverLifecycleContext = createContext({ dispatch: () => {}, }); +// 侧栏显示偏好只有一个消费点在 SessionRow 深处,用 context 而不是逐层透传。 +// 默认 true:漏接线时维持现状(显示时间),不会让这一列凭空消失。 +const SidebarSessionTimeContext = createContext(true); + function sidebarSessionDragKey(workspaceHash, sessionId) { const ws = String(workspaceHash || ''); const id = String(sessionId || ''); @@ -342,6 +346,7 @@ function startSidebarSessionPointerDrag({ event, session, title, + showSessionTime = true, dragRef, conflictingDragRef, finishDrag, @@ -436,7 +441,9 @@ function startSidebarSessionPointerDrag({ offsetY: event.clientY - rect.top, rect, title, - timeText: relativeTime(session.updated_at || session.created_at), + timeText: showSessionTime + ? relativeTime(session.updated_at || session.created_at) + : '', dragging: false, cleanup, }; @@ -748,7 +755,7 @@ function SessionHoverCard({ window.removeEventListener('resize', updatePosition); document.removeEventListener('scroll', updatePosition, true); }; - }, [anchorRef, details?.branch]); + }, [anchorRef, details?.branch, details?.hasWorkspace, details?.updatedAt]); if (!details || typeof document === 'undefined') return null; @@ -770,7 +777,9 @@ function SessionHoverCard({ >
工作目录 - {details.cwd} + + {details.hasWorkspace ? details.cwd : '无工作区'} +
{details.isGitRepository && (
@@ -778,6 +787,14 @@ function SessionHoverCard({ {details.branch}
)} + {details.updatedAt && ( +
+ 最近活动 + + {relativeTime(details.updatedAt)} + +
+ )}
, document.body, ); @@ -904,6 +921,7 @@ function SessionRow({ const pointerFocusRef = useRef(false); const pointerFocusResetTimerRef = useRef(null); const sessionHoverLifecycle = useContext(SessionHoverLifecycleContext); + const showSessionTime = useContext(SidebarSessionTimeContext); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(title); const [remoteControlSurging, setRemoteControlSurging] = useState(false); @@ -1238,19 +1256,24 @@ function SessionRow({ {!editing && pendingPermission ? ( 权限请求 ) : !editing && pendingQuestion ? ( 等待回复 ) : null} + {showSessionTime && !editing && !pendingPermission && !pendingQuestion && ( + + {relativeTime(s.updated_at || s.created_at)} + + )} {pinEnabled && (