Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/STATUS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 2026-10-31 — 应用会话恢复返回按钮

- 紧凑标题栏根据应用返回动作显示按钮,标题加载后继续显示;菜单保持桌面应用侧栏与移动系统菜单的各自入口。
- 返回保留发现广场应用分类、应用中心、移动探索三种来源路径。
- 33 项相关回归、24 组 macOS Electron 隔离渲染/点击检查、前端静态检查及 Client 构建通过。验证数据采用夹具;部署与真实账号安装端验收待执行。
- 基线 `4cc8f565f`;详细记录见 `docs/verification/app-chat-back-20261031.md`。

## 2026-10-30 — 订阅首页比例调整

- 页面左右留白自适应为 16–32px,栏目最小宽度由 260px 调整至 320px,并按实际容器宽度收缩;960px 内容窗口呈现两列。
Expand Down
22 changes: 22 additions & 0 deletions docs/verification/app-chat-back-20261031.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# 应用会话返回按钮 — 2026-10-31

## 基线

- 仓库与版本:`dataelement/bisheng:feat/bisheng-work-120-main@4cc8f565fb5bc8bd918973ce74f876e6fd66af26`,通过远端 fetch 核对。
- 工作目录:`.worktrees/bisheng-app-chat-back-visible`;分支:`codex/app-chat-back-visible`。
- 本次修改属于 BISHENG 网页应用会话导航。已安装 BISHENG 包版本 `1.2.0-test.2`,应用包内 `@deepseek-ai/dsh-harness-dev-context` 版本 `0.2.1-rc.2`,通过 asar 元数据读取。

## 根因和行为

- 紧凑布局(内容区宽度 ≤1023px)使用 MobileNav。旧逻辑在会话标题加载后隐藏顶栏返回按钮;SideNav 的返回区域同时受 `touch-desktop` 控制,紧凑布局隐藏该区域。侧栏收起时 AppRoot 的宽屏悬浮返回也退出渲染,导致返回入口消失。
- 标题栏现在根据应用返回动作显示返回按钮,标题加载与历史展开均保持入口可用。沿用已有按钮样式,给长标题预留菜单和返回所需的宽度。
- 菜单共用同一处理逻辑:桌面切换应用侧栏,移动端打开系统菜单;操作前关闭历史下拉。
- 返回继续调用现有来源解析:发现广场进入的会话回到 `/square?type=apps`,应用中心进入的会话回到 `/apps`,移动探索回到 `/apps/explore`。

## 验证

- 旧代码对照:新增 3 项标题加载/移动端返回测试均失败,已有 12 项通过。
- 修正后 3 个测试文件、33 项通过。AppRoot 与真实 MobileNav、真实来源解析组合测试覆盖标题加载、侧栏展开/收起、历史展开后的三种来源返回,以及此前菜单、新建和历史下拉行为;探索入口测试使用真实路由验证进入与返回。
- Client 全量 ESLint、1504 个严格类型文件;Platform 全量 ESLint、620 个严格类型文件;UI/file-viewers ESLint、file-viewers tsc 检查通过。Client 生产构建、i18n 与受影响文件架构检查通过。
- macOS Electron 隔离验证使用真实 MobileNav 和生产 CSS,服务与回调采用夹具。390/768/820/920/1000/1023px × 标题加载前后 × 侧栏展开/收起,24 组检查通过:按钮命中区域、与长标题分离、返回回调触发。证据:主工作区 `outputs/app-chat-back-20261031/`。
- 当前状态为源码修复和本地自动验证完成。测试环境部署、macOS/Windows 已安装客户端真实账号的发现广场进入与返回验收待执行。
38 changes: 19 additions & 19 deletions src/frontend/client/src/components/Nav/MobileNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type MobileNavProps = {
onMenuClick?: () => void;
preferBackButton?: boolean;
onBack?: () => void;
/** 应用内对话:无合并标题时与侧栏并排展示「返回」;有会话标题时返回在侧栏内,顶栏仅抽屉 */
/** App surfaces keep their return action available alongside the menu. */
appSurfaceBackAction?: () => void;
/** App-surface: opens the app-chat history dropdown (app card + conversation list).
* Caret/grey states key off `appHistoryDropdownOpen`; the dropdown itself is rendered
Expand Down Expand Up @@ -114,13 +114,15 @@ export default function MobileNav({
(historyDropdownOpen && !appSurfaceBackAction) ||
(appHistoryDropdownOpen && Boolean(appSurfaceBackAction));

/** 应用内对话:有合并标题时顶栏与主站对话一致(左仅抽屉、中标题、右分享+新建);无标题时保留「菜单+返回」 */
const appSurfaceShowBackWithMenu =
Boolean(
appSurfaceBackAction &&
!preferBackButton &&
!(showWorkbenchMergedBar && chatMobileHeader),
);
// App navigation is available throughout conversation-title hydration.
const appSurfaceShowBackWithMenu = Boolean(appSurfaceBackAction) && preferBackButton === false;

const handleMenuClick = () => {
setHistoryDropdownOpen(false);
if (appHistoryDropdownOpen) onToggleAppHistoryDropdown?.();
if (onMenuClick) onMenuClick();
else setSystemMenuOpen(true);
};

const appBackBtnClassName =
'inline-flex size-8 shrink-0 items-center justify-center rounded-lg border border-border-base bg-white text-text-1 shadow-sm transition-colors hover:bg-fill-1';
Expand All @@ -141,12 +143,12 @@ export default function MobileNav({
<button
type="button"
data-testid="mobile-header-left-action"
aria-label={navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar')}
aria-expanded={navVisible}
aria-label={localize(onMenuClick && navVisible ? 'com_nav_close_sidebar' : 'com_nav_open_sidebar')}
aria-expanded={onMenuClick ? navVisible : undefined}
className={appBackBtnClassName}
onClick={toggleSidebar}
onClick={handleMenuClick}
>
{navVisible ? (
{onMenuClick && navVisible ? (
<X className="size-4" strokeWidth={2} />
) : (
<Menu className="size-4" strokeWidth={2} />
Expand All @@ -172,12 +174,7 @@ export default function MobileNav({
mobileHeadIconBtnClassName,
!onMenuClick && (historyDropdownOpen || appHistoryDropdownOpen) && 'pointer-events-none text-text-4',
)}
onClick={preferBackButton ? (onBack ?? toggleSidebar) : () => {
setHistoryDropdownOpen(false);
if (appHistoryDropdownOpen) onToggleAppHistoryDropdown?.();
if (onMenuClick) onMenuClick();
else setSystemMenuOpen(true);
}}
onClick={preferBackButton ? (onBack ?? toggleSidebar) : handleMenuClick}
>
{preferBackButton ? (
<ChevronLeft className="size-4" strokeWidth={2} />
Expand All @@ -194,7 +191,10 @@ export default function MobileNav({
either edge. max-w reserves room for the menu (left) and the
share + new-chat icons (right) so a long title truncates rather
than sliding under them. Mirrors the 知识空间 / 订阅 mobile headers. */}
<div className="absolute left-1/2 top-0 flex h-full max-w-[calc(100%-128px)] -translate-x-1/2 items-center justify-center px-1">
<div className={cn(
'absolute left-1/2 top-0 flex h-full -translate-x-1/2 items-center justify-center px-1',
appSurfaceShowBackWithMenu ? 'max-w-[calc(100%-176px)]' : 'max-w-[calc(100%-128px)]',
)}>
{appSurfaceBackAction ? (
// App-surface chat: title opens the app-chat history dropdown (app card
// + conversation list). The dropdown itself is rendered by AppRoot which
Expand Down
36 changes: 30 additions & 6 deletions src/frontend/client/src/routes/AppRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,11 @@ let mockMobile = false;
let mockCompact = true;
let mockSidebarVisible = true;
let mockMerged = true;
let mockReturnTo = '/square?type=apps';
const mockHeader = { title: 'New conversation', conversationId: 'old', flowId: 'assistant', flowType: 5, hideShare: true };

jest.mock('react-router-dom', () => ({
useLocation: () => ({ pathname: '/app/old/assistant/5', search: '', state: { appSurfaceReturn: '/square?type=apps' } }),
useLocation: () => ({ pathname: '/app/old/assistant/5', search: '', state: { appSurfaceReturn: mockReturnTo } }),
useNavigate: () => mockNavigate,
useParams: () => ({ fid: 'assistant', type: '5' }),
Outlet: () => <div>Conversation content</div>,
Expand Down Expand Up @@ -61,15 +62,11 @@ jest.mock('~/pages/appChat/components/AppSidebarConvoItem', () => ({
AppSidebarConvoItem: ({ conv, onClick }: { conv: { title: string }; onClick: () => void }) => <button onClick={onClick}>{conv.title}</button>,
}));
jest.mock('~/components/ui/Tooltip2', () => ({ Tooltip: () => null, TooltipProvider: () => null, TooltipTrigger: () => null, TooltipContent: () => null }));
jest.mock('~/pages/appChat/appChatOrigin', () => ({
copyAppChatReturnTo: jest.fn(), copyAppChatOrigin: jest.fn(), normalizeAppChatReturn: (value: string) => value,
resolveAppChatExitNavigateTarget: () => '/square?type=apps', writeAppChatReturnTo: jest.fn(),
}));
jest.mock('bisheng-icons', () => ({ Outlined: { SidebarMenu: () => null, Plus: () => null, Down: () => null } }));

beforeEach(() => {
mockMobile = false; mockCompact = true; mockSidebarVisible = true; mockMerged = true;
localStorage.clear();
localStorage.clear(); sessionStorage.clear(); mockReturnTo = '/square?type=apps';
});

it.each([true, false])('toggles the compact desktop app sidebar from visibility %s', (visible) => {
Expand Down Expand Up @@ -167,3 +164,30 @@ it('retains the app sidebar toggle before a conversation title is available', ()
fireEvent.click(menu);
expect(menu).toHaveAttribute('aria-expanded', 'false');
});

// The compact header owns a visible return action throughout title hydration.
it.each([true, false])('keeps return available as the title loads with sidebar=%s', (visible) => {
mockSidebarVisible = visible;
mockMerged = false;
const view = render(<AppRoot />);
expect(screen.getByRole('button', { name: 'com_ui_go_back' })).toBeVisible();
mockMerged = true;
view.rerender(<AppRoot />);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_go_back' }));
expect(mockNavigate).toHaveBeenCalledWith('/square?type=apps', { replace: false });
});

it('keeps return available on mobile after a title loads', () => {
mockMobile = true;
render(<AppRoot />);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_go_back' }));
expect(mockNavigate).toHaveBeenCalledWith('/square?type=apps', { replace: false });
});

it.each(['/square?type=apps', '/apps', '/apps/explore'])('returns to %s with history expanded', (target) => {
mockReturnTo = target;
render(<AppRoot />);
fireEvent.click(screen.getByRole('button', { name: 'New conversation' }));
fireEvent.click(screen.getByRole('button', { name: 'com_ui_go_back' }));
expect(mockNavigate).toHaveBeenCalledWith(target, { replace: target === '/apps' });
});
Loading