@rexvane/piweb 0.3.22 → 0.3.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +5 -4
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +4 -4
- package/.next/react-loadable-manifest.json +11 -4
- package/.next/routes-manifest.json +8 -0
- package/.next/server/app/_global-error/page.js +2 -2
- package/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page.js +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +2 -2
- package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/side-chat/route.js +5 -0
- package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +1 -0
- package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/config/route.js +1 -1
- package/.next/server/app/api/config/route.js.nft.json +1 -1
- package/.next/server/app/api/files/route.js +1 -1
- package/.next/server/app/api/files/route.js.nft.json +1 -1
- package/.next/server/app/api/fs/route.js +1 -1
- package/.next/server/app/api/fs/route.js.nft.json +1 -1
- package/.next/server/app/api/git/route.js +2 -2
- package/.next/server/app/api/git/route.js.nft.json +1 -1
- package/.next/server/app/api/github/route.js +2 -2
- package/.next/server/app/api/github/route.js.nft.json +1 -1
- package/.next/server/app/api/growth/route.js +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/health/route.js +1 -1
- package/.next/server/app/api/health/route.js.nft.json +1 -1
- package/.next/server/app/api/models/defaults/route.js +1 -1
- package/.next/server/app/api/models/defaults/route.js.nft.json +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/models/route.js.nft.json +1 -1
- package/.next/server/app/api/pi-settings/route.js +1 -1
- package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/plugins/route.js.nft.json +1 -1
- package/.next/server/app/api/prompts/route.js +1 -1
- package/.next/server/app/api/prompts/route.js.nft.json +1 -1
- package/.next/server/app/api/remote/route.js +3 -3
- package/.next/server/app/api/remote/route.js.nft.json +1 -1
- package/.next/server/app/api/security/route.js +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/import/route.js +2 -2
- package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/route.js +1 -1
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js +2 -2
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/update/route.js +2 -2
- package/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/server/app/api/version/route.js +1 -1
- package/.next/server/app/api/version/route.js.nft.json +1 -1
- package/.next/server/app/api/web-auth/route.js +2 -2
- package/.next/server/app/api/web-auth/route.js.nft.json +1 -1
- package/.next/server/app/api/workspaces/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/icon.svg/route.js +1 -1
- package/.next/server/app/icon.svg/route.js.nft.json +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +7 -6
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +7 -6
- package/.next/server/app/index.segments/_full.segment.rsc +7 -6
- package/.next/server/app/index.segments/_tree.segment.rsc +3 -2
- package/.next/server/app/login/page.js +1 -1
- package/.next/server/app/login/page.js.nft.json +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.rsc +3 -3
- package/.next/server/app/login.segments/_full.segment.rsc +3 -3
- package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/page.js +1 -1
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +5 -4
- package/.next/server/chunks/{813.js → 1813.js} +1 -1
- package/.next/server/chunks/{430.js → 2430.js} +1 -1
- package/.next/server/chunks/{970.js → 2970.js} +1 -1
- package/.next/server/chunks/3421.js +1 -0
- package/.next/server/chunks/3924.js +6 -0
- package/.next/server/chunks/4807.js +1 -0
- package/.next/server/chunks/5393.js +1 -0
- package/.next/server/chunks/5802.js +2 -0
- package/.next/server/chunks/6229.js +1 -0
- package/.next/server/chunks/7496.js +1 -0
- package/.next/server/chunks/7662.js +2 -0
- package/.next/server/chunks/8686.js +1 -0
- package/.next/server/chunks/{593.js → 974.js} +1 -1
- package/.next/server/chunks/{920.js → 9920.js} +1 -1
- package/.next/server/functions-config-manifest.json +2 -1
- package/.next/server/instrumentation.js +1 -1
- package/.next/server/instrumentation.js.nft.json +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/middleware.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/preview-props.json +3 -3
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/server/webpack-runtime.js +1 -1
- package/.next/static/chunks/2716.44fdf1b21454b961.js +1 -0
- package/.next/static/chunks/4673.e261707dce391be7.js +1 -0
- package/.next/static/chunks/4686-0cd87513f9ea3538.js +1 -0
- package/.next/static/chunks/7411.5f79671208209b13.js +1 -0
- package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/app/page-dc3cb41d91c7432e.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +1 -0
- package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +1 -0
- package/.next/static/css/9fc559c2c4ad1d0f.css +1 -0
- package/.next/static/css/c6e2227c36ec2fd6.css +1 -0
- package/.next/types/app/api/agent/[id]/side-chat/route.ts +355 -0
- package/.next/types/routes.d.ts +2 -1
- package/.next/types/validator.ts +10 -0
- package/next.config.ts +2 -0
- package/package.json +7 -1
- package/scripts/release.mjs +3 -7
- package/scripts/runtime/check-installed.mjs +66 -0
- package/scripts/runtime/pi-packages.mjs +51 -0
- package/scripts/verify-package.mjs +29 -7
- package/src/app/api/agent/[id]/side-chat/route.ts +77 -0
- package/src/app/globals.css +1576 -14
- package/src/components/AppShell.tsx +63 -13
- package/src/components/ChatInput.tsx +33 -29
- package/src/components/ChatWindow.tsx +6 -7
- package/src/components/ContextMeter.tsx +35 -16
- package/src/components/DrawerPanel.tsx +1 -1
- package/src/components/FileViewer.tsx +89 -8
- package/src/components/GrowthTree.tsx +30 -5
- package/src/components/ModelSelector.tsx +21 -5
- package/src/components/SettingsPanel.tsx +25 -16
- package/src/components/chat/reading-anchor.ts +81 -0
- package/src/components/composer/ComposerImages.tsx +31 -11
- package/src/components/side-chat/SideChatPanel.module.css +131 -0
- package/src/components/side-chat/SideChatPanel.tsx +189 -0
- package/src/components/side-chat/icons.tsx +8 -0
- package/src/components/side-chat/request-history.ts +29 -0
- package/src/components/side-chat/store.ts +248 -0
- package/src/hooks/useFileViewer.ts +6 -4
- package/src/i18n.tsx +50 -8
- package/src/lib/files-service.ts +66 -2
- package/src/lib/path-security.ts +24 -32
- package/src/lib/session-reader.ts +1 -1
- package/src/lib/side-chat/context.ts +123 -0
- package/src/lib/side-chat/service.ts +191 -0
- package/src/lib/side-chat/types.ts +61 -0
- package/src/lib/types.ts +1 -1
- package/src/lib/update-service.ts +4 -6
- package/.next/server/chunks/229.js +0 -1
- package/.next/server/chunks/393.js +0 -1
- package/.next/server/chunks/421.js +0 -1
- package/.next/server/chunks/496.js +0 -1
- package/.next/server/chunks/662.js +0 -2
- package/.next/server/chunks/686.js +0 -1
- package/.next/server/chunks/802.js +0 -2
- package/.next/server/chunks/807.js +0 -1
- package/.next/server/chunks/924.js +0 -6
- package/.next/static/chunks/2716.7ad073038e94a802.js +0 -1
- package/.next/static/chunks/4673.4cf66cbdd8364c57.js +0 -1
- package/.next/static/chunks/4686-09fcd0028079006c.js +0 -1
- package/.next/static/chunks/7411.67366dc5032a141d.js +0 -1
- package/.next/static/chunks/app/_global-error/page-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/events/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/tree/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/agent/new/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/config/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/files/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/fs/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/git/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/github/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/growth/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/health/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/models/defaults/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/models/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/pi-settings/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/plugins/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/prompts/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/remote/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/security/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/export/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/sessions/import/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/sessions/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/skills/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/update/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/version/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/web-auth/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/api/workspaces/route-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/app/page-fc4ed2ff29b6235e.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f977af7f858990a2.js +0 -1
- package/.next/static/chunks/webpack-22ebffc20adb44ae.js +0 -1
- package/.next/static/css/6671d0d1589fdc0e.css +0 -1
- /package/.next/static/{WMazYvD-gbeLsCUfUxvvM → hYPL1-dmhKFjLS3f5yoqN}/_buildManifest.js +0 -0
- /package/.next/static/{WMazYvD-gbeLsCUfUxvvM → hYPL1-dmhKFjLS3f5yoqN}/_ssgManifest.js +0 -0
|
@@ -13,6 +13,9 @@ import { UpdateNotice } from "@/components/update/UpdateNotice";
|
|
|
13
13
|
import dynamic from "next/dynamic";
|
|
14
14
|
import { SessionSidebar } from "@/components/SessionSidebar";
|
|
15
15
|
import type { DrawerSection } from "@/components/DrawerPanel";
|
|
16
|
+
import { SideChatPanel } from "@/components/side-chat/SideChatPanel";
|
|
17
|
+
import { SideChatIcon } from "@/components/side-chat/icons";
|
|
18
|
+
import { preserveChatReadingPosition } from "@/components/chat/reading-anchor";
|
|
16
19
|
import { DirectoryPicker } from "@/components/DirectoryPicker";
|
|
17
20
|
|
|
18
21
|
// 首屏不需要的重组件按需加载(设置面板含供应商配置与代码高亮,轨迹/文件只在打开时才用)
|
|
@@ -157,10 +160,20 @@ export function AppShell() {
|
|
|
157
160
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
158
161
|
const [selected, setSelected] = useState<TrajEntry | null>(null);
|
|
159
162
|
/** 抽屉当前入口(由这里持有:轨迹详情关掉后要回到同一个入口) */
|
|
160
|
-
|
|
163
|
+
/** 抽屉入口保存在浏览器;新用户先看到旁聊。 */
|
|
161
164
|
const [drawerSection, setDrawerSection] = useState<DrawerSection>("project");
|
|
165
|
+
const chooseDrawerSection = useCallback((section: DrawerSection) => {
|
|
166
|
+
setDrawerSection(section);
|
|
167
|
+
localStorage.setItem("piweb.drawerSection", section);
|
|
168
|
+
setSideChatOpen(false);
|
|
169
|
+
}, []);
|
|
162
170
|
/** 右侧抽屉(最右一列):入口列表 + 内容 */
|
|
163
171
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
172
|
+
/**
|
|
173
|
+
* 右侧旁聊面板:和抽屉共用最右一列,所以两者互斥(开一个就关另一个)。
|
|
174
|
+
* 旁聊不再作为抽屉的一个入口 —— 它是独立面板,入口是会话头部那个图标按钮。
|
|
175
|
+
*/
|
|
176
|
+
const [sideChatOpen, setSideChatOpen] = useState(false);
|
|
164
177
|
/** 打开抽屉时「落到最新轮」只跳过这一次(点 badge 指定轮次时用) */
|
|
165
178
|
const skipLatestOnce = useRef(false);
|
|
166
179
|
/** useGrowth 的"落到最新轮":它在 AppShell 里、不随抽屉卸载,所以必须显式调用 */
|
|
@@ -177,6 +190,8 @@ export function AppShell() {
|
|
|
177
190
|
setRightWidth(restoreRightWidth());
|
|
178
191
|
setSidebarCollapsed(localStorage.getItem("piweb.sidebarCollapsed") === "1");
|
|
179
192
|
setDrawerOpen(localStorage.getItem("piweb.drawerOpen") === "1");
|
|
193
|
+
const section = localStorage.getItem("piweb.drawerSection");
|
|
194
|
+
if (["project", "prompts", "git", "import"].includes(section ?? "")) setDrawerSection(section as DrawerSection);
|
|
180
195
|
}, []);
|
|
181
196
|
/** 新会话时加载用户之前的选择,或使用系统建议(第一个认证的) */
|
|
182
197
|
useEffect(() => {
|
|
@@ -203,20 +218,23 @@ export function AppShell() {
|
|
|
203
218
|
}, [heroCwd]);
|
|
204
219
|
/** 右侧抽屉开关(项目栏降级成抽屉入口后,它是"最右一列是否打开"唯一的开关) */
|
|
205
220
|
const toggleDrawer = useCallback((next?: boolean) => {
|
|
221
|
+
preserveChatReadingPosition();
|
|
206
222
|
setDrawerOpen((cur) => {
|
|
207
223
|
const v = next ?? !cur;
|
|
208
224
|
localStorage.setItem("piweb.drawerOpen", v ? "1" : "0");
|
|
225
|
+
// 最右一列只有一个:开抽屉就收起旁聊
|
|
226
|
+
if (v) setSideChatOpen(false);
|
|
209
227
|
return v;
|
|
210
228
|
});
|
|
211
229
|
}, []);
|
|
212
230
|
/** 切到某个抽屉入口并打开抽屉(顶栏图标 / 点 badge 都用这条) */
|
|
213
231
|
const openDrawerSection = useCallback(
|
|
214
232
|
(section: DrawerSection) => {
|
|
215
|
-
|
|
233
|
+
chooseDrawerSection(section);
|
|
216
234
|
setSelected(null);
|
|
217
235
|
toggleDrawer(true);
|
|
218
236
|
},
|
|
219
|
-
[toggleDrawer],
|
|
237
|
+
[toggleDrawer, chooseDrawerSection],
|
|
220
238
|
);
|
|
221
239
|
// Ctrl/⌘+Shift+E:打开抽屉并切到「项目」入口;已经停在该入口时再按收起。
|
|
222
240
|
// 轨迹详情占着这一列时,这个快捷键把抽屉叫回来(而不是切到轨迹详情)。
|
|
@@ -351,12 +369,14 @@ export function AppShell() {
|
|
|
351
369
|
if (!frame) {
|
|
352
370
|
frame = requestAnimationFrame(() => {
|
|
353
371
|
frame = 0;
|
|
372
|
+
preserveChatReadingPosition();
|
|
354
373
|
commit(latest);
|
|
355
374
|
});
|
|
356
375
|
}
|
|
357
376
|
};
|
|
358
377
|
const up = () => {
|
|
359
378
|
if (frame) cancelAnimationFrame(frame);
|
|
379
|
+
preserveChatReadingPosition();
|
|
360
380
|
commit(latest);
|
|
361
381
|
persist(side === "sidebar" ? "piweb.sidebarW" : "piweb.drawerW", latest);
|
|
362
382
|
window.removeEventListener("pointermove", move);
|
|
@@ -372,6 +392,7 @@ export function AppShell() {
|
|
|
372
392
|
);
|
|
373
393
|
|
|
374
394
|
const toggleSidebar = () => {
|
|
395
|
+
preserveChatReadingPosition();
|
|
375
396
|
const next = !sidebarCollapsed;
|
|
376
397
|
setSidebarCollapsed(next);
|
|
377
398
|
localStorage.setItem("piweb.sidebarCollapsed", next ? "1" : "0");
|
|
@@ -386,6 +407,7 @@ export function AppShell() {
|
|
|
386
407
|
const openToolTrajectory = useCallback((toolCallId: string) => {
|
|
387
408
|
const entry = trajectoryRef.current.find((item) => item.toolCallId === toolCallId);
|
|
388
409
|
if (!entry) return;
|
|
410
|
+
preserveChatReadingPosition();
|
|
389
411
|
// 最右一列与抽屉互斥:选中轨迹详情即接管这一列,关掉后回落回抽屉的当前入口
|
|
390
412
|
setSelected(entry);
|
|
391
413
|
}, []);
|
|
@@ -673,7 +695,7 @@ export function AppShell() {
|
|
|
673
695
|
watchRounds: Boolean(currentPath),
|
|
674
696
|
connected: state.connected,
|
|
675
697
|
});
|
|
676
|
-
const openFileNode = useCallback((node: TreeNode) => viewer.open(node.path, { from: node.from, lazy: node.lazy }), [viewer]);
|
|
698
|
+
const openFileNode = useCallback((node: TreeNode) => viewer.open(node.path, { from: node.from, lazy: node.lazy, kind: node.kind }), [viewer]);
|
|
677
699
|
// 打开面板时落在最新轮:useGrowth 活在 AppShell 里、不随抽屉卸载,state 不会自己回到最新,
|
|
678
700
|
// 所以每次从「关」变「开」(或换了工作区)都显式选一次最新轮。
|
|
679
701
|
// 新轮次出现时不再自动跳——用户打开面板后停在哪一轮就是哪一轮。
|
|
@@ -704,11 +726,11 @@ export function AppShell() {
|
|
|
704
726
|
const growthSelect = growth.select;
|
|
705
727
|
const showRound = useCallback((commit: string) => {
|
|
706
728
|
skipLatestOnce.current = true;
|
|
707
|
-
|
|
729
|
+
chooseDrawerSection("project");
|
|
708
730
|
setSelected(null);
|
|
709
731
|
toggleDrawer(true);
|
|
710
732
|
growthSelect(commit);
|
|
711
|
-
}, [toggleDrawer, growthSelect]);
|
|
733
|
+
}, [toggleDrawer, growthSelect, chooseDrawerSection]);
|
|
712
734
|
const jumpToChat = useCallback((messageId: string) => {
|
|
713
735
|
requestAnimationFrame(() => {
|
|
714
736
|
const node = document.querySelector<HTMLElement>(`[data-role="user"][data-message-id="${CSS.escape(messageId)}"]`);
|
|
@@ -727,7 +749,7 @@ export function AppShell() {
|
|
|
727
749
|
// 抽屉里的一个入口,不再单独占一列,所以是 3 列而不是 4 列。
|
|
728
750
|
// JSX 里各列的渲染顺序必须与这里严格一致,否则网格会把内容摆错列。
|
|
729
751
|
// 最右一列只有一条宽度(rightWidth):详情栏与抽屉合并后不再各占一列。
|
|
730
|
-
const rightColumnVisible = Boolean(selected) || drawerOpen;
|
|
752
|
+
const rightColumnVisible = Boolean(selected) || drawerOpen || sideChatOpen;
|
|
731
753
|
const gridCols = isNarrow ? "minmax(0,1fr)" : `${sidebarCollapsed ? SIDEBAR_COLLAPSED : sidebarWidth}px minmax(0,1fr) ${
|
|
732
754
|
rightColumnVisible ? `${rightWidth}px` : "0px"}`;
|
|
733
755
|
|
|
@@ -748,11 +770,21 @@ export function AppShell() {
|
|
|
748
770
|
>
|
|
749
771
|
{selected ? (
|
|
750
772
|
<TrajInspector entry={selected} onClose={() => setSelected(null)} />
|
|
773
|
+
) : sideChatOpen ? (
|
|
774
|
+
<SideChatPanel
|
|
775
|
+
sessionId={currentId}
|
|
776
|
+
ready={Boolean(snapshot)}
|
|
777
|
+
mainModel={snapshot?.model}
|
|
778
|
+
models={modelChoices}
|
|
779
|
+
providerNames={providerNames}
|
|
780
|
+
authByProvider={authByProvider}
|
|
781
|
+
onClose={() => setSideChatOpen(false)}
|
|
782
|
+
/>
|
|
751
783
|
) : (
|
|
752
784
|
<DrawerPanel
|
|
753
785
|
cwd={panelCwd}
|
|
754
786
|
section={drawerSection}
|
|
755
|
-
onSectionChange={
|
|
787
|
+
onSectionChange={chooseDrawerSection}
|
|
756
788
|
onClose={() => toggleDrawer(false)}
|
|
757
789
|
sessionId={currentId}
|
|
758
790
|
promptRefreshKey={currentId}
|
|
@@ -907,10 +939,28 @@ export function AppShell() {
|
|
|
907
939
|
<span className="min-w-0 flex-1 truncate" style={{ fontSize: 14.5, fontWeight: 600 }}>
|
|
908
940
|
{title}
|
|
909
941
|
</span>
|
|
910
|
-
{/*
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
942
|
+
{/* 旁聊直达按钮;旁聊与其他入口共用右侧抽屉。 */}
|
|
943
|
+
<button
|
|
944
|
+
type="button"
|
|
945
|
+
className="icon-btn"
|
|
946
|
+
style={{ width: 26, height: 26, color: sideChatOpen ? "var(--dsw-accent)" : "var(--dsw-label-secondary)" }}
|
|
947
|
+
aria-label={t.sideChat}
|
|
948
|
+
title={t.sideChat}
|
|
949
|
+
aria-pressed={sideChatOpen}
|
|
950
|
+
data-testid="side-chat-toggle"
|
|
951
|
+
onClick={() => {
|
|
952
|
+
preserveChatReadingPosition();
|
|
953
|
+
setSelected(null);
|
|
954
|
+
setSideChatOpen((open) => {
|
|
955
|
+
// 最右一列只有一个:开旁聊就收起抽屉
|
|
956
|
+
if (!open) toggleDrawer(false);
|
|
957
|
+
return !open;
|
|
958
|
+
});
|
|
959
|
+
}}
|
|
960
|
+
>
|
|
961
|
+
<SideChatIcon size={16} />
|
|
962
|
+
</button>
|
|
963
|
+
{/* 右侧抽屉的入口与开关 */}
|
|
914
964
|
<button
|
|
915
965
|
type="button"
|
|
916
966
|
className="icon-btn"
|
|
@@ -1065,7 +1115,7 @@ export function AppShell() {
|
|
|
1065
1115
|
{/* 窄屏最右一列:贴右边滑出(轨迹详情与抽屉共用,遮罩关闭回到对话) */}
|
|
1066
1116
|
{isNarrow && rightColumnVisible && (
|
|
1067
1117
|
<>
|
|
1068
|
-
<button className="fixed inset-0 z-[85]" style={{ background: "var(--dsw-mask)" }} onClick={() => (selected ? setSelected(null) : toggleDrawer(false))} aria-label={t.close} />
|
|
1118
|
+
<button className="fixed inset-0 z-[85]" style={{ background: "var(--dsw-mask)" }} onClick={() => (selected ? setSelected(null) : sideChatOpen ? setSideChatOpen(false) : toggleDrawer(false))} aria-label={t.close} />
|
|
1069
1119
|
{rightColumn}
|
|
1070
1120
|
</>
|
|
1071
1121
|
)}
|
|
@@ -159,6 +159,8 @@ export function ChatInput({
|
|
|
159
159
|
const [dragActive, setDragActive] = useState(false);
|
|
160
160
|
/** 引用 chip 超过 COLLAPSE_AT 就先折叠:手机上折行会把输入框顶掉(图片的折叠在 ComposerImages 里) */
|
|
161
161
|
const [refsExpanded, setRefsExpanded] = useState(false);
|
|
162
|
+
/** 上传文件超过 4 个时的展开状态(与引用同一套折叠规则) */
|
|
163
|
+
const [uploadsExpanded, setUploadsExpanded] = useState(false);
|
|
162
164
|
const dragDepth = useRef(0);
|
|
163
165
|
const [modelOpen, setModelOpen] = useState(false);
|
|
164
166
|
const [cmdIdx, setCmdIdx] = useState(0);
|
|
@@ -624,33 +626,34 @@ export function ChatInput({
|
|
|
624
626
|
</div>
|
|
625
627
|
)}
|
|
626
628
|
|
|
629
|
+
{/*
|
|
630
|
+
* 附件轨(图片 / 引用 / 上传文件):绝对定位浮在输入卡上方,**不占布局高度** ——
|
|
631
|
+
* 输入框位置不动,多出来的附件直接盖住后面的聊天记录(用户要的就是这个)。
|
|
632
|
+
* 外层 pointer-events-none,只有附件本身接收点击,免得挡住聊天区的交互。
|
|
633
|
+
*/}
|
|
634
|
+
<div className="pointer-events-none absolute inset-x-0 bottom-full z-20 flex flex-col gap-1.5 pb-2" data-testid="composer-attachments">
|
|
627
635
|
{/* 附件轨:图片(超过 4 张折叠成牌堆,展开沿弧线扇开;点开可放大、切换、删除) */}
|
|
628
636
|
{images.length > 0 && (
|
|
629
637
|
<ComposerImages
|
|
630
638
|
images={images}
|
|
631
639
|
disabled={sendBusy}
|
|
632
640
|
onRemove={(index) => changeDraft((previous) => ({ ...previous, images: previous.images.filter((_, at) => at !== index) }))}
|
|
641
|
+
onClear={() => changeDraft((previous) => ({ ...previous, images: [] }))}
|
|
633
642
|
/>
|
|
634
643
|
)}
|
|
635
644
|
|
|
636
645
|
{/* 路径引用(项目结构图/文件面板的「引用到输入框」):独立于正文,发送时拼成 @路径 */}
|
|
637
646
|
{references.length > 0 && (
|
|
638
|
-
<div className="
|
|
639
|
-
{(refsExpanded ? references : references.slice(0, COLLAPSE_AT)).map((path) => {
|
|
647
|
+
<div className="pointer-events-auto flex flex-wrap gap-1 px-1" data-testid="composer-references">
|
|
648
|
+
{(refsExpanded ? references : references.slice(0, COLLAPSE_AT)).map((path, i) => {
|
|
640
649
|
const chip = referenceChipOf(path);
|
|
641
650
|
return (
|
|
642
651
|
<span
|
|
643
652
|
key={path}
|
|
644
|
-
className="
|
|
645
|
-
style={{
|
|
646
|
-
maxWidth: 260,
|
|
647
|
-
fontSize: 12,
|
|
648
|
-
fontFamily: "var(--font-mono)",
|
|
649
|
-
color: "var(--dsw-label-secondary)",
|
|
650
|
-
background: "var(--dsw-selector)",
|
|
651
|
-
border: "0.5px solid var(--dsw-border-l3)",
|
|
652
|
-
}}
|
|
653
|
+
className="pw-ref-chip group/ref"
|
|
653
654
|
title={path}
|
|
655
|
+
/* 与图片卡组同一套时序:从左往右错开浮上来,收起反向;不旋转(带文字转着读着累) */
|
|
656
|
+
style={{ animationDelay: `${Math.min(i * 14, 140)}ms` }}
|
|
654
657
|
>
|
|
655
658
|
{chip.dir ? (
|
|
656
659
|
<IconFolderClose16 size={12} style={{ flex: "none", color: "var(--dsw-warn)" }} />
|
|
@@ -660,8 +663,7 @@ export function ChatInput({
|
|
|
660
663
|
<span className="truncate">{chip.text}</span>
|
|
661
664
|
<button
|
|
662
665
|
type="button"
|
|
663
|
-
className="
|
|
664
|
-
style={{ color: "var(--dsw-label-caption)" }}
|
|
666
|
+
className="pw-ref-close"
|
|
665
667
|
disabled={sendBusy}
|
|
666
668
|
onClick={() => changeDraft((previous) => ({ ...previous, references: (previous.references ?? []).filter((p) => p !== path) }))}
|
|
667
669
|
aria-label={`${t.delete} ${path}`}
|
|
@@ -679,33 +681,35 @@ export function ChatInput({
|
|
|
679
681
|
</div>
|
|
680
682
|
)}
|
|
681
683
|
|
|
682
|
-
{/*
|
|
684
|
+
{/* 附件轨:上传的普通文件(压缩包/文档等,路径引用);超过 4 个折叠 */}
|
|
683
685
|
{uploads.length > 0 && (
|
|
684
|
-
<div className="
|
|
685
|
-
{uploads.map((f, i) => (
|
|
686
|
-
<
|
|
687
|
-
|
|
688
|
-
className="
|
|
689
|
-
style={{
|
|
690
|
-
title={f.path}
|
|
691
|
-
>
|
|
692
|
-
<IconFileOutline16 size={14} style={{ flex: "none", color: "var(--dsw-label-tertiary)" }} />
|
|
693
|
-
<span className="max-w-[180px] truncate" style={{ fontSize: 12.5, color: "var(--dsw-label-secondary)" }}>{f.name}</span>
|
|
694
|
-
<span style={{ fontSize: 11, color: "var(--dsw-label-caption)" }}>{fmtUploadSize(f.size)}</span>
|
|
686
|
+
<div className="pointer-events-auto flex flex-wrap items-center gap-1 px-1" data-testid="composer-uploads">
|
|
687
|
+
{(uploadsExpanded ? uploads : uploads.slice(0, COLLAPSE_AT)).map((f, i) => (
|
|
688
|
+
<span key={f.path} className="pw-ref-chip group/ref" title={f.path}>
|
|
689
|
+
<IconFileOutline16 size={12} style={{ flex: "none", color: "var(--dsw-label-tertiary)" }} />
|
|
690
|
+
<span className="max-w-[180px] truncate">{f.name}</span>
|
|
691
|
+
<span style={{ flex: "none", fontSize: 11, color: "var(--dsw-label-caption)" }}>{fmtUploadSize(f.size)}</span>
|
|
695
692
|
<button
|
|
696
|
-
|
|
697
|
-
|
|
693
|
+
type="button"
|
|
694
|
+
className="pw-ref-close"
|
|
698
695
|
disabled={sendBusy}
|
|
699
696
|
onClick={() => changeDraft((previous) => ({ ...previous, uploads: previous.uploads.filter((_, index) => index !== i) }))}
|
|
700
|
-
aria-label=
|
|
697
|
+
aria-label={`${t.delete} ${f.name}`}
|
|
701
698
|
>
|
|
702
699
|
✕
|
|
703
700
|
</button>
|
|
704
|
-
</
|
|
701
|
+
</span>
|
|
705
702
|
))}
|
|
703
|
+
{uploads.length > COLLAPSE_AT ? (
|
|
704
|
+
<button type="button" className="pw-chip" data-testid="uploads-toggle" onClick={() => setUploadsExpanded((open) => !open)}>
|
|
705
|
+
{uploadsExpanded ? t.chipCollapse : t.chipExpandMore.replace("{n}", String(uploads.length - COLLAPSE_AT))}
|
|
706
|
+
</button>
|
|
707
|
+
) : null}
|
|
706
708
|
</div>
|
|
707
709
|
)}
|
|
708
710
|
|
|
711
|
+
</div>
|
|
712
|
+
|
|
709
713
|
{/* 卡片(玻璃态)—— 拖放由 document 级监听统一接管 */}
|
|
710
714
|
<div
|
|
711
715
|
className="relative w-full"
|
|
@@ -29,6 +29,7 @@ import { languageForPath } from "@/lib/highlight";
|
|
|
29
29
|
import { copyText } from "@/lib/clipboard";
|
|
30
30
|
import { OutlineRail } from "@/components/OutlineRail";
|
|
31
31
|
import { ChatScrollContext, type ChatScroll } from "@/components/chat/chat-scroll";
|
|
32
|
+
import { useChatReadingAnchor } from "@/components/chat/reading-anchor";
|
|
32
33
|
import { useI18n } from "@/i18n";
|
|
33
34
|
import { fmtDuration, fmtPercent, fmtThroughput, fmtTokens } from "@/lib/format";
|
|
34
35
|
import type { ToolCardState } from "@/hooks/usePiWeb";
|
|
@@ -1257,15 +1258,12 @@ export function SessionStatsBar({
|
|
|
1257
1258
|
.replace("{steps}", String(stats.assistantMessages)),
|
|
1258
1259
|
];
|
|
1259
1260
|
const durations: string[] = [];
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
if (stats.llmMs > 0) durations.push(`LLM ${fmtDuration(stats.llmMs)}${coverage(stats.llmTimedSteps, stats.assistantMessages)}`);
|
|
1263
|
-
if (stats.toolMs > 0) durations.push(`${t.toolCalls} ${fmtDuration(stats.toolMs)}${coverage(stats.toolTimedCalls, stats.toolCalls)}`);
|
|
1261
|
+
if (stats.llmMs > 0) durations.push(`LLM ${fmtDuration(stats.llmMs)}`);
|
|
1262
|
+
if (stats.toolMs > 0) durations.push(`${t.toolCalls} ${fmtDuration(stats.toolMs)}`);
|
|
1264
1263
|
if (durations.length) groups.push(durations.join(" · "));
|
|
1265
1264
|
const speeds: string[] = [];
|
|
1266
|
-
|
|
1267
|
-
if (stats.
|
|
1268
|
-
if (stats.decodeMs > 0) speeds.push(`${fmtThroughput(stats.decodeTokens / (stats.decodeMs / 1000))} tok/s${timedLabel(stats.decodeSteps ?? stats.ttftSteps)}`);
|
|
1265
|
+
if (stats.ttftSteps > 0) speeds.push(`${t.ttftAverage} ${fmtDuration(stats.ttftMs / stats.ttftSteps)}`);
|
|
1266
|
+
if (stats.decodeMs > 0) speeds.push(`${fmtThroughput(stats.decodeTokens / (stats.decodeMs / 1000))} tok/s`);
|
|
1269
1267
|
if (speeds.length) groups.push(speeds.join(" · "));
|
|
1270
1268
|
const billedInput = stats.tokens.input + stats.tokens.cacheRead + stats.tokens.cacheWrite;
|
|
1271
1269
|
if (billedInput > 0 || stats.tokens.output > 0) {
|
|
@@ -1431,6 +1429,7 @@ export function ChatWindow({
|
|
|
1431
1429
|
if (node) node.scrollTop = node.scrollHeight;
|
|
1432
1430
|
},
|
|
1433
1431
|
}), []);
|
|
1432
|
+
useChatReadingAnchor(rootRef, scrollRef, chatScroll.shouldPin);
|
|
1434
1433
|
const { t } = useI18n();
|
|
1435
1434
|
useEffect(() => {
|
|
1436
1435
|
const root = rootRef.current;
|
|
@@ -8,12 +8,14 @@ import { formatCapacity, parseCapacity } from "@/lib/model-draft";
|
|
|
8
8
|
import { classifyMessageChars } from "@/lib/process-format";
|
|
9
9
|
import type { ContextBreakdown, TrajTokens } from "@/lib/types";
|
|
10
10
|
|
|
11
|
-
export function ContextMeter({ percent, tokens, contextWindow, model, source }: {
|
|
11
|
+
export function ContextMeter({ percent, tokens, contextWindow, model, source, label }: {
|
|
12
12
|
percent: number | null;
|
|
13
13
|
tokens: number | null;
|
|
14
14
|
contextWindow: number | null;
|
|
15
15
|
/** 当前会话的模型:有它才能在弹窗里改上下文窗口 */
|
|
16
16
|
model?: { provider: string; id: string } | null;
|
|
17
|
+
/** 覆盖「上下文估算」这个措辞:真实用量(如旁聊)不该叫估算 */
|
|
18
|
+
label?: string;
|
|
17
19
|
source?: {
|
|
18
20
|
systemChars: number;
|
|
19
21
|
messages: Array<{ role?: string; content: Array<{ type: string; text?: string; thinking?: string; arguments?: unknown; name?: string }>; usage?: TrajTokens; stopReason?: string }>;
|
|
@@ -55,18 +57,25 @@ export function ContextMeter({ percent, tokens, contextWindow, model, source }:
|
|
|
55
57
|
const window_ = override ?? contextWindow ?? 0;
|
|
56
58
|
const recomputed = override !== null && known;
|
|
57
59
|
const p = recomputed ? used / override * 100 : percent ?? 0;
|
|
58
|
-
|
|
60
|
+
/** 圆环与悬停提示仍用 SDK 估算(那是「占窗口多少」),弹窗正文只留 API 出来的真实数字 */
|
|
61
|
+
const usageLabel = percent === null && !recomputed ? t.contextUnknown : `${fmtPercent(p)} ${label ?? t.contextEstimated}`;
|
|
59
62
|
const r = 5.5;
|
|
60
63
|
const circumference = 2 * Math.PI * r;
|
|
61
64
|
const reserved = Math.max(0, source?.breakdown?.autoCompactBuffer ?? 0);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
/**
|
|
66
|
+
* 进度条与 Free Space 的「已用」基准:优先最近一次请求的真实输入(API 报的),
|
|
67
|
+
* 没测到才退回 SDK 估算;两者都没有就不画(未知不能当空闲)。
|
|
68
|
+
*/
|
|
69
|
+
const anchor = window_ > 0 ? (measuredInput !== null ? Math.min(measuredInput, window_) : known ? Math.min(used, window_) : null) : null;
|
|
70
|
+
// 超过窗口时预留不再画到窗口外;用量未知不能将整扇窗口显示为 Free Space。
|
|
71
|
+
const visibleReserved = anchor === null ? 0 : Math.min(reserved, Math.max(0, window_ - anchor));
|
|
72
|
+
const free = anchor === null ? null : Math.max(0, window_ - anchor - reserved);
|
|
65
73
|
const b = source?.breakdown?.characters;
|
|
66
74
|
const rows = [
|
|
67
75
|
{ key: "system", label: t.contextSystemPrompt, value: b?.systemPrompt, color: "var(--dsw-accent)" },
|
|
68
76
|
{ key: "builtin", label: t.contextSystemTools, value: b?.systemTools, color: "#8B7EC8" },
|
|
69
77
|
{ key: "custom", label: t.contextCustomTools, value: b?.customTools, color: "#B39DDB" },
|
|
78
|
+
{ key: "background", label: t.contextBackground, value: b?.background, color: "#5B8DEF" },
|
|
70
79
|
{ key: "memory", label: t.contextMemory, value: b?.memory, color: "#5FA8D3" },
|
|
71
80
|
{ key: "skills", label: t.contextSkills, value: b?.skills, color: "#4FB286" },
|
|
72
81
|
{ key: "user", label: t.contextUserMessages, value: chars.user, color: "var(--dsw-success)" },
|
|
@@ -76,6 +85,18 @@ export function ContextMeter({ percent, tokens, contextWindow, model, source }:
|
|
|
76
85
|
{ key: "output", label: t.contextToolOutput, value: chars.toolOutput, color: "var(--dsw-warn)" },
|
|
77
86
|
{ key: "compacted", label: t.contextCompacted, value: b?.compacted, color: "#A05A78" },
|
|
78
87
|
].filter((row) => row.value !== undefined && row.value > 0);
|
|
88
|
+
const charTotal = rows.reduce((sum, row) => sum + (row.value ?? 0), 0);
|
|
89
|
+
/**
|
|
90
|
+
* 进度条整条 = 全部上下文窗口:已用那一截按各类**字符数比例**分段(API 不给分类 token,
|
|
91
|
+
* 所以只摊比例、不报每类的 token 数),剩下的部分就是背景色的 Free Space。
|
|
92
|
+
*/
|
|
93
|
+
const segments = anchor === null || charTotal === 0 ? [] : rows.map((row) => ({
|
|
94
|
+
key: row.key,
|
|
95
|
+
label: row.label,
|
|
96
|
+
value: row.value as number,
|
|
97
|
+
color: row.color,
|
|
98
|
+
width: anchor / window_ * 100 * ((row.value as number) / charTotal),
|
|
99
|
+
}));
|
|
79
100
|
|
|
80
101
|
return (
|
|
81
102
|
<div ref={ref} className="relative">
|
|
@@ -88,24 +109,22 @@ export function ContextMeter({ percent, tokens, contextWindow, model, source }:
|
|
|
88
109
|
</button>
|
|
89
110
|
{open && (
|
|
90
111
|
<div className="popover absolute bottom-9 right-0 z-50 w-72 p-4" style={{ fontSize: "var(--dsh-content-font-size-secondary)" }}>
|
|
91
|
-
<div className="font-medium" style={{ color: "var(--dsw-label-primary)" }}>{usageLabel}</div>
|
|
92
|
-
<div className="mt-1" style={{ color: "var(--dsw-label-tertiary)" }}>
|
|
93
|
-
{known ? `~${fmtTokens(used)}` : "—"} / {window_ > 0 ? fmtTokens(window_) : "—"} tok
|
|
94
|
-
</div>
|
|
95
112
|
{measuredInput !== null && <div className="mt-2 flex items-center justify-between gap-2"><span>{t.contextApiInput}</span><span title={`${measuredInput} tok`}>{fmtTokens(measuredInput)} tok</span></div>}
|
|
96
|
-
{
|
|
97
|
-
<div className="mt-3 flex h-2 w-full overflow-hidden rounded-full" style={{ background: "var(--dsw-selector)" }} aria-hidden>
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
{window_ > 0 && (
|
|
114
|
+
<div className="mt-3 flex h-2 w-full overflow-hidden rounded-full" style={{ background: "var(--dsw-selector)" }} data-testid="context-bar" aria-hidden>
|
|
115
|
+
{segments.map((segment) => (
|
|
116
|
+
<span key={segment.key} data-segment={segment.key} className="flex-none" title={`${segment.label}:${fmtTokens(segment.value)} ${t.contextChars}`}
|
|
117
|
+
style={{ width: `${segment.width}%`, background: segment.color }} />
|
|
118
|
+
))}
|
|
119
|
+
{visibleReserved > 0 && <span data-segment="reserved" className="flex-none" style={{ width: `${visibleReserved / window_ * 100}%`, background: "var(--dsw-border-l3)" }} />}
|
|
100
120
|
</div>
|
|
101
121
|
)}
|
|
102
122
|
<div className="mt-2 flex flex-col gap-1" style={{ color: "var(--dsw-label-tertiary)" }}>
|
|
103
123
|
{reserved > 0 && <DataRow label={t.contextAutoCompactBuffer} value={`${fmtTokens(reserved)} tok`} exact={`${reserved} tok`} color="var(--dsw-border-l3)" />}
|
|
104
124
|
{window_ > 0 && <DataRow label={t.contextFreeSpace} value={free === null ? "—" : `~${fmtTokens(free)} tok`} color="var(--dsw-selector)" />}
|
|
105
125
|
</div>
|
|
106
|
-
|
|
107
|
-
<div className="mt-1" style={{
|
|
108
|
-
<div className="mt-2 flex flex-col gap-1" style={{ color: "var(--dsw-label-tertiary)" }}>
|
|
126
|
+
{/* 进度条的图例:颜色跟上面的分段一一对应,数字是真实字符数 */}
|
|
127
|
+
<div className="mt-3 flex flex-col gap-1" style={{ color: "var(--dsw-label-tertiary)" }}>
|
|
109
128
|
{rows.map((row) => <DataRow key={row.key} label={row.label} value={`${fmtTokens(row.value!)} ${t.contextChars}`} exact={`${row.value} ${t.contextChars}`} color={row.color} />)}
|
|
110
129
|
</div>
|
|
111
130
|
{cold && source?.loadFull && (
|
|
@@ -18,7 +18,7 @@ import type { GrowthApi } from "@/hooks/useGrowth";
|
|
|
18
18
|
import { useI18n } from "@/i18n";
|
|
19
19
|
import type { TreeNode } from "@/lib/growth-tree";
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/** 抽屉入口:项目 → 提示词 → Git 详情 → 会话导入(旁聊已独立成右侧面板,不再共用这一列)。 */
|
|
22
22
|
export type DrawerSection = "project" | "prompts" | "git" | "import";
|
|
23
23
|
|
|
24
24
|
export function DrawerPanel({
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* 文件页签:面包屑 + 模式(变更 / 内容 / 渲染)+ 折叠未改动 + 上一处 / 下一处 + 引用 / 编辑器打开。
|
|
7
7
|
* 「变更」= 整文件行内 diff(FullDiff);「内容」= 所选步的全文(被删文件显示删除前);「渲染」= Markdown。
|
|
8
8
|
*/
|
|
9
|
-
import { useEffect, useMemo, useRef, useState } from "react";
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
10
10
|
import ReactMarkdown from "react-markdown";
|
|
11
11
|
import rehypeHighlight from "rehype-highlight";
|
|
12
12
|
import remarkGfm from "remark-gfm";
|
|
@@ -46,6 +46,10 @@ function cachedJson<T>(url: string): Promise<T> {
|
|
|
46
46
|
return p;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** 文件夹预览:一页目录条目(分页;后端已按 .gitignore 过滤) */
|
|
50
|
+
interface DirEntry { name: string; kind: "dir" | "file"; size?: number }
|
|
51
|
+
interface DirPage { dir: string; entries: DirEntry[]; nextOffset: number | null; truncated: boolean }
|
|
52
|
+
|
|
49
53
|
interface Loaded {
|
|
50
54
|
key: string;
|
|
51
55
|
diff: DiffLine[] | null;
|
|
@@ -94,7 +98,7 @@ export function FileViewer({
|
|
|
94
98
|
onClose: () => void;
|
|
95
99
|
onSelectTab: (path: string | null) => void;
|
|
96
100
|
onCloseTab: (path: string) => void;
|
|
97
|
-
onOpen: (path: string, opts?: { from?: string; lazy?: boolean }) => void;
|
|
101
|
+
onOpen: (path: string, opts?: { from?: string; lazy?: boolean; kind?: "dir" | "file" }) => void;
|
|
98
102
|
onReference?: (path: string) => void;
|
|
99
103
|
onOpenEditor?: (path: string) => void;
|
|
100
104
|
}) {
|
|
@@ -109,6 +113,8 @@ export function FileViewer({
|
|
|
109
113
|
const [treeFilter, setTreeFilter] = useState("");
|
|
110
114
|
const [reveal, setReveal] = useState<{ path: string; key: number } | null>(null);
|
|
111
115
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
|
116
|
+
const [dirPage, setDirPage] = useState<(DirPage & { key: string }) | null>(null);
|
|
117
|
+
const [dirMore, setDirMore] = useState<DirEntry[]>([]);
|
|
112
118
|
const diffRef = useRef<FullDiffHandle>(null);
|
|
113
119
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
114
120
|
const quickInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -121,15 +127,56 @@ export function FileViewer({
|
|
|
121
127
|
// 切页签:模式按是否有改动自动选,用户手动切过才保留
|
|
122
128
|
useEffect(() => {
|
|
123
129
|
setModeTouched(false);
|
|
130
|
+
setDirFallback(null);
|
|
124
131
|
}, [state.active]);
|
|
125
132
|
useEffect(() => {
|
|
126
133
|
if (modeTouched || !activeTab) return;
|
|
127
134
|
setMode(change && !activeTab.lazy ? "diff" : isMarkdown ? "render" : "content");
|
|
128
135
|
}, [activeTab, change, isMarkdown, modeTouched]);
|
|
129
136
|
|
|
137
|
+
/** 目录兜底:文件读取报 400(不存在 / 不是文件)时探一下目录接口,成功就当文件夹显示 */
|
|
138
|
+
const [dirFallback, setDirFallback] = useState<string | null>(null);
|
|
139
|
+
const dirPath = activeTab?.kind === "dir" ? activeTab.path : dirFallback;
|
|
140
|
+
const isDir = Boolean(dirPath);
|
|
141
|
+
// 文件夹:列目录(分页,后端已按 .gitignore 过滤),不读文件内容
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!dirPath || !cwd || !activeTab) {
|
|
144
|
+
setDirPage(null);
|
|
145
|
+
setDirMore([]);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// @src/ 这类带尾斜杠的引用:列目录时去掉
|
|
149
|
+
const path_ = dirPath.replace(/\/+$/, "");
|
|
150
|
+
const key = `dir|${path_}`;
|
|
151
|
+
let alive = true;
|
|
152
|
+
setDirMore([]);
|
|
153
|
+
void (async () => {
|
|
154
|
+
try {
|
|
155
|
+
const data = await requestJson<DirPage>(`/api/files?cwd=${encodeURIComponent(cwd)}&path=${encodeURIComponent(path_)}&offset=0`);
|
|
156
|
+
if (alive) setDirPage({ key, ...data });
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (alive) setDirPage({ key, dir: path_, entries: [], nextOffset: null, truncated: false, error: error instanceof Error ? error.message : t.viewerLoadFailed } as DirPage & { key: string; error?: string });
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
161
|
+
return () => {
|
|
162
|
+
alive = false;
|
|
163
|
+
};
|
|
164
|
+
}, [activeTab, dirPath, cwd, t.viewerLoadFailed]);
|
|
165
|
+
|
|
166
|
+
/** 目录还有下一页时接着拉(大目录:node_modules 这种已被忽略,剩下的也不会太多) */
|
|
167
|
+
const loadMore = useCallback(async () => {
|
|
168
|
+
if (!activeTab || !cwd || !dirPage?.nextOffset) return;
|
|
169
|
+
try {
|
|
170
|
+
const data = await requestJson<DirPage>(`/api/files?cwd=${encodeURIComponent(cwd)}&path=${encodeURIComponent(activeTab.path)}&offset=${dirPage.nextOffset}`);
|
|
171
|
+
setDirPage({ key: `dir|${activeTab.path}`, ...data, entries: [...dirPage.entries, ...data.entries] });
|
|
172
|
+
} catch {
|
|
173
|
+
// 下一页失败就留在当前这页,用户可以再点
|
|
174
|
+
}
|
|
175
|
+
}, [activeTab, cwd, dirPage]);
|
|
176
|
+
|
|
130
177
|
// 取数据
|
|
131
178
|
useEffect(() => {
|
|
132
|
-
if (!activeTab) {
|
|
179
|
+
if (!activeTab || activeTab.kind === "dir") {
|
|
133
180
|
setLoaded(null);
|
|
134
181
|
return;
|
|
135
182
|
}
|
|
@@ -157,6 +204,15 @@ export function FileViewer({
|
|
|
157
204
|
done({ content: data.content, binary: data.binary, truncated: data.truncated, notice });
|
|
158
205
|
} catch (error) {
|
|
159
206
|
if ((error as { status?: number }).status !== 400) throw error;
|
|
207
|
+
// 400 有两种:文件不存在 / 路径其实是目录。探一下目录接口,能列出条目就当文件夹打开
|
|
208
|
+
try {
|
|
209
|
+
await requestJson<DirPage>(`/api/files?${q}&path=${encodeURIComponent(tab.path)}&offset=0`);
|
|
210
|
+
done({ notice: null });
|
|
211
|
+
setDirFallback(tab.path);
|
|
212
|
+
return;
|
|
213
|
+
} catch {
|
|
214
|
+
// 目录也列不出来:照旧报「文件不存在」
|
|
215
|
+
}
|
|
160
216
|
done({ notice: "missing" });
|
|
161
217
|
}
|
|
162
218
|
};
|
|
@@ -379,7 +435,7 @@ export function FileViewer({
|
|
|
379
435
|
{stepBadge}
|
|
380
436
|
</span>
|
|
381
437
|
)}
|
|
382
|
-
{!activeTab.lazy && (
|
|
438
|
+
{!activeTab.lazy && !isDir && (
|
|
383
439
|
<span className="pw-seg" role="radiogroup">
|
|
384
440
|
{(["diff", "content", ...(isMarkdown ? ["render" as const] : [])] as Mode[]).map((m) => (
|
|
385
441
|
<button
|
|
@@ -398,7 +454,7 @@ export function FileViewer({
|
|
|
398
454
|
))}
|
|
399
455
|
</span>
|
|
400
456
|
)}
|
|
401
|
-
{activeTab.lazy && isMarkdown && (
|
|
457
|
+
{!isDir && activeTab.lazy && isMarkdown && (
|
|
402
458
|
<span className="pw-seg" role="radiogroup">
|
|
403
459
|
{(["content", "render"] as Mode[]).map((m) => (
|
|
404
460
|
<button key={m} type="button" role="radio" aria-checked={mode === m} data-active={mode === m} onClick={() => { setModeTouched(true); setMode(m); }}>
|
|
@@ -407,7 +463,7 @@ export function FileViewer({
|
|
|
407
463
|
))}
|
|
408
464
|
</span>
|
|
409
465
|
)}
|
|
410
|
-
{isDiffMode && (
|
|
466
|
+
{isDiffMode && !isDir && (
|
|
411
467
|
<>
|
|
412
468
|
<button type="button" className="pw-chip" data-active={folded} onClick={() => setFolded((f) => !f)}>
|
|
413
469
|
{folded ? t.viewerFold : t.viewerUnfold}
|
|
@@ -439,7 +495,32 @@ export function FileViewer({
|
|
|
439
495
|
</div>
|
|
440
496
|
)}
|
|
441
497
|
{loaded?.truncated && <div className="pw-viewer-notice" data-kind="warn">{mode === "diff" ? t.viewerPatchTruncated : t.filePreviewTruncated}</div>}
|
|
442
|
-
{
|
|
498
|
+
{isDir ? (
|
|
499
|
+
<div className="min-h-0 flex-1 overflow-auto" data-testid="viewer-dir">
|
|
500
|
+
<div className="px-3 py-2" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
|
|
501
|
+
{t.viewerDirHint.replace("{n}", String(dirPage?.entries.length ?? 0))}
|
|
502
|
+
</div>
|
|
503
|
+
{dirPage?.entries.map((entry) => (
|
|
504
|
+
<button
|
|
505
|
+
key={entry.name}
|
|
506
|
+
type="button"
|
|
507
|
+
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-[var(--dsw-hover)]"
|
|
508
|
+
onClick={() => (entry.kind === "dir"
|
|
509
|
+
? onOpen(`${dirPath!.replace(/\/+$/, "")}/${entry.name}`, { kind: "dir", lazy: true })
|
|
510
|
+
: onOpen(`${dirPath!.replace(/\/+$/, "")}/${entry.name}`, { lazy: true }))}
|
|
511
|
+
>
|
|
512
|
+
<img className="pw-tree-icon" src={entry.kind === "dir" ? "/file-icons/folder-close.svg" : fileIconSrc(entry.name)} alt="" />
|
|
513
|
+
<span className="min-w-0 flex-1 truncate" style={{ fontSize: 12.5, fontFamily: entry.kind === "dir" ? "inherit" : "var(--font-mono)" }}>{entry.name}</span>
|
|
514
|
+
{entry.kind === "file" && entry.size !== undefined && (
|
|
515
|
+
<span style={{ fontSize: 11, color: "var(--dsw-label-caption)", fontVariantNumeric: "tabular-nums" }}>{entry.size} B</span>
|
|
516
|
+
)}
|
|
517
|
+
</button>
|
|
518
|
+
))}
|
|
519
|
+
{dirPage?.nextOffset ? (
|
|
520
|
+
<button type="button" className="pw-chip mx-3 my-2" onClick={() => void loadMore()}>{t.viewerDirMore}</button>
|
|
521
|
+
) : null}
|
|
522
|
+
</div>
|
|
523
|
+
) : !loaded || loaded.key !== `${activeTab.path}|${mode}|${range?.from ?? ""}|${range?.to ?? ""}|${change?.status ?? ""}` ? (
|
|
443
524
|
<div className="flex min-h-0 flex-1 items-center justify-center" style={{ fontSize: 12.5, color: "var(--dsw-label-caption)" }}>{t.panelLoading}</div>
|
|
444
525
|
) : loaded.error ? (
|
|
445
526
|
<div className="flex min-h-0 flex-1 items-center justify-center" style={{ fontSize: 12.5, color: "var(--dsw-danger)" }}>{loaded.error}</div>
|
|
@@ -480,7 +561,7 @@ export function FileViewer({
|
|
|
480
561
|
selectedPath={null}
|
|
481
562
|
onToggleDir={growth.toggleDir}
|
|
482
563
|
onExpandLazy={growth.expandLazy}
|
|
483
|
-
onOpenFile={(node: TreeNode) => onOpen(node.path, { from: node.from, lazy: node.lazy })}
|
|
564
|
+
onOpenFile={(node: TreeNode) => onOpen(node.path, { from: node.from, lazy: node.lazy, kind: node.kind })}
|
|
484
565
|
onReference={onReference}
|
|
485
566
|
onOpenEditor={onOpenEditor}
|
|
486
567
|
revealPath={reveal?.path ?? null}
|