@rexvane/piweb 0.3.17 → 0.3.19

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 (261) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +6 -4
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +4 -4
  5. package/.next/react-loadable-manifest.json +23 -9
  6. package/.next/required-server-files.js +2 -1
  7. package/.next/required-server-files.json +2 -1
  8. package/.next/routes-manifest.json +12 -0
  9. package/.next/server/app/_global-error/page.js +2 -2
  10. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_global-error.html +1 -1
  12. package/.next/server/app/_global-error.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js +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 +3 -3
  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]/tree/route.js +1 -1
  28. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  29. package/.next/server/app/api/agent/new/route.js +1 -1
  30. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  31. package/.next/server/app/api/config/route.js +1 -1
  32. package/.next/server/app/api/config/route.js.nft.json +1 -1
  33. package/.next/server/app/api/files/route.js +1 -1
  34. package/.next/server/app/api/files/route.js.nft.json +1 -1
  35. package/.next/server/app/api/fs/route.js +1 -0
  36. package/.next/server/app/api/fs/route.js.nft.json +1 -0
  37. package/.next/server/app/api/fs/route_client-reference-manifest.js +1 -0
  38. package/.next/server/app/api/git/route.js +2 -2
  39. package/.next/server/app/api/git/route.js.nft.json +1 -1
  40. package/.next/server/app/api/growth/route.js +1 -1
  41. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  42. package/.next/server/app/api/health/route.js +1 -1
  43. package/.next/server/app/api/models/route.js +1 -1
  44. package/.next/server/app/api/models/route.js.nft.json +1 -1
  45. package/.next/server/app/api/pi-settings/route.js +1 -1
  46. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  47. package/.next/server/app/api/plugins/route.js +1 -1
  48. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  49. package/.next/server/app/api/prompts/route.js +1 -1
  50. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  51. package/.next/server/app/api/remote/route.js +8 -0
  52. package/.next/server/app/api/remote/route.js.nft.json +1 -0
  53. package/.next/server/app/api/remote/route_client-reference-manifest.js +1 -0
  54. package/.next/server/app/api/security/route.js +1 -1
  55. package/.next/server/app/api/security/route.js.nft.json +1 -1
  56. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  57. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  58. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  59. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  60. package/.next/server/app/api/sessions/import/route.js +2 -2
  61. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  62. package/.next/server/app/api/sessions/route.js +1 -1
  63. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  64. package/.next/server/app/api/skills/route.js +1 -1
  65. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  66. package/.next/server/app/api/update/route.js +2 -2
  67. package/.next/server/app/api/update/route.js.nft.json +1 -1
  68. package/.next/server/app/api/version/route.js +1 -1
  69. package/.next/server/app/api/version/route.js.nft.json +1 -1
  70. package/.next/server/app/api/web-auth/route.js +3 -1
  71. package/.next/server/app/api/workspaces/route.js +1 -1
  72. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  73. package/.next/server/app/icon.svg/route.js +1 -1
  74. package/.next/server/app/index.html +1 -1
  75. package/.next/server/app/index.rsc +4 -4
  76. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  77. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  78. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  79. package/.next/server/app/login/page.js +1 -1
  80. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  81. package/.next/server/app/login.html +1 -1
  82. package/.next/server/app/login.rsc +3 -3
  83. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  84. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  85. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  86. package/.next/server/app/page.js +1 -1
  87. package/.next/server/app/page.js.nft.json +1 -1
  88. package/.next/server/app/page_client-reference-manifest.js +1 -1
  89. package/.next/server/app-paths-manifest.json +6 -4
  90. package/.next/server/chunks/229.js +1 -1
  91. package/.next/server/chunks/243.js +1 -0
  92. package/.next/server/chunks/393.js +1 -0
  93. package/.next/server/chunks/496.js +1 -1
  94. package/.next/server/chunks/538.js +1 -0
  95. package/.next/server/chunks/686.js +1 -4
  96. package/.next/server/chunks/802.js +2 -0
  97. package/.next/server/chunks/807.js +1 -0
  98. package/.next/server/chunks/920.js +4 -0
  99. package/.next/server/chunks/924.js +6 -0
  100. package/.next/server/instrumentation.js +1 -0
  101. package/.next/server/instrumentation.js.nft.json +1 -0
  102. package/.next/server/middleware-build-manifest.js +1 -1
  103. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  104. package/.next/server/middleware.js +4 -3
  105. package/.next/server/pages/404.html +1 -1
  106. package/.next/server/pages/500.html +1 -1
  107. package/.next/server/server-reference-manifest.json +1 -1
  108. package/.next/static/JDGSR-TIbk9qZWcUNYIX3/_buildManifest.js +1 -0
  109. package/.next/static/chunks/2716.7ad073038e94a802.js +1 -0
  110. package/.next/static/chunks/316-5ea443ee6b512aa9.js +1 -0
  111. package/.next/static/chunks/4673.8a5a9e6855350e34.js +1 -0
  112. package/.next/static/chunks/4686-e3825f3b6a384aa1.js +1 -0
  113. package/.next/static/chunks/4778.e4c9ed093a413bc4.js +1 -0
  114. package/.next/static/chunks/5173.91ddb803785009f8.js +1 -0
  115. package/.next/static/chunks/839.7403c6bdace4c56c.js +1 -0
  116. package/.next/static/chunks/8552.6bfe3e101250b20f.js +1 -0
  117. package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +1 -0
  118. package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +1 -0
  119. package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +1 -0
  120. package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +1 -0
  121. package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +1 -0
  122. package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +1 -0
  123. package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +1 -0
  124. package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +1 -0
  125. package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +1 -0
  126. package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +1 -0
  127. package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +1 -0
  128. package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +1 -0
  129. package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +1 -0
  130. package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +1 -0
  131. package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +1 -0
  132. package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +1 -0
  133. package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +1 -0
  134. package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +1 -0
  135. package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +1 -0
  136. package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +1 -0
  137. package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +1 -0
  138. package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +1 -0
  139. package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +1 -0
  140. package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +1 -0
  141. package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +1 -0
  142. package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +1 -0
  143. package/.next/static/chunks/app/login/page-5e276a1eeb8867df.js +1 -0
  144. package/.next/static/chunks/app/page-2e425ef83fba0782.js +1 -0
  145. package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +1 -0
  146. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +1 -0
  147. package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +1 -0
  148. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +1 -0
  149. package/.next/static/chunks/webpack-bd2b9202ecbcdcaa.js +1 -0
  150. package/.next/static/css/7391c42e1ae617f9.css +1 -0
  151. package/.next/types/app/api/fs/route.ts +353 -0
  152. package/.next/types/app/api/remote/route.ts +353 -0
  153. package/.next/types/routes.d.ts +3 -1
  154. package/.next/types/validator.ts +18 -0
  155. package/README.md +56 -10
  156. package/next.config.ts +3 -2
  157. package/package.json +3 -3
  158. package/scripts/launcher.mjs +194 -13
  159. package/scripts/release.mjs +0 -2
  160. package/src/app/api/agent/[id]/events/route.ts +11 -6
  161. package/src/app/api/fs/route.ts +119 -0
  162. package/src/app/api/growth/route.ts +11 -6
  163. package/src/app/api/models/route.ts +21 -9
  164. package/src/app/api/remote/route.ts +67 -0
  165. package/src/app/api/sessions/[id]/export/route.ts +11 -1
  166. package/src/app/api/sessions/route.ts +3 -1
  167. package/src/app/api/web-auth/route.ts +45 -49
  168. package/src/app/api/workspaces/route.ts +16 -0
  169. package/src/app/globals.css +11 -3
  170. package/src/app/login/page.tsx +8 -60
  171. package/src/components/AppShell.tsx +202 -155
  172. package/src/components/ChatInput.tsx +61 -6
  173. package/src/components/ChatWindow.tsx +274 -132
  174. package/src/components/DirectoryPicker.tsx +189 -0
  175. package/src/components/DrawerPanel.tsx +164 -0
  176. package/src/components/FileViewer.tsx +2 -2
  177. package/src/components/GrowthTree.tsx +6 -5
  178. package/src/components/MarkdownBlock.tsx +134 -0
  179. package/src/components/OutlineRail.tsx +22 -1
  180. package/src/components/ProjectPanel.tsx +197 -14
  181. package/src/components/RemoteSection.tsx +232 -0
  182. package/src/components/SessionImportSection.tsx +60 -38
  183. package/src/components/SettingsPanel.tsx +104 -57
  184. package/src/components/icons.tsx +23 -0
  185. package/src/hooks/useGrowth.ts +47 -25
  186. package/src/hooks/usePiWeb.ts +100 -14
  187. package/src/i18n.tsx +115 -28
  188. package/src/instrumentation-node.ts +35 -0
  189. package/src/instrumentation.ts +18 -0
  190. package/src/lib/agent-config.ts +134 -0
  191. package/src/lib/agent-manager.ts +243 -61
  192. package/src/lib/auth-throttle.ts +102 -0
  193. package/src/lib/auth.ts +35 -27
  194. package/src/lib/files-service.ts +61 -1
  195. package/src/lib/font.ts +84 -0
  196. package/src/lib/git-service.ts +5 -3
  197. package/src/lib/growth-service.ts +78 -30
  198. package/src/lib/growth-tracker.ts +91 -9
  199. package/src/lib/models-service.ts +36 -11
  200. package/src/lib/pi-settings.ts +10 -12
  201. package/src/lib/pi.ts +145 -18
  202. package/src/lib/plugins-service.ts +67 -35
  203. package/src/lib/remote/assets.json +4 -0
  204. package/src/lib/remote/mobile-proxy.ts +149 -0
  205. package/src/lib/remote/supervisor.ts +482 -0
  206. package/src/lib/security-service.ts +6 -11
  207. package/src/lib/session-import/claude.ts +45 -21
  208. package/src/lib/session-reader.ts +106 -3
  209. package/src/lib/skills-service.ts +34 -12
  210. package/src/lib/text-sanitize.ts +12 -0
  211. package/src/lib/theme.ts +3 -0
  212. package/src/lib/types.ts +16 -2
  213. package/src/lib/update-service.ts +0 -1
  214. package/src/lib/web-auth-shared.ts +5 -1
  215. package/src/lib/web-auth.ts +139 -22
  216. package/src/lib/web-launch-token.ts +55 -0
  217. package/src/lib/web-secret.ts +182 -0
  218. package/src/lib/workspace-store.ts +91 -7
  219. package/src/proxy.ts +46 -12
  220. package/.next/server/chunks/532.js +0 -1
  221. package/.next/static/chunks/2155.2f27fe7098987699.js +0 -1
  222. package/.next/static/chunks/2608.ec8ff1f5767c9056.js +0 -1
  223. package/.next/static/chunks/2716.93be8ad5c970c516.js +0 -1
  224. package/.next/static/chunks/4632.112d2e893d48d188.js +0 -1
  225. package/.next/static/chunks/4673.c1fb1580efc6ace7.js +0 -1
  226. package/.next/static/chunks/4686-f4a037d609cea3f2.js +0 -1
  227. package/.next/static/chunks/692-7992db0ae9cc3021.js +0 -1
  228. package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +0 -1
  229. package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +0 -1
  230. package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +0 -1
  231. package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +0 -1
  232. package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +0 -1
  233. package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +0 -1
  234. package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +0 -1
  235. package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +0 -1
  236. package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +0 -1
  237. package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +0 -1
  238. package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +0 -1
  239. package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +0 -1
  240. package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +0 -1
  241. package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +0 -1
  242. package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +0 -1
  243. package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +0 -1
  244. package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +0 -1
  245. package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +0 -1
  246. package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +0 -1
  247. package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +0 -1
  248. package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +0 -1
  249. package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +0 -1
  250. package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +0 -1
  251. package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +0 -1
  252. package/.next/static/chunks/app/login/page-52ef4841cbd199a8.js +0 -1
  253. package/.next/static/chunks/app/page-b51087831582ee37.js +0 -1
  254. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +0 -1
  255. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +0 -1
  256. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +0 -1
  257. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +0 -1
  258. package/.next/static/chunks/webpack-c31263fd83d75ce6.js +0 -1
  259. package/.next/static/css/850c2fd130ffa2fc.css +0 -1
  260. package/.next/static/zsynuO_9Kx6nrXgkTUJ04/_buildManifest.js +0 -1
  261. /package/.next/static/{zsynuO_9Kx6nrXgkTUJ04 → JDGSR-TIbk9qZWcUNYIX3}/_ssgManifest.js +0 -0
@@ -1,10 +1,10 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * 项目栏(侧栏与对话之间的第四列):项目生长 = 每轮一个 git commit。
4
+ * 项目栏:右侧抽屉的「项目」入口(不再独占网格一列)。项目生长 = 每轮一个 git commit。
5
5
  * 头部:工作区名 + 立即记录 / 收起;所选轮:提问首行、时间、文件数与行数、在对话里看;
6
6
  * 工具行:范围(本轮 / 本会话累计)、只看改动、筛选;中间:项目树(GrowthTree);
7
- * 底部:时间轴每轮一根柱(高度 = 改动行数,灰色细柱 = 你在两轮之间自己的修改),默认跟随最新一轮。
7
+ * 底部:时间轴每轮一根柱(高度 = 改动行数,灰色细柱 = 你在两轮之间自己的修改),默认不再跟随,底部计数可点开轮数列表跳到任意一轮。
8
8
  */
9
9
  import { useEffect, useMemo, useRef, useState } from "react";
10
10
  import { GrowthTree } from "@/components/GrowthTree";
@@ -13,7 +13,59 @@ import type { GrowthApi, GrowthEntry } from "@/hooks/useGrowth";
13
13
  import { useI18n } from "@/i18n";
14
14
  import type { GitInfo } from "@/lib/git-service";
15
15
  import type { TreeNode } from "@/lib/growth-tree";
16
- import type { GrowthChange } from "@/lib/types";
16
+ import type { GrowthChange, GrowthConsentRequest } from "@/lib/types";
17
+
18
+ /**
19
+ * 「要不要在这个工作区建 git 仓库」:工作区还不是仓库根目录时,生长记录第一次提交前问一次。
20
+ * 关掉(Esc / 点遮罩外)不算回答,下次打开会话还会再问;只有两个按钮会被记住。
21
+ */
22
+ export function GrowthConsentDialog({ request, onAnswer }: { request: GrowthConsentRequest; onAnswer: (allow: boolean) => void }) {
23
+ const { t } = useI18n();
24
+ const [dismissed, setDismissed] = useState(false);
25
+ const ref = useRef<HTMLDivElement>(null);
26
+ useEffect(() => {
27
+ setDismissed(false);
28
+ ref.current?.focus();
29
+ }, [request.cwd, request.reason]);
30
+ if (dismissed) return null;
31
+ const body = request.reason === "nested" ? t.growthConsentNested.replace("{parent}", request.parent ?? "") : t.growthConsentNotGit;
32
+ return (
33
+ <div
34
+ ref={ref}
35
+ tabIndex={-1}
36
+ className="fixed inset-0 z-[120] flex items-center justify-center"
37
+ style={{ background: "var(--dsw-mask)" }}
38
+ role="dialog"
39
+ aria-modal="true"
40
+ aria-label={t.growthConsentTitle}
41
+ data-testid="growth-consent-dialog"
42
+ onKeyDown={(event) => {
43
+ event.stopPropagation();
44
+ if (event.key === "Escape") setDismissed(true);
45
+ }}
46
+ >
47
+ <div className="popover flex w-[min(92vw,480px)] flex-col gap-3 p-5" style={{ background: "var(--dsw-glass-modal)" }}>
48
+ <div className="flex items-start gap-2.5">
49
+ <span className="flex flex-none items-center justify-center rounded-lg" style={{ width: 30, height: 30, background: "var(--dsw-hover)" }}>
50
+ <IconGitOutline16 size={16} style={{ color: "var(--dsw-label-secondary)" }} />
51
+ </span>
52
+ <div className="min-w-0 flex-1">
53
+ <div style={{ fontSize: 14.5, fontWeight: 600, lineHeight: 1.4 }}>{t.growthConsentTitle}</div>
54
+ <div className="mt-0.5 truncate" style={{ fontSize: 11, color: "var(--dsw-label-caption)", fontFamily: "var(--font-mono)" }} title={request.cwd}>{request.cwd}</div>
55
+ </div>
56
+ </div>
57
+ <div className="rounded-xl px-3 py-2.5" style={{ fontSize: 12.5, color: "var(--dsw-label-secondary)", lineHeight: 1.55, background: "var(--dsw-hover)" }}>
58
+ {body}
59
+ <div className="mt-1.5" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>{t.growthConsentNote}</div>
60
+ </div>
61
+ <div className="flex justify-end gap-2">
62
+ <button className="btn-outline" style={{ height: 32 }} onClick={() => onAnswer(false)}>{t.growthConsentDecline}</button>
63
+ <button className="btn-primary-white" style={{ height: 32 }} onClick={() => onAnswer(true)}>{t.growthConsentAllow}</button>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ );
68
+ }
17
69
 
18
70
  /** 「第 7 轮 · 把保存按钮改成红色」/「你的修改」 */
19
71
  export function roundTitle(round: GrowthEntry, t: Record<string, string>): string {
@@ -90,6 +142,13 @@ export function ProjectPanel({
90
142
  const [onlyChanges, setOnlyChanges] = useState(true);
91
143
  const [recording, setRecording] = useState(false);
92
144
  const [notice, setNotice] = useState("");
145
+ /** 轮数列表(底部计数点开) */
146
+ const [roundPickerOpen, setRoundPickerOpen] = useState(false);
147
+ /** 列表顶部「输入轮号...」已就地把那一行变成输入框 */
148
+ const [jumpEditing, setJumpEditing] = useState(false);
149
+ const [jumpValue, setJumpValue] = useState("");
150
+ const roundPickerRef = useRef<HTMLDivElement>(null);
151
+ const roundListRef = useRef<HTMLDivElement>(null);
93
152
  const [gitState, setGitState] = useState<{ cwd: string; info: GitInfo | null; error?: string } | null>(null);
94
153
  const git = gitState?.cwd === cwd ? gitState.info : null;
95
154
  const gitError = gitState?.cwd === cwd ? gitState.error ?? git?.statusError : undefined;
@@ -125,6 +184,43 @@ export function ProjectPanel({
125
184
  railRef.current?.querySelector<HTMLElement>(`[data-commit="${selected.commit}"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
126
185
  }, [selected]);
127
186
  const selectedIdx = selected ? rounds.findIndex((round) => round.commit === selected.commit) : -1;
187
+
188
+ // 轮数列表:打开时自动滚到当前轮;Esc / 点外部关闭
189
+ useEffect(() => {
190
+ if (!roundPickerOpen) {
191
+ setJumpEditing(false);
192
+ setJumpValue("");
193
+ return;
194
+ }
195
+ requestAnimationFrame(() => {
196
+ roundListRef.current?.querySelector('[data-selected]')?.scrollIntoView({ block: "center" });
197
+ });
198
+ const onPointerDown = (e: MouseEvent) => {
199
+ if (!roundPickerRef.current?.contains(e.target as Node)) setRoundPickerOpen(false);
200
+ };
201
+ const onKeyDown = (e: KeyboardEvent) => {
202
+ if (e.key === "Escape") {
203
+ e.stopPropagation();
204
+ setRoundPickerOpen(false);
205
+ }
206
+ };
207
+ document.addEventListener("mousedown", onPointerDown);
208
+ document.addEventListener("keydown", onKeyDown);
209
+ return () => {
210
+ document.removeEventListener("mousedown", onPointerDown);
211
+ document.removeEventListener("keydown", onKeyDown);
212
+ };
213
+ }, [roundPickerOpen]);
214
+ /** 跳到第 n 轮(轮号从 1 起,只数 pi 的轮次);越界夹到有效范围,非法输入不跳 */
215
+ const jumpToRound = (raw: string) => {
216
+ const parsed = Number.parseInt(raw.trim(), 10);
217
+ if (Number.isNaN(parsed) || piRounds === 0) return;
218
+ const n = Math.max(1, Math.min(piRounds, parsed));
219
+ const round = rounds.find((item) => item.n === n);
220
+ if (!round) return;
221
+ growth.select(round.commit);
222
+ setRoundPickerOpen(false);
223
+ };
128
224
  const conflicts = git?.files.filter((file) => file.kind === "conflict").length ?? 0;
129
225
  const staged = git?.files.filter((file) => file.kind !== "conflict" && file.indexStatus !== " ").length ?? 0;
130
226
  const unstaged = git?.files.filter((file) => file.kind !== "conflict" && file.workStatus !== " " && file.kind !== "untracked").length ?? 0;
@@ -152,8 +248,10 @@ export function ProjectPanel({
152
248
  const emptyText = !cwd || !hasSession ? t.growthNoSession : growth.loading && !rounds.length ? t.growthLoading : !growth.available ? t.growthUnavailable : rounds.length ? undefined : t.growthNoRounds;
153
249
  const statusText = selected?.status === "aborted" ? t.growthStatusAborted : selected?.status === "error" ? t.growthStatusError : "";
154
250
 
251
+ // 项目面板现在是抽屉的一个入口(不再独占网格一列):.pw-project 自带的右描边在抽屉里
252
+ // 会变成贴在窗口最右边的一条多余发丝线,所以这里去掉;背景沿用侧栏填充色即可。
155
253
  return (
156
- <div className="pw-project flex h-full min-h-0 flex-col" data-testid="project-panel">
254
+ <div className="pw-project flex h-full min-h-0 flex-1 flex-col" style={{ borderRight: "none" }} data-testid="project-panel">
157
255
  {/* 头部 */}
158
256
  <div className="flex items-center gap-1.5 px-3 pt-3 pb-1.5">
159
257
  <span className="min-w-0 flex-1 truncate" style={{ fontSize: 13, fontWeight: 600 }} title={cwd}>
@@ -314,16 +412,101 @@ export function ProjectPanel({
314
412
  <button className="icon-btn" style={{ width: 20, height: 20 }} title={t.growthNext} onClick={growth.next} disabled={selectedIdx < 0 || selectedIdx >= rounds.length - 1}>
315
413
  <IconChevronRight14 size={12} />
316
414
  </button>
317
- <span className="min-w-0 flex-1 truncate" style={{ color: "var(--dsw-label-secondary)" }}>
318
- {selected?.n
319
- ? t.growthRoundPosition.replace("{current}", String(selected.n)).replace("{total}", String(piRounds))
320
- : selected
321
- ? t.growthKindUser
322
- : t.growthRoundPosition.replace("{current}", "0").replace("{total}", String(piRounds))}
323
- </span>
324
- <button type="button" className="pw-chip" data-active={growth.following} onClick={growth.follow} title={t.growthFollow}>
325
- {growth.following ? t.growthFollowing : t.growthFollow}
326
- </button>
415
+ {/* 计数可点开轮数列表:单击直接弹列表(不做双击变输入框,避免 250ms 单双击
416
+ 判定拖慢弹出);「输入轮号」是列表里一个固定入口,选中后当场变数字输入框 */}
417
+ <div className="relative min-w-0 flex-1">
418
+ <button
419
+ type="button"
420
+ className="w-full truncate rounded px-1 text-left"
421
+ style={{ color: "var(--dsw-label-secondary)", background: roundPickerOpen ? "var(--dsw-active)" : undefined }}
422
+ title={t.growthPickRound}
423
+ aria-haspopup="listbox"
424
+ aria-expanded={roundPickerOpen}
425
+ data-testid="growth-round-picker"
426
+ onClick={() => setRoundPickerOpen((v) => !v)}
427
+ >
428
+ {selected?.n
429
+ ? t.growthRoundPosition.replace("{current}", String(selected.n)).replace("{total}", String(piRounds))
430
+ : selected
431
+ ? t.growthKindUser
432
+ : t.growthRoundPosition.replace("{current}", "0").replace("{total}", String(piRounds))}
433
+ </button>
434
+ {roundPickerOpen && (
435
+ <div
436
+ ref={roundPickerRef}
437
+ className="popover absolute bottom-7 left-0 z-50 flex w-[268px] max-w-[calc(100vw-24px)] flex-col"
438
+ role="listbox"
439
+ aria-label={t.growthPickRound}
440
+ data-testid="growth-round-list"
441
+ >
442
+ {/* 「输入轮号」入口固定在最上面:选中后它自己变成数字输入框 */}
443
+ {jumpEditing ? (
444
+ <div className="px-1.5 pb-1">
445
+ <input
446
+ autoFocus
447
+ value={jumpValue}
448
+ inputMode="numeric"
449
+ placeholder={t.growthRoundJump}
450
+ title={t.growthRoundJumpHint}
451
+ data-testid="growth-round-input"
452
+ className="w-full rounded-md px-1.5 py-1"
453
+ style={{ fontSize: 12.5, background: "var(--dsw-hover)" }}
454
+ onChange={(e) => setJumpValue(e.target.value)}
455
+ onKeyDown={(e) => {
456
+ if (e.key === "Enter") {
457
+ e.preventDefault();
458
+ jumpToRound(jumpValue);
459
+ }
460
+ }}
461
+ />
462
+ </div>
463
+ ) : (
464
+ <button
465
+ type="button"
466
+ className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors"
467
+ style={{ fontSize: 12.5, color: "var(--dsw-label-secondary)" }}
468
+ onMouseEnter={(e) => (e.currentTarget.style.background = "var(--dsw-hover)")}
469
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
470
+ data-testid="growth-round-jump"
471
+ onClick={() => {
472
+ setJumpValue("");
473
+ setJumpEditing(true);
474
+ }}
475
+ >
476
+ {t.growthRoundJump}
477
+ </button>
478
+ )}
479
+ {/* 轮次列表:当前轮高亮,打开时自动滚到它 */}
480
+ <div ref={roundListRef} className="min-h-0 max-h-[260px] overflow-y-auto border-t pt-1" style={{ borderColor: "var(--dsw-border-l1)" }}>
481
+ {rounds.map((round) => (
482
+ <button
483
+ key={round.commit}
484
+ type="button"
485
+ role="option"
486
+ aria-selected={selected?.commit === round.commit}
487
+ data-selected={selected?.commit === round.commit || undefined}
488
+ className="flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left transition-colors"
489
+ style={{ background: selected?.commit === round.commit ? "var(--dsw-active)" : "transparent" }}
490
+ onMouseEnter={(e) => {
491
+ if (selected?.commit !== round.commit) e.currentTarget.style.background = "var(--dsw-hover)";
492
+ }}
493
+ onMouseLeave={(e) => {
494
+ if (selected?.commit !== round.commit) e.currentTarget.style.background = "transparent";
495
+ }}
496
+ title={`${roundTitle(round, tt)} · ${fmtTime(round.ts)}`}
497
+ onClick={() => {
498
+ growth.select(round.commit);
499
+ setRoundPickerOpen(false);
500
+ }}
501
+ >
502
+ <span className="min-w-0 flex-1 truncate" style={{ fontSize: 12.5 }}>{roundTitle(round, tt)}</span>
503
+ <span className="flex-none" style={{ fontSize: 11, color: "var(--dsw-label-caption)" }}>{fmtTime(round.ts)}</span>
504
+ </button>
505
+ ))}
506
+ </div>
507
+ </div>
508
+ )}
509
+ </div>
327
510
  </div>
328
511
  </div>
329
512
  )}
@@ -0,0 +1,232 @@
1
+ "use client";
2
+
3
+ /**
4
+ * 设置 → 远程连接(内置插件)。
5
+ *
6
+ * 显示一个内置插件「远程连接」,它有两层入口:
7
+ * - 局域网入口:注入代理,默认就开着;手机连同一个 WiFi 用这个地址,排版是手机版。
8
+ * - 公网入口:cloudflared 快速隧道,由这里的按钮开关;开了之后任何浏览器都能打开。
9
+ *
10
+ * 公开地址由 Cloudflare 随机分配且每次启动都变;拿到地址后还要十几秒才在全球 DNS 生效,
11
+ * 所以状态里区分「已生成」和「已生效」,没生效时按钮区域给出说明而不是假装好了。
12
+ */
13
+ import { useCallback, useEffect, useRef, useState } from "react";
14
+ import { useI18n } from "@/i18n";
15
+
16
+ interface LanState {
17
+ running: boolean;
18
+ port: number | null;
19
+ urls: string[];
20
+ error: string | null;
21
+ }
22
+
23
+ interface PublicState {
24
+ running: boolean;
25
+ url: string | null;
26
+ ready: boolean;
27
+ engine: string | null;
28
+ engineAvailable: boolean;
29
+ error: string | null;
30
+ }
31
+
32
+ interface RemoteStatus {
33
+ plugin: string;
34
+ targetPort: number;
35
+ /** 非空时服务端拒绝开任何入口(没开认证 / 开发模式) */
36
+ blocked: "auth" | "dev" | null;
37
+ lan: LanState;
38
+ public: PublicState;
39
+ }
40
+
41
+ /** 状态点:绿=在跑,蓝=过渡中,灰=没开 */
42
+ function Dot({ tone }: { tone: "on" | "pending" | "off" }) {
43
+ const color = tone === "on" ? "var(--dsw-success)" : tone === "pending" ? "var(--dsw-accent)" : "var(--dsw-label-caption)";
44
+ return <span className="inline-block h-[7px] w-[7px] flex-none rounded-full" style={{ background: color }} />;
45
+ }
46
+
47
+ function Row({ title, hint, children }: { title: string; hint?: string; children: React.ReactNode }) {
48
+ return (
49
+ <div className="hairline-b flex flex-wrap items-start justify-between gap-4 pb-5">
50
+ <div className="min-w-0 flex-1" style={{ minWidth: "45%" }}>
51
+ <div style={{ fontSize: 14 }}>{title}</div>
52
+ {hint ? <div style={{ fontSize: 12, color: "var(--dsw-label-caption)", lineHeight: 1.55, marginTop: 2 }}>{hint}</div> : null}
53
+ </div>
54
+ <div className="flex-none">{children}</div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ export function RemoteSection() {
60
+ const { t } = useI18n();
61
+ const [status, setStatus] = useState<RemoteStatus | null>(null);
62
+ const [busy, setBusy] = useState(false);
63
+ const [error, setError] = useState("");
64
+ const alive = useRef(true);
65
+
66
+ const load = useCallback(async () => {
67
+ try {
68
+ const response = await fetch("/api/remote", { cache: "no-store" });
69
+ const result = await response.json();
70
+ if (!alive.current) return;
71
+ if (result?.success) {
72
+ setStatus(result.data as RemoteStatus);
73
+ setError("");
74
+ } else {
75
+ setError(result?.error ?? t.remoteFailed);
76
+ }
77
+ } catch {
78
+ if (alive.current) setError(t.remoteFailed);
79
+ }
80
+ }, [t]);
81
+
82
+ useEffect(() => {
83
+ alive.current = true;
84
+ void load();
85
+ return () => {
86
+ alive.current = false;
87
+ };
88
+ }, [load]);
89
+
90
+ // 地址已生成但还没生效时,短轮询到生效为止(实测这段要十几秒)
91
+ const waiting = Boolean(status?.public.running && !status.public.ready);
92
+ useEffect(() => {
93
+ if (!waiting) return;
94
+ const timer = setInterval(() => void load(), 2_500);
95
+ return () => clearInterval(timer);
96
+ }, [waiting, load]);
97
+
98
+ const act = async (action: "start" | "stop") => {
99
+ if (busy) return;
100
+ setBusy(true);
101
+ setError("");
102
+ try {
103
+ const response = await fetch("/api/remote", {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/json" },
106
+ body: JSON.stringify({ action }),
107
+ });
108
+ const result = await response.json();
109
+ if (result?.success) setStatus(result.data as RemoteStatus);
110
+ else setError(result?.error ?? t.remoteFailed);
111
+ } catch {
112
+ setError(t.remoteFailed);
113
+ } finally {
114
+ if (alive.current) setBusy(false);
115
+ }
116
+ };
117
+
118
+ // 「让所有设备重新登录」点两下才生效(第一下只把按钮换成确认文案,4 秒后复原)
119
+ const [confirmSignOut, setConfirmSignOut] = useState(false);
120
+ useEffect(() => {
121
+ if (!confirmSignOut) return;
122
+ const timer = setTimeout(() => setConfirmSignOut(false), 4_000);
123
+ return () => clearTimeout(timer);
124
+ }, [confirmSignOut]);
125
+ const signOutAll = async () => {
126
+ if (!confirmSignOut) {
127
+ setConfirmSignOut(true);
128
+ return;
129
+ }
130
+ setConfirmSignOut(false);
131
+ setError("");
132
+ try {
133
+ const response = await fetch("/api/web-auth?scope=all", { method: "DELETE" });
134
+ const result = await response.json().catch(() => null);
135
+ if (!response.ok || !result?.success) throw new Error(result?.error ?? t.remoteFailed);
136
+ window.location.href = "/login";
137
+ } catch (e) {
138
+ if (alive.current) setError(e instanceof Error ? e.message : t.remoteFailed);
139
+ }
140
+ };
141
+
142
+ const lan = status?.lan;
143
+ const pub = status?.public;
144
+ const blocked = status?.blocked ?? null;
145
+ const lanTone: "on" | "off" = lan?.running ? "on" : "off";
146
+ const pubTone: "on" | "pending" | "off" = pub?.running ? (pub.ready ? "on" : "pending") : "off";
147
+
148
+ return (
149
+ <div className="flex flex-col gap-6">
150
+ {/* 插件抬头 */}
151
+ <div className="flex items-center gap-2.5">
152
+ <div style={{ fontSize: 15, fontWeight: 500 }}>{t.setRemote}</div>
153
+ <span
154
+ className="rounded-md px-2 py-[2px]"
155
+ style={{ fontSize: 11, background: "var(--dsw-hover)", color: "var(--dsw-label-secondary)" }}
156
+ >
157
+ {t.remoteBuiltin}
158
+ </span>
159
+ </div>
160
+
161
+ {/* 局域网入口 */}
162
+ <Row title={t.remoteLanTitle} hint={t.remoteLanHint}>
163
+ <div className="flex flex-col items-end gap-1.5">
164
+ <div className="flex items-center gap-2" style={{ fontSize: 13 }}>
165
+ <Dot tone={lanTone} />
166
+ <span style={{ color: lan?.running ? "var(--dsw-label-primary)" : "var(--dsw-label-caption)" }}>
167
+ {lan?.running ? t.remoteRunning : t.remoteStopped}
168
+ </span>
169
+ </div>
170
+ {(lan?.urls ?? []).map((url) => (
171
+ <a key={url} href={url} target="_blank" rel="noreferrer" className="font-mono hover:underline" style={{ fontSize: 12 }}>
172
+ {url}
173
+ </a>
174
+ ))}
175
+ {lan?.error ? <div style={{ fontSize: 12, color: "var(--dsw-danger)" }}>{lan.error}</div> : null}
176
+ </div>
177
+ </Row>
178
+
179
+ {/* 公网入口 */}
180
+ <Row title={t.remotePublicTitle} hint={t.remotePublicHint}>
181
+ <div className="flex flex-col items-end gap-2">
182
+ <div className="flex items-center gap-2" style={{ fontSize: 13 }}>
183
+ <Dot tone={pubTone} />
184
+ <span style={{ color: pub?.running ? "var(--dsw-label-primary)" : "var(--dsw-label-caption)" }}>
185
+ {pub?.running ? (pub.ready ? t.remoteRunning : t.remotePending) : t.remoteStopped}
186
+ </span>
187
+ </div>
188
+ {pub?.url ? (
189
+ <a href={pub.url} target="_blank" rel="noreferrer" className="font-mono hover:underline" style={{ fontSize: 12 }}>
190
+ {pub.url}
191
+ </a>
192
+ ) : null}
193
+ <div className="flex items-center gap-2">
194
+ {pub?.running ? (
195
+ <button className="btn-outline" style={{ height: 30, fontSize: 13 }} disabled={busy} onClick={() => void act("stop")}>
196
+ {t.remoteStop}
197
+ </button>
198
+ ) : (
199
+ <button className="btn-primary-white" style={{ height: 30, fontSize: 13 }} disabled={busy || blocked !== null} onClick={() => void act("start")}>
200
+ {busy ? t.remoteWorking : t.remoteStart}
201
+ </button>
202
+ )}
203
+ </div>
204
+ </div>
205
+ </Row>
206
+
207
+ {/* 没开认证就没有登录这回事 */}
208
+ {status && blocked !== "auth" ? (
209
+ <Row title={t.remoteSessionsTitle} hint={t.remoteSessionsHint}>
210
+ <button
211
+ className="btn-outline"
212
+ style={{ height: 30, fontSize: 13, color: confirmSignOut ? "var(--dsw-danger)" : undefined }}
213
+ onClick={() => void signOutAll()}
214
+ >
215
+ {confirmSignOut ? t.remoteSignOutAllConfirm : t.remoteSignOutAll}
216
+ </button>
217
+ </Row>
218
+ ) : null}
219
+
220
+ {blocked ? (
221
+ <div style={{ fontSize: 12, color: "var(--dsw-label-secondary)", lineHeight: 1.6 }}>
222
+ {blocked === "auth" ? t.remoteBlockedAuth : t.remoteBlockedDev}
223
+ </div>
224
+ ) : null}
225
+ {pub && !pub.engineAvailable ? (
226
+ <div style={{ fontSize: 12, color: "var(--dsw-danger)", lineHeight: 1.6 }}>{t.remoteEngineMissing}</div>
227
+ ) : null}
228
+ {pub?.error ? <div style={{ fontSize: 12, color: "var(--dsw-danger)", lineHeight: 1.6 }}>{pub.error}</div> : null}
229
+ {error ? <div role="alert" style={{ fontSize: 12, color: "var(--dsw-danger)" }}>{error}</div> : null}
230
+ </div>
231
+ );
232
+ }
@@ -200,50 +200,38 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
200
200
  const skipped = report?.results.filter((result) => result.status === "skipped") ?? [];
201
201
 
202
202
  return (
203
- <div className="flex flex-col gap-4" data-testid="session-import">
204
- <div style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>{t.importDesc}</div>
205
- <div style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }} title={t.importLimitHintTooltip} data-testid="import-limit-hint">
206
- {t.importLimitHint.replace("{n}", String(SCAN_LIMIT))}
203
+ <div className="flex flex-col gap-3.5" data-testid="session-import">
204
+ {/* 说明区:主说明 + 扫描范围提示收进一个卡片,和下面的操作区分开 */}
205
+ <div className="flex flex-col gap-1.5 rounded-2xl px-4 py-3" style={{ background: "var(--dsw-hover)" }}>
206
+ <div style={{ fontSize: 12.5, color: "var(--dsw-label-secondary)", lineHeight: 1.6 }}>{t.importDesc}</div>
207
+ <div style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }} title={t.importLimitHintTooltip} data-testid="import-limit-hint">
208
+ {t.importLimitHint.replace("{n}", String(SCAN_LIMIT))}
209
+ </div>
207
210
  </div>
208
211
 
209
- {/* 工具条:扫描 / 选择 / 兜底工作区 / 导入 */}
212
+ {/* 操作行:扫描 / 清空 / 导入。去向是"设置",单独一行读起来更清楚 */}
210
213
  <div className="flex flex-wrap items-center gap-2">
211
214
  <button
212
- className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5"
213
- style={{ fontSize: 12, border: "0.5px solid var(--dsw-border-l2)" }}
215
+ className="btn-outline flex flex-none items-center gap-1.5 whitespace-nowrap"
216
+ style={{ height: 30, padding: "0 12px", fontSize: 12.5 }}
214
217
  onClick={() => void scan()}
215
218
  disabled={scanning}
216
219
  data-testid="import-rescan"
217
220
  >
218
221
  <IconRefreshOutline14 size={13} /> {scanning ? t.importScanning : t.importRescan}
219
222
  </button>
220
- <span style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>{t.importFallbackWorkspace}</span>
221
- <select
222
- className="rounded-lg px-2 py-1"
223
- style={{ fontSize: 12, border: "0.5px solid var(--dsw-border-l2)", background: "transparent", maxWidth: 260 }}
224
- value={fallback}
225
- onChange={(event) => setFallback(event.target.value)}
226
- aria-label={t.importFallbackWorkspace}
227
- data-testid="import-fallback"
228
- >
229
- {(workspaces.includes(fallback) || !fallback ? workspaces : [fallback, ...workspaces]).map((workspace) => (
230
- <option key={workspace} value={workspace}>
231
- {workspace}
232
- </option>
233
- ))}
234
- </select>
235
223
  <div className="flex-1" />
236
224
  <button
237
- className="rounded-lg px-2.5 py-1.5"
238
- style={{ fontSize: 12, color: "var(--dsw-label-tertiary)" }}
225
+ className="whitespace-nowrap rounded-lg px-2.5 py-1.5"
226
+ style={{ fontSize: 12, color: selected.size ? "var(--dsw-label-secondary)" : "var(--dsw-label-tertiary)" }}
239
227
  onClick={() => setSelected(new Set())}
240
228
  disabled={!selected.size}
241
229
  >
242
230
  {t.importClearSelection}
243
231
  </button>
244
232
  <button
245
- className="flex items-center gap-1.5 rounded-lg px-3 py-1.5"
246
- style={{ fontSize: 12, background: selected.size && !importing ? "var(--dsw-accent)" : "var(--dsw-hover)", color: selected.size && !importing ? "#fff" : "var(--dsw-label-caption)" }}
233
+ className="flex flex-none items-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5"
234
+ style={{ fontSize: 12.5, background: selected.size && !importing ? "var(--dsw-accent)" : "var(--dsw-hover)", color: selected.size && !importing ? "#fff" : "var(--dsw-label-caption)" }}
247
235
  onClick={() => void run()}
248
236
  disabled={!selected.size || importing}
249
237
  data-testid="import-run"
@@ -251,6 +239,24 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
251
239
  <IconDownloadOutline16 size={13} /> {importing ? t.importRunning : t.importRun.replace("{n}", String(selected.size))}
252
240
  </button>
253
241
  </div>
242
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
243
+ <span className="flex-none" style={{ fontSize: 12, color: "var(--dsw-label-caption)" }}>{t.importFallbackWorkspace}</span>
244
+ <select
245
+ className="min-w-0 rounded-lg px-2 py-1"
246
+ style={{ fontSize: 12, border: "0.5px solid var(--dsw-border-l2)", background: "transparent", maxWidth: 280 }}
247
+ value={fallback}
248
+ onChange={(event) => setFallback(event.target.value)}
249
+ aria-label={t.importFallbackWorkspace}
250
+ data-testid="import-fallback"
251
+ >
252
+ {(workspaces.includes(fallback) || !fallback ? workspaces : [fallback, ...workspaces]).map((workspace) => (
253
+ <option key={workspace} value={workspace}>
254
+ {workspace}
255
+ </option>
256
+ ))}
257
+ </select>
258
+ <span className="min-w-0" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>{t.importFallbackHint}</span>
259
+ </div>
254
260
 
255
261
  {failure && (
256
262
  <div role="alert" className="rounded-lg px-3 py-2" style={{ fontSize: 12.5, background: "var(--dsw-hover)", color: "var(--dsw-danger)" }}>
@@ -335,30 +341,46 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
335
341
  const rowKey = key(row);
336
342
  const done = importedKeys.has(rowKey);
337
343
  const checked = selected.has(rowKey);
344
+ // 去向:源项目目录还在本机就回原工作区(服务端判定),否则用兜底工作区。
345
+ // 这里只按"有没有 projectPath"给出预期,不假装知道目录是否真的存在。
346
+ const target = row.projectPath || fallback;
347
+ const goesOriginal = Boolean(row.projectPath);
338
348
  return (
339
349
  <label
340
350
  key={rowKey}
341
- className="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2"
342
- style={{ border: `0.5px solid ${checked ? "var(--dsw-accent)" : "var(--dsw-border-l2)"}`, opacity: done ? 0.55 : 1 }}
351
+ className="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 transition-colors"
352
+ style={{
353
+ border: `0.5px solid ${checked ? "var(--dsw-accent)" : "var(--dsw-border-l2)"}`,
354
+ background: checked ? "var(--dsw-accent-soft)" : "transparent",
355
+ opacity: done ? 0.5 : 1,
356
+ }}
343
357
  >
344
- <input type="checkbox" checked={checked} disabled={done} onChange={() => toggle(row)} />
345
- <div className="min-w-0 flex-1">
346
- <div className="truncate" style={{ fontSize: 12.5 }} title={row.title}>
358
+ <input type="checkbox" checked={checked} disabled={done} onChange={() => toggle(row)} className="flex-none" />
359
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
360
+ <div className="truncate" style={{ fontSize: 12.5, color: done ? "var(--dsw-label-tertiary)" : "var(--dsw-label-primary)" }} title={row.title}>
347
361
  {row.title || t.importUntitled}
348
362
  </div>
349
- <div className="truncate" style={{ fontSize: 11, color: "var(--dsw-label-caption)" }} title={row.projectPath}>
350
- {row.projectPath || t.importNoProject}
363
+ <div className="flex min-w-0 items-center gap-1.5" title={row.projectPath}>
364
+ <span
365
+ className="flex-none rounded px-1 py-0.5"
366
+ style={{ fontSize: 10, background: "var(--dsw-selector)", color: "var(--dsw-label-tertiary)" }}
367
+ >
368
+ {goesOriginal ? t.importGoesOriginal : t.importGoesFallback}
369
+ </span>
370
+ <span className="truncate" style={{ fontSize: 11, color: "var(--dsw-label-caption)" }}>
371
+ {target || t.importNoProject}
372
+ </span>
351
373
  </div>
352
374
  </div>
375
+ <span className="flex-none" style={{ fontSize: 11, color: "var(--dsw-label-caption)", whiteSpace: "nowrap" }}>
376
+ {typeof row.messageCount === "number" ? t.importMessages.replace("{n}", String(row.messageCount)) : "—"}
377
+ </span>
378
+ <span className="flex-none" style={{ fontSize: 11, color: "var(--dsw-label-caption)", whiteSpace: "nowrap" }}>{dayOf(row.updatedAt ?? row.createdAt)}</span>
353
379
  {done && (
354
- <span className="rounded-full px-2 py-0.5" style={{ fontSize: 10, background: "var(--dsw-hover)", color: "var(--dsw-label-caption)" }}>
380
+ <span className="flex-none rounded-full px-2 py-0.5" style={{ fontSize: 10, background: "var(--dsw-selector)", color: "var(--dsw-label-caption)" }}>
355
381
  {t.importAlready}
356
382
  </span>
357
383
  )}
358
- <span style={{ fontSize: 11, color: "var(--dsw-label-caption)", whiteSpace: "nowrap" }}>
359
- {typeof row.messageCount === "number" ? t.importMessages.replace("{n}", String(row.messageCount)) : "—"}
360
- </span>
361
- <span style={{ fontSize: 11, color: "var(--dsw-label-caption)", whiteSpace: "nowrap" }}>{dayOf(row.updatedAt ?? row.createdAt)}</span>
362
384
  </label>
363
385
  );
364
386
  })}