@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.
Files changed (251) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +5 -4
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +4 -4
  5. package/.next/react-loadable-manifest.json +11 -4
  6. package/.next/routes-manifest.json +8 -0
  7. package/.next/server/app/_global-error/page.js +2 -2
  8. package/.next/server/app/_global-error/page.js.nft.json +1 -1
  9. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  10. package/.next/server/app/_global-error.html +1 -1
  11. package/.next/server/app/_global-error.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page.js +1 -1
  16. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +1 -1
  19. package/.next/server/app/_not-found.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  24. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/[id]/side-chat/route.js +5 -0
  28. package/.next/server/app/api/agent/[id]/side-chat/route.js.nft.json +1 -0
  29. package/.next/server/app/api/agent/[id]/side-chat/route_client-reference-manifest.js +1 -0
  30. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  31. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  32. package/.next/server/app/api/agent/new/route.js +1 -1
  33. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  34. package/.next/server/app/api/config/route.js +1 -1
  35. package/.next/server/app/api/config/route.js.nft.json +1 -1
  36. package/.next/server/app/api/files/route.js +1 -1
  37. package/.next/server/app/api/files/route.js.nft.json +1 -1
  38. package/.next/server/app/api/fs/route.js +1 -1
  39. package/.next/server/app/api/fs/route.js.nft.json +1 -1
  40. package/.next/server/app/api/git/route.js +2 -2
  41. package/.next/server/app/api/git/route.js.nft.json +1 -1
  42. package/.next/server/app/api/github/route.js +2 -2
  43. package/.next/server/app/api/github/route.js.nft.json +1 -1
  44. package/.next/server/app/api/growth/route.js +1 -1
  45. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  46. package/.next/server/app/api/health/route.js +1 -1
  47. package/.next/server/app/api/health/route.js.nft.json +1 -1
  48. package/.next/server/app/api/models/defaults/route.js +1 -1
  49. package/.next/server/app/api/models/defaults/route.js.nft.json +1 -1
  50. package/.next/server/app/api/models/route.js +1 -1
  51. package/.next/server/app/api/models/route.js.nft.json +1 -1
  52. package/.next/server/app/api/pi-settings/route.js +1 -1
  53. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  54. package/.next/server/app/api/plugins/route.js +1 -1
  55. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  56. package/.next/server/app/api/prompts/route.js +1 -1
  57. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  58. package/.next/server/app/api/remote/route.js +3 -3
  59. package/.next/server/app/api/remote/route.js.nft.json +1 -1
  60. package/.next/server/app/api/security/route.js +1 -1
  61. package/.next/server/app/api/security/route.js.nft.json +1 -1
  62. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  63. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  64. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  65. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  66. package/.next/server/app/api/sessions/import/route.js +2 -2
  67. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  68. package/.next/server/app/api/sessions/route.js +1 -1
  69. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  70. package/.next/server/app/api/skills/route.js +2 -2
  71. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  72. package/.next/server/app/api/update/route.js +2 -2
  73. package/.next/server/app/api/update/route.js.nft.json +1 -1
  74. package/.next/server/app/api/version/route.js +1 -1
  75. package/.next/server/app/api/version/route.js.nft.json +1 -1
  76. package/.next/server/app/api/web-auth/route.js +2 -2
  77. package/.next/server/app/api/web-auth/route.js.nft.json +1 -1
  78. package/.next/server/app/api/workspaces/route.js +1 -1
  79. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  80. package/.next/server/app/icon.svg/route.js +1 -1
  81. package/.next/server/app/icon.svg/route.js.nft.json +1 -1
  82. package/.next/server/app/index.html +1 -1
  83. package/.next/server/app/index.rsc +7 -6
  84. package/.next/server/app/index.segments/__PAGE__.segment.rsc +7 -6
  85. package/.next/server/app/index.segments/_full.segment.rsc +7 -6
  86. package/.next/server/app/index.segments/_tree.segment.rsc +3 -2
  87. package/.next/server/app/login/page.js +1 -1
  88. package/.next/server/app/login/page.js.nft.json +1 -1
  89. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  90. package/.next/server/app/login.html +1 -1
  91. package/.next/server/app/login.rsc +3 -3
  92. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  93. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  94. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  95. package/.next/server/app/page.js +1 -1
  96. package/.next/server/app/page.js.nft.json +1 -1
  97. package/.next/server/app/page_client-reference-manifest.js +1 -1
  98. package/.next/server/app-paths-manifest.json +5 -4
  99. package/.next/server/chunks/{813.js → 1813.js} +1 -1
  100. package/.next/server/chunks/{430.js → 2430.js} +1 -1
  101. package/.next/server/chunks/{970.js → 2970.js} +1 -1
  102. package/.next/server/chunks/3421.js +1 -0
  103. package/.next/server/chunks/3924.js +6 -0
  104. package/.next/server/chunks/4807.js +1 -0
  105. package/.next/server/chunks/5393.js +1 -0
  106. package/.next/server/chunks/5802.js +2 -0
  107. package/.next/server/chunks/6229.js +1 -0
  108. package/.next/server/chunks/7496.js +1 -0
  109. package/.next/server/chunks/7662.js +2 -0
  110. package/.next/server/chunks/8686.js +1 -0
  111. package/.next/server/chunks/{593.js → 974.js} +1 -1
  112. package/.next/server/chunks/{920.js → 9920.js} +1 -1
  113. package/.next/server/functions-config-manifest.json +2 -1
  114. package/.next/server/instrumentation.js +1 -1
  115. package/.next/server/instrumentation.js.nft.json +1 -1
  116. package/.next/server/middleware-build-manifest.js +1 -1
  117. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  118. package/.next/server/middleware.js +1 -1
  119. package/.next/server/pages/404.html +1 -1
  120. package/.next/server/pages/500.html +1 -1
  121. package/.next/server/preview-props.json +3 -3
  122. package/.next/server/server-reference-manifest.json +1 -1
  123. package/.next/server/webpack-runtime.js +1 -1
  124. package/.next/static/chunks/2716.44fdf1b21454b961.js +1 -0
  125. package/.next/static/chunks/4673.e261707dce391be7.js +1 -0
  126. package/.next/static/chunks/4686-0cd87513f9ea3538.js +1 -0
  127. package/.next/static/chunks/7411.5f79671208209b13.js +1 -0
  128. package/.next/static/chunks/app/_global-error/page-cbaaee0d7aacb85a.js +1 -0
  129. package/.next/static/chunks/app/api/agent/[id]/events/route-cbaaee0d7aacb85a.js +1 -0
  130. package/.next/static/chunks/app/api/agent/[id]/route-cbaaee0d7aacb85a.js +1 -0
  131. package/.next/static/chunks/app/api/agent/[id]/side-chat/route-cbaaee0d7aacb85a.js +1 -0
  132. package/.next/static/chunks/app/api/agent/[id]/tree/route-cbaaee0d7aacb85a.js +1 -0
  133. package/.next/static/chunks/app/api/agent/new/route-cbaaee0d7aacb85a.js +1 -0
  134. package/.next/static/chunks/app/api/config/route-cbaaee0d7aacb85a.js +1 -0
  135. package/.next/static/chunks/app/api/files/route-cbaaee0d7aacb85a.js +1 -0
  136. package/.next/static/chunks/app/api/fs/route-cbaaee0d7aacb85a.js +1 -0
  137. package/.next/static/chunks/app/api/git/route-cbaaee0d7aacb85a.js +1 -0
  138. package/.next/static/chunks/app/api/github/route-cbaaee0d7aacb85a.js +1 -0
  139. package/.next/static/chunks/app/api/growth/route-cbaaee0d7aacb85a.js +1 -0
  140. package/.next/static/chunks/app/api/health/route-cbaaee0d7aacb85a.js +1 -0
  141. package/.next/static/chunks/app/api/models/defaults/route-cbaaee0d7aacb85a.js +1 -0
  142. package/.next/static/chunks/app/api/models/route-cbaaee0d7aacb85a.js +1 -0
  143. package/.next/static/chunks/app/api/pi-settings/route-cbaaee0d7aacb85a.js +1 -0
  144. package/.next/static/chunks/app/api/plugins/route-cbaaee0d7aacb85a.js +1 -0
  145. package/.next/static/chunks/app/api/prompts/route-cbaaee0d7aacb85a.js +1 -0
  146. package/.next/static/chunks/app/api/remote/route-cbaaee0d7aacb85a.js +1 -0
  147. package/.next/static/chunks/app/api/security/route-cbaaee0d7aacb85a.js +1 -0
  148. package/.next/static/chunks/app/api/sessions/[id]/export/route-cbaaee0d7aacb85a.js +1 -0
  149. package/.next/static/chunks/app/api/sessions/[id]/route-cbaaee0d7aacb85a.js +1 -0
  150. package/.next/static/chunks/app/api/sessions/import/route-cbaaee0d7aacb85a.js +1 -0
  151. package/.next/static/chunks/app/api/sessions/route-cbaaee0d7aacb85a.js +1 -0
  152. package/.next/static/chunks/app/api/skills/route-cbaaee0d7aacb85a.js +1 -0
  153. package/.next/static/chunks/app/api/update/route-cbaaee0d7aacb85a.js +1 -0
  154. package/.next/static/chunks/app/api/version/route-cbaaee0d7aacb85a.js +1 -0
  155. package/.next/static/chunks/app/api/web-auth/route-cbaaee0d7aacb85a.js +1 -0
  156. package/.next/static/chunks/app/api/workspaces/route-cbaaee0d7aacb85a.js +1 -0
  157. package/.next/static/chunks/app/page-dc3cb41d91c7432e.js +1 -0
  158. package/.next/static/chunks/next/dist/client/components/builtin/app-error-cbaaee0d7aacb85a.js +1 -0
  159. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-cbaaee0d7aacb85a.js +1 -0
  160. package/.next/static/chunks/next/dist/client/components/builtin/not-found-cbaaee0d7aacb85a.js +1 -0
  161. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-cbaaee0d7aacb85a.js +1 -0
  162. package/.next/static/chunks/webpack-2ac3d76b81895f5b.js +1 -0
  163. package/.next/static/css/9fc559c2c4ad1d0f.css +1 -0
  164. package/.next/static/css/c6e2227c36ec2fd6.css +1 -0
  165. package/.next/types/app/api/agent/[id]/side-chat/route.ts +355 -0
  166. package/.next/types/routes.d.ts +2 -1
  167. package/.next/types/validator.ts +10 -0
  168. package/next.config.ts +2 -0
  169. package/package.json +7 -1
  170. package/scripts/release.mjs +3 -7
  171. package/scripts/runtime/check-installed.mjs +66 -0
  172. package/scripts/runtime/pi-packages.mjs +51 -0
  173. package/scripts/verify-package.mjs +29 -7
  174. package/src/app/api/agent/[id]/side-chat/route.ts +77 -0
  175. package/src/app/globals.css +1576 -14
  176. package/src/components/AppShell.tsx +63 -13
  177. package/src/components/ChatInput.tsx +33 -29
  178. package/src/components/ChatWindow.tsx +6 -7
  179. package/src/components/ContextMeter.tsx +35 -16
  180. package/src/components/DrawerPanel.tsx +1 -1
  181. package/src/components/FileViewer.tsx +89 -8
  182. package/src/components/GrowthTree.tsx +30 -5
  183. package/src/components/ModelSelector.tsx +21 -5
  184. package/src/components/SettingsPanel.tsx +25 -16
  185. package/src/components/chat/reading-anchor.ts +81 -0
  186. package/src/components/composer/ComposerImages.tsx +31 -11
  187. package/src/components/side-chat/SideChatPanel.module.css +131 -0
  188. package/src/components/side-chat/SideChatPanel.tsx +189 -0
  189. package/src/components/side-chat/icons.tsx +8 -0
  190. package/src/components/side-chat/request-history.ts +29 -0
  191. package/src/components/side-chat/store.ts +248 -0
  192. package/src/hooks/useFileViewer.ts +6 -4
  193. package/src/i18n.tsx +50 -8
  194. package/src/lib/files-service.ts +66 -2
  195. package/src/lib/path-security.ts +24 -32
  196. package/src/lib/session-reader.ts +1 -1
  197. package/src/lib/side-chat/context.ts +123 -0
  198. package/src/lib/side-chat/service.ts +191 -0
  199. package/src/lib/side-chat/types.ts +61 -0
  200. package/src/lib/types.ts +1 -1
  201. package/src/lib/update-service.ts +4 -6
  202. package/.next/server/chunks/229.js +0 -1
  203. package/.next/server/chunks/393.js +0 -1
  204. package/.next/server/chunks/421.js +0 -1
  205. package/.next/server/chunks/496.js +0 -1
  206. package/.next/server/chunks/662.js +0 -2
  207. package/.next/server/chunks/686.js +0 -1
  208. package/.next/server/chunks/802.js +0 -2
  209. package/.next/server/chunks/807.js +0 -1
  210. package/.next/server/chunks/924.js +0 -6
  211. package/.next/static/chunks/2716.7ad073038e94a802.js +0 -1
  212. package/.next/static/chunks/4673.4cf66cbdd8364c57.js +0 -1
  213. package/.next/static/chunks/4686-09fcd0028079006c.js +0 -1
  214. package/.next/static/chunks/7411.67366dc5032a141d.js +0 -1
  215. package/.next/static/chunks/app/_global-error/page-f977af7f858990a2.js +0 -1
  216. package/.next/static/chunks/app/api/agent/[id]/events/route-f977af7f858990a2.js +0 -1
  217. package/.next/static/chunks/app/api/agent/[id]/route-f977af7f858990a2.js +0 -1
  218. package/.next/static/chunks/app/api/agent/[id]/tree/route-f977af7f858990a2.js +0 -1
  219. package/.next/static/chunks/app/api/agent/new/route-f977af7f858990a2.js +0 -1
  220. package/.next/static/chunks/app/api/config/route-f977af7f858990a2.js +0 -1
  221. package/.next/static/chunks/app/api/files/route-f977af7f858990a2.js +0 -1
  222. package/.next/static/chunks/app/api/fs/route-f977af7f858990a2.js +0 -1
  223. package/.next/static/chunks/app/api/git/route-f977af7f858990a2.js +0 -1
  224. package/.next/static/chunks/app/api/github/route-f977af7f858990a2.js +0 -1
  225. package/.next/static/chunks/app/api/growth/route-f977af7f858990a2.js +0 -1
  226. package/.next/static/chunks/app/api/health/route-f977af7f858990a2.js +0 -1
  227. package/.next/static/chunks/app/api/models/defaults/route-f977af7f858990a2.js +0 -1
  228. package/.next/static/chunks/app/api/models/route-f977af7f858990a2.js +0 -1
  229. package/.next/static/chunks/app/api/pi-settings/route-f977af7f858990a2.js +0 -1
  230. package/.next/static/chunks/app/api/plugins/route-f977af7f858990a2.js +0 -1
  231. package/.next/static/chunks/app/api/prompts/route-f977af7f858990a2.js +0 -1
  232. package/.next/static/chunks/app/api/remote/route-f977af7f858990a2.js +0 -1
  233. package/.next/static/chunks/app/api/security/route-f977af7f858990a2.js +0 -1
  234. package/.next/static/chunks/app/api/sessions/[id]/export/route-f977af7f858990a2.js +0 -1
  235. package/.next/static/chunks/app/api/sessions/[id]/route-f977af7f858990a2.js +0 -1
  236. package/.next/static/chunks/app/api/sessions/import/route-f977af7f858990a2.js +0 -1
  237. package/.next/static/chunks/app/api/sessions/route-f977af7f858990a2.js +0 -1
  238. package/.next/static/chunks/app/api/skills/route-f977af7f858990a2.js +0 -1
  239. package/.next/static/chunks/app/api/update/route-f977af7f858990a2.js +0 -1
  240. package/.next/static/chunks/app/api/version/route-f977af7f858990a2.js +0 -1
  241. package/.next/static/chunks/app/api/web-auth/route-f977af7f858990a2.js +0 -1
  242. package/.next/static/chunks/app/api/workspaces/route-f977af7f858990a2.js +0 -1
  243. package/.next/static/chunks/app/page-fc4ed2ff29b6235e.js +0 -1
  244. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f977af7f858990a2.js +0 -1
  245. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f977af7f858990a2.js +0 -1
  246. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f977af7f858990a2.js +0 -1
  247. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f977af7f858990a2.js +0 -1
  248. package/.next/static/chunks/webpack-22ebffc20adb44ae.js +0 -1
  249. package/.next/static/css/6671d0d1589fdc0e.css +0 -1
  250. /package/.next/static/{WMazYvD-gbeLsCUfUxvvM → hYPL1-dmhKFjLS3f5yoqN}/_buildManifest.js +0 -0
  251. /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
- setDrawerSection(section);
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
- setDrawerSection("project");
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={setDrawerSection}
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
- Ctrl/⌘+Shift+E 仍然直达项目入口(见快捷键绑定)。 */}
913
- {/* 右侧抽屉:项目 / 提示词 / Git 详情 / 会话导入 四个入口都在里面 */}
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="mb-2 flex flex-wrap gap-1.5 px-1" data-testid="composer-references">
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="flex items-center gap-1 rounded-md px-2 py-1"
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="flex h-4 w-4 flex-none items-center justify-center rounded-full text-[10px]"
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="mb-2 flex flex-wrap gap-2 px-1">
685
- {uploads.map((f, i) => (
686
- <div
687
- key={f.path}
688
- className="flex items-center gap-2 rounded-lg px-2.5 py-1.5"
689
- style={{ border: "0.5px solid var(--dsw-border-l2)", background: "var(--dsw-hover)" }}
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
- className="flex h-5 w-5 items-center justify-center rounded-full text-[10px]"
697
- style={{ color: "var(--dsw-label-caption)" }}
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="remove"
697
+ aria-label={`${t.delete} ${f.name}`}
701
698
  >
702
699
  ✕
703
700
  </button>
704
- </div>
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
- const coverage = (n: number | undefined, total: number) => n !== undefined && n < total
1261
- ? ` (${t.statsTimedCoverage.replace("{n}", String(n)).replace("{total}", String(total))})` : "";
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
- const timedLabel = (steps: number) => ` (${t.statsMeasuredSteps.replace("{n}", String(steps))})`;
1267
- if (stats.ttftSteps > 0) speeds.push(`${t.ttftAverage} ${fmtDuration(stats.ttftMs / stats.ttftSteps)}${timedLabel(stats.ttftSteps)}`);
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
- const usageLabel = percent === null && !recomputed ? t.contextUnknown : `${fmtPercent(p)} ${t.contextEstimated}`;
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
- // 超过窗口时预留不再画到窗口外;未知用量不能将整扇窗口显示为 Free Space。
63
- const visibleReserved = known && window_ > 0 ? Math.min(reserved, Math.max(0, window_ - used)) : 0;
64
- const free = known && window_ > 0 ? Math.max(0, window_ - used - reserved) : null;
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
- {known && window_ > 0 && (
97
- <div className="mt-3 flex h-2 w-full overflow-hidden rounded-full" style={{ background: "var(--dsw-selector)" }} aria-hidden>
98
- <span className="flex-none" style={{ width: `${Math.min(100, used / window_ * 100)}%`, background: "var(--dsw-accent)" }} />
99
- {visibleReserved > 0 && <span className="flex-none" style={{ width: `${visibleReserved / window_ * 100}%`, background: "var(--dsw-border-l3)" }} />}
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
- <div className="mt-3 font-medium">{t.contextCharacterBreakdown}</div>
107
- <div className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }}>{t.contextCharacterHint}</div>
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
- /** 抽屉里的入口,顺序即顶部入口列表的显示顺序:项目 → 提示词 → Git 详情 → 会话导入 */
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
- {!loaded || loaded.key !== `${activeTab.path}|${mode}|${range?.from ?? ""}|${range?.to ?? ""}|${change?.status ?? ""}` ? (
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}