@rexvane/piweb 0.3.15 → 0.3.16

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 (230) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +9 -6
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +6 -6
  5. package/.next/react-loadable-manifest.json +16 -4
  6. package/.next/routes-manifest.json +20 -0
  7. package/.next/server/app/_global-error/page.js +2 -2
  8. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_global-error.html +1 -1
  10. package/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page.js +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +1 -1
  17. package/.next/server/app/_not-found.rsc +7 -4
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +7 -4
  19. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +5 -2
  20. package/.next/server/app/_not-found.segments/_tree.segment.rsc +5 -2
  21. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  22. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  23. package/.next/server/app/api/agent/[id]/route.js +1 -1
  24. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/new/route.js +1 -1
  28. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  29. package/.next/server/app/api/config/route.js +1 -1
  30. package/.next/server/app/api/config/route.js.nft.json +1 -1
  31. package/.next/server/app/api/files/route.js +1 -1
  32. package/.next/server/app/api/files/route.js.nft.json +1 -1
  33. package/.next/server/app/api/git/route.js +2 -2
  34. package/.next/server/app/api/git/route.js.nft.json +1 -1
  35. package/.next/server/app/api/growth/route.js +1 -1
  36. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  37. package/.next/server/app/api/health/route.js +1 -1
  38. package/.next/server/app/api/models/route.js +1 -1
  39. package/.next/server/app/api/models/route.js.nft.json +1 -1
  40. package/.next/server/app/api/pi-settings/route.js +1 -1
  41. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  42. package/.next/server/app/api/plugins/route.js +1 -1
  43. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  44. package/.next/server/app/api/prompts/route.js +1 -1
  45. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  46. package/.next/server/app/api/security/route.js +1 -1
  47. package/.next/server/app/api/security/route.js.nft.json +1 -1
  48. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  49. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  50. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  51. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  52. package/.next/server/app/api/sessions/import/route.js +3 -3
  53. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  54. package/.next/server/app/api/sessions/route.js +1 -1
  55. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  56. package/.next/server/app/api/skills/route.js +2 -2
  57. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  58. package/.next/server/app/api/swarm/[id]/route.js +1 -0
  59. package/.next/server/app/api/swarm/[id]/route.js.nft.json +1 -0
  60. package/.next/server/app/api/swarm/[id]/route_client-reference-manifest.js +1 -0
  61. package/.next/server/app/api/swarm/route.js +1 -0
  62. package/.next/server/app/api/swarm/route.js.nft.json +1 -0
  63. package/.next/server/app/api/swarm/route_client-reference-manifest.js +1 -0
  64. package/.next/server/app/api/tests/route.js +2 -0
  65. package/.next/server/app/api/tests/route.js.nft.json +1 -0
  66. package/.next/server/app/api/tests/route_client-reference-manifest.js +1 -0
  67. package/.next/server/app/api/update/route.js +2 -2
  68. package/.next/server/app/api/update/route.js.nft.json +1 -1
  69. package/.next/server/app/api/version/route.js +1 -1
  70. package/.next/server/app/api/version/route.js.nft.json +1 -1
  71. package/.next/server/app/api/web-auth/route.js +1 -1
  72. package/.next/server/app/api/workspaces/route.js +1 -1
  73. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  74. package/.next/server/app/icon.svg/route.js +1 -1
  75. package/.next/server/app/index.html +1 -1
  76. package/.next/server/app/index.rsc +9 -6
  77. package/.next/server/app/index.segments/__PAGE__.segment.rsc +7 -4
  78. package/.next/server/app/index.segments/_full.segment.rsc +9 -6
  79. package/.next/server/app/index.segments/_tree.segment.rsc +5 -2
  80. package/.next/server/app/login/page.js +1 -1
  81. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  82. package/.next/server/app/login.html +1 -1
  83. package/.next/server/app/login.rsc +8 -5
  84. package/.next/server/app/login.segments/_full.segment.rsc +8 -5
  85. package/.next/server/app/login.segments/_tree.segment.rsc +5 -2
  86. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +6 -3
  87. package/.next/server/app/page.js +1 -1
  88. package/.next/server/app/page_client-reference-manifest.js +1 -1
  89. package/.next/server/app-paths-manifest.json +9 -6
  90. package/.next/server/chunks/496.js +1 -1
  91. package/.next/server/chunks/532.js +1 -1
  92. package/.next/server/chunks/686.js +6 -2
  93. package/.next/server/chunks/757.js +1 -0
  94. package/.next/server/chunks/775.js +3 -0
  95. package/.next/server/middleware-build-manifest.js +1 -1
  96. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  97. package/.next/server/pages/404.html +1 -1
  98. package/.next/server/pages/500.html +1 -1
  99. package/.next/server/server-reference-manifest.json +1 -1
  100. package/.next/static/0t1jBUAuVaTFTXxRyIo3q/_buildManifest.js +1 -0
  101. package/.next/static/chunks/2608.43c58236c9cce8e6.js +1 -0
  102. package/.next/static/chunks/{2716.a9e808190a4e2424.js → 2716.4ec42df1d51354c9.js} +1 -1
  103. package/.next/static/chunks/4632.62139b565b657aac.js +1 -0
  104. package/.next/static/chunks/{4673.d3d0a7f2599080fb.js → 4673.7796104395deb8d7.js} +1 -1
  105. package/.next/static/chunks/4686-17f21a006739a596.js +1 -0
  106. package/.next/static/chunks/7577.10af9bb661652da8.js +1 -0
  107. package/.next/static/chunks/8866.38ec4ac4ee5a1b74.js +1 -0
  108. package/.next/static/chunks/app/_global-error/page-5148173b3f55e415.js +1 -0
  109. package/.next/static/chunks/app/api/agent/[id]/events/route-5148173b3f55e415.js +1 -0
  110. package/.next/static/chunks/app/api/agent/[id]/route-5148173b3f55e415.js +1 -0
  111. package/.next/static/chunks/app/api/agent/[id]/tree/route-5148173b3f55e415.js +1 -0
  112. package/.next/static/chunks/app/api/agent/new/route-5148173b3f55e415.js +1 -0
  113. package/.next/static/chunks/app/api/config/route-5148173b3f55e415.js +1 -0
  114. package/.next/static/chunks/app/api/files/route-5148173b3f55e415.js +1 -0
  115. package/.next/static/chunks/app/api/git/route-5148173b3f55e415.js +1 -0
  116. package/.next/static/chunks/app/api/growth/route-5148173b3f55e415.js +1 -0
  117. package/.next/static/chunks/app/api/health/route-5148173b3f55e415.js +1 -0
  118. package/.next/static/chunks/app/api/models/route-5148173b3f55e415.js +1 -0
  119. package/.next/static/chunks/app/api/pi-settings/route-5148173b3f55e415.js +1 -0
  120. package/.next/static/chunks/app/api/plugins/route-5148173b3f55e415.js +1 -0
  121. package/.next/static/chunks/app/api/prompts/route-5148173b3f55e415.js +1 -0
  122. package/.next/static/chunks/app/api/security/route-5148173b3f55e415.js +1 -0
  123. package/.next/static/chunks/app/api/sessions/[id]/export/route-5148173b3f55e415.js +1 -0
  124. package/.next/static/chunks/app/api/sessions/[id]/route-5148173b3f55e415.js +1 -0
  125. package/.next/static/chunks/app/api/sessions/import/route-5148173b3f55e415.js +1 -0
  126. package/.next/static/chunks/app/api/sessions/route-5148173b3f55e415.js +1 -0
  127. package/.next/static/chunks/app/api/skills/route-5148173b3f55e415.js +1 -0
  128. package/.next/static/chunks/app/api/swarm/[id]/route-5148173b3f55e415.js +1 -0
  129. package/.next/static/chunks/app/api/swarm/route-5148173b3f55e415.js +1 -0
  130. package/.next/static/chunks/app/api/tests/route-5148173b3f55e415.js +1 -0
  131. package/.next/static/chunks/app/api/update/route-5148173b3f55e415.js +1 -0
  132. package/.next/static/chunks/app/api/version/route-5148173b3f55e415.js +1 -0
  133. package/.next/static/chunks/app/api/web-auth/route-5148173b3f55e415.js +1 -0
  134. package/.next/static/chunks/app/api/workspaces/route-5148173b3f55e415.js +1 -0
  135. package/.next/static/chunks/app/page-14379b8060d5cf07.js +1 -0
  136. package/.next/static/chunks/next/dist/client/components/builtin/app-error-5148173b3f55e415.js +1 -0
  137. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-5148173b3f55e415.js +1 -0
  138. package/.next/static/chunks/next/dist/client/components/builtin/not-found-5148173b3f55e415.js +1 -0
  139. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-5148173b3f55e415.js +1 -0
  140. package/.next/static/chunks/webpack-d1a69ad9edb38b11.js +1 -0
  141. package/.next/static/css/d0a0162cb481abe8.css +1 -0
  142. package/.next/types/app/api/swarm/[id]/route.ts +353 -0
  143. package/.next/types/app/api/swarm/route.ts +353 -0
  144. package/.next/types/app/api/tests/route.ts +353 -0
  145. package/.next/types/routes.d.ts +4 -1
  146. package/.next/types/validator.ts +27 -0
  147. package/README.md +10 -0
  148. package/package.json +4 -3
  149. package/scripts/pick-folder.ps1 +11 -2
  150. package/scripts/release.mjs +11 -31
  151. package/scripts/run-test-file.mjs +34 -0
  152. package/scripts/verify-release-config.mjs +24 -0
  153. package/src/app/api/config/route.ts +4 -1
  154. package/src/app/api/security/route.ts +8 -3
  155. package/src/app/api/swarm/[id]/route.ts +28 -0
  156. package/src/app/api/swarm/route.ts +24 -0
  157. package/src/app/api/tests/route.ts +27 -0
  158. package/src/app/api/update/route.ts +8 -2
  159. package/src/app/globals.css +777 -0
  160. package/src/app/layout.tsx +13 -6
  161. package/src/components/AppShell.tsx +505 -199
  162. package/src/components/ChatInput.tsx +153 -26
  163. package/src/components/ChatWindow.tsx +46 -40
  164. package/src/components/ContextMeter.tsx +11 -17
  165. package/src/components/FileViewer.tsx +1 -1
  166. package/src/components/GitPanel.tsx +13 -11
  167. package/src/components/ModelSelector.tsx +19 -6
  168. package/src/components/PiMark.tsx +5 -38
  169. package/src/components/ProjectPanel.tsx +1 -0
  170. package/src/components/PromptPanel.tsx +132 -51
  171. package/src/components/SessionImportSection.tsx +17 -17
  172. package/src/components/SessionSidebar.tsx +159 -57
  173. package/src/components/SettingsPanel.tsx +330 -193
  174. package/src/components/SwarmCoordinatorPod.tsx +1021 -0
  175. package/src/components/TestInspectorPod.tsx +549 -0
  176. package/src/components/icons.tsx +23 -0
  177. package/src/hooks/usePiWeb.ts +112 -8
  178. package/src/i18n.tsx +59 -3
  179. package/src/lib/agent-manager.ts +180 -12
  180. package/src/lib/command-validation.ts +9 -1
  181. package/src/lib/delegation.ts +93 -0
  182. package/src/lib/managed-niubash.ts +8 -4
  183. package/src/lib/pi.ts +3 -2
  184. package/src/lib/runtime-activity.ts +27 -0
  185. package/src/lib/session-import/index.ts +5 -0
  186. package/src/lib/session-import/lock.ts +25 -0
  187. package/src/lib/session-import/writer.ts +8 -3
  188. package/src/lib/skills-service.ts +16 -9
  189. package/src/lib/swarm-service.ts +371 -0
  190. package/src/lib/test-run-service.ts +122 -0
  191. package/src/lib/theme.ts +68 -1
  192. package/src/lib/trajectory.ts +1 -0
  193. package/src/lib/types.ts +14 -0
  194. package/src/lib/workflow.ts +55 -0
  195. package/.next/static/DdBwPYoHBKtaJ97QIr6Co/_buildManifest.js +0 -1
  196. package/.next/static/chunks/2608.ec8ff1f5767c9056.js +0 -1
  197. package/.next/static/chunks/4632.0ed92ae42905ce40.js +0 -1
  198. package/.next/static/chunks/4686-7d694def920ff093.js +0 -1
  199. package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +0 -1
  200. package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +0 -1
  201. package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +0 -1
  202. package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +0 -1
  203. package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +0 -1
  204. package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +0 -1
  205. package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +0 -1
  206. package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +0 -1
  207. package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +0 -1
  208. package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +0 -1
  209. package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +0 -1
  210. package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +0 -1
  211. package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +0 -1
  212. package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +0 -1
  213. package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +0 -1
  214. package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +0 -1
  215. package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +0 -1
  216. package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +0 -1
  217. package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +0 -1
  218. package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +0 -1
  219. package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +0 -1
  220. package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +0 -1
  221. package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +0 -1
  222. package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +0 -1
  223. package/.next/static/chunks/app/page-9a5440047c5a415c.js +0 -1
  224. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +0 -1
  225. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +0 -1
  226. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +0 -1
  227. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +0 -1
  228. package/.next/static/chunks/webpack-42c5aae79b0e5d8c.js +0 -1
  229. package/.next/static/css/b2d35abc828d4713.css +0 -1
  230. /package/.next/static/{DdBwPYoHBKtaJ97QIr6Co → 0t1jBUAuVaTFTXxRyIo3q}/_ssgManifest.js +0 -0
@@ -8,16 +8,22 @@
8
8
  */
9
9
  import { useEffect, useRef, useState } from "react";
10
10
  import {
11
+ IconCheckOutline14,
12
+ IconChevronRight14,
13
+ IconCloseOutline14,
11
14
  IconFileOutline16,
12
15
  IconSendArrowUp14,
13
16
  IconStopFill16,
14
17
  IconTerminalOutline14,
18
+ IconWorkflowAgent16,
19
+ IconWorkflowGoal16,
20
+ IconWorkflowPlan16,
15
21
  } from "@/components/icons";
16
22
  import { ContextMeter } from "@/components/ContextMeter";
17
23
  import { ModelSelector, type ModelChoice } from "@/components/ModelSelector";
18
24
  import { useI18n } from "@/i18n";
19
25
  import { otherBehavior, useEnterBehavior } from "@/lib/enter-behavior";
20
- import type { ImageAttachment } from "@/lib/types";
26
+ import type { ImageAttachment, WorkflowMode, WorkflowState } from "@/lib/types";
21
27
 
22
28
  export interface SlashCommand {
23
29
  name: string;
@@ -56,6 +62,9 @@ export function ChatInput({
56
62
  thinkingLevel,
57
63
  thinkingLevels,
58
64
  models,
65
+ modelLoading,
66
+ modelLoadError,
67
+ onRetryModels,
59
68
  providerNames,
60
69
  authByProvider,
61
70
  queue,
@@ -68,6 +77,9 @@ export function ChatInput({
68
77
  onSelectModel,
69
78
  onSelectLevel,
70
79
  onClearQueue,
80
+ workflow,
81
+ onWorkflowModeChange,
82
+ onApprovePlan,
71
83
  draft,
72
84
  initialDraft,
73
85
  onDraftChange,
@@ -95,6 +107,9 @@ export function ChatInput({
95
107
  thinkingLevel?: string;
96
108
  thinkingLevels: string[];
97
109
  models: ModelChoice[];
110
+ modelLoading?: boolean;
111
+ modelLoadError?: string | null;
112
+ onRetryModels?: () => void;
98
113
  providerNames: Record<string, string>;
99
114
  authByProvider: Record<string, boolean>;
100
115
  queue: { steering: string[]; followUp: string[] };
@@ -109,6 +124,9 @@ export function ChatInput({
109
124
  onSelectLevel: (level: string) => void;
110
125
  /** 清空队列并返回被清掉的文本(取回编辑 / 丢弃都走它) */
111
126
  onClearQueue?: () => Promise<QueueResult | void> | void;
127
+ workflow?: WorkflowState;
128
+ onWorkflowModeChange?: (mode: WorkflowMode) => void | Promise<{ success?: boolean } | void>;
129
+ onApprovePlan?: () => void | Promise<{ success?: boolean; error?: string } | void>;
112
130
  /** Parent-owned, session-scoped draft. Async work always updates this same target. */
113
131
  draft?: ChatDraft;
114
132
  initialDraft?: ChatDraft;
@@ -146,15 +164,70 @@ export function ChatInput({
146
164
  const [dragActive, setDragActive] = useState(false);
147
165
  const dragDepth = useRef(0);
148
166
  const [modelOpen, setModelOpen] = useState(false);
167
+ const [workflowOpen, setWorkflowOpen] = useState(false);
168
+ const [pendingWorkflowMode, setPendingWorkflowMode] = useState<WorkflowMode | null>(null);
169
+ const [approvalPending, setApprovalPending] = useState(false);
170
+ const [approvalError, setApprovalError] = useState("");
171
+ const approvalInFlightRef = useRef(false);
172
+ const workflowRequestRef = useRef(0);
173
+ const workflowInFlightRef = useRef(false);
174
+ const queuedWorkflowRef = useRef<{ mode: WorkflowMode; request: number } | null>(null);
149
175
  const [cmdIdx, setCmdIdx] = useState(0);
150
176
  const [cmdDismissed, setCmdDismissed] = useState(false);
151
177
  const taRef = useRef<HTMLTextAreaElement>(null);
152
178
  const wrapRef = useRef<HTMLDivElement>(null);
179
+ const workflowRef = useRef<HTMLDivElement>(null);
153
180
  const { t } = useI18n();
154
181
  const isBlocked = !model?.id;
155
182
  const enterBehavior = useEnterBehavior();
156
183
  const queuedCount = queue.steering.length + queue.followUp.length;
157
184
  const [restoreTick, setRestoreTick] = useState(0);
185
+ const activeWorkflowMode = pendingWorkflowMode ?? workflow?.mode ?? "agent";
186
+
187
+ useEffect(() => {
188
+ if (pendingWorkflowMode !== null && workflow?.mode === pendingWorkflowMode) setPendingWorkflowMode(null);
189
+ }, [workflow?.mode, pendingWorkflowMode]);
190
+ useEffect(() => { setApprovalError(""); }, [workflow?.mode, workflow?.planStatus, workflow?.planId]);
191
+
192
+ const approvePlan = async () => {
193
+ if (!onApprovePlan || approvalInFlightRef.current || isStreaming || disabled) return;
194
+ approvalInFlightRef.current = true;
195
+ setApprovalPending(true);
196
+ setApprovalError("");
197
+ try {
198
+ const result = await onApprovePlan();
199
+ if (result && result.success === false && mountedRef.current) setApprovalError(result.error || t.planApprovalFailed);
200
+ } catch (error) {
201
+ if (mountedRef.current) setApprovalError(error instanceof Error ? error.message : t.planApprovalFailed);
202
+ } finally {
203
+ approvalInFlightRef.current = false;
204
+ if (mountedRef.current) setApprovalPending(false);
205
+ }
206
+ };
207
+
208
+ const flushWorkflowMode = async () => {
209
+ if (workflowInFlightRef.current || !queuedWorkflowRef.current || !onWorkflowModeChange) return;
210
+ const { mode, request } = queuedWorkflowRef.current;
211
+ queuedWorkflowRef.current = null;
212
+ workflowInFlightRef.current = true;
213
+ try {
214
+ const result = await onWorkflowModeChange(mode);
215
+ if (mountedRef.current && request === workflowRequestRef.current && result && result.success === false) setPendingWorkflowMode(null);
216
+ } catch {
217
+ if (mountedRef.current && request === workflowRequestRef.current) setPendingWorkflowMode(null);
218
+ } finally {
219
+ workflowInFlightRef.current = false;
220
+ if (mountedRef.current && queuedWorkflowRef.current) void flushWorkflowMode();
221
+ }
222
+ };
223
+
224
+ const selectWorkflowMode = (mode: WorkflowMode) => {
225
+ if (!onWorkflowModeChange) return;
226
+ const request = ++workflowRequestRef.current;
227
+ setPendingWorkflowMode(mode);
228
+ queuedWorkflowRef.current = { mode, request };
229
+ void flushWorkflowMode();
230
+ };
158
231
 
159
232
  useEffect(() => {
160
233
  mountedRef.current = true;
@@ -163,6 +236,23 @@ export function ChatInput({
163
236
  useEffect(() => {
164
237
  setAttachmentError(uploadFailure ?? "");
165
238
  }, [uploadFailure]);
239
+ useEffect(() => {
240
+ if (!workflowOpen) return;
241
+ const closeOnPointer = (event: PointerEvent) => {
242
+ if (!workflowRef.current?.contains(event.target as Node)) setWorkflowOpen(false);
243
+ };
244
+ const closeOnEscape = (event: KeyboardEvent) => {
245
+ if (event.key !== "Escape") return;
246
+ setWorkflowOpen(false);
247
+ workflowRef.current?.querySelector<HTMLButtonElement>(".pw-workflow-add")?.focus();
248
+ };
249
+ document.addEventListener("pointerdown", closeOnPointer);
250
+ document.addEventListener("keydown", closeOnEscape);
251
+ return () => {
252
+ document.removeEventListener("pointerdown", closeOnPointer);
253
+ document.removeEventListener("keydown", closeOnEscape);
254
+ };
255
+ }, [workflowOpen]);
166
256
  useEffect(() => {
167
257
  autoSize();
168
258
  }, [text]);
@@ -404,9 +494,35 @@ export function ChatInput({
404
494
  }, []);
405
495
 
406
496
  const hasDraft = text.trim().length > 0 || images.length > 0 || uploads.length > 0;
497
+ const workflowName = activeWorkflowMode === "plan" ? t.workflowPlan : activeWorkflowMode === "goal" ? t.workflowGoal : t.workflowAgent;
498
+ const workflowPlaceholder = activeWorkflowMode === "plan" ? t.workflowPlanPlaceholder : activeWorkflowMode === "goal" ? t.workflowGoalPlaceholder : t.inputPlaceholder;
499
+ const composerExpanded = text.length > 56 || text.includes("\n") || images.length > 0 || uploads.length > 0 || Boolean(adopted);
407
500
 
408
501
  return (
409
- <div ref={wrapRef} className="relative w-full" data-testid="composer">
502
+ <div ref={wrapRef} className="pw-composer-shell relative w-full" data-expanded={composerExpanded || undefined} data-testid="composer">
503
+ {activeWorkflowMode === "plan" && workflow?.mode === "plan" && workflow.planStatus === "ready" && onApprovePlan && (
504
+ <section className="pw-plan-ready" aria-label={t.planReady}>
505
+ <div className="pw-plan-ready-content">
506
+ <span className="pw-plan-ready-icon" aria-hidden="true"><IconWorkflowPlan16 size={16} /></span>
507
+ <div className="pw-plan-ready-copy">
508
+ <strong>{t.planReady}</strong>
509
+ <span>{t.planReadyHint}</span>
510
+ </div>
511
+ </div>
512
+ <div className="pw-plan-ready-actions">
513
+ <button type="button" className="pw-plan-ready-refine" disabled={disabled || approvalPending} onClick={() => taRef.current?.focus()}>{t.planRefine}</button>
514
+ <button type="button" className="pw-plan-ready-approve" disabled={isStreaming || disabled || approvalPending} onClick={() => { void approvePlan(); }}>{approvalPending ? t.planApproving : t.planApprove}<IconChevronRight14 size={14} aria-hidden="true" /></button>
515
+ </div>
516
+ {approvalError && <p className="pw-plan-ready-error" role="alert">{approvalError}</p>}
517
+ </section>
518
+ )}
519
+ {activeWorkflowMode === "goal" && workflow?.mode === "goal" && workflow.goal && (
520
+ <div className="pw-goal-active" title={workflow.goal}>
521
+ <IconWorkflowGoal16 size={14} aria-hidden="true" />
522
+ <span className="pw-goal-active-label">{t.goalActive}</span>
523
+ <span className="pw-goal-active-text">{workflow.goal}</span>
524
+ </div>
525
+ )}
410
526
  {/* 排队中的消息(Claude Code 同款:列在输入卡上方;↑ 取回编辑,或直接丢弃) */}
411
527
  {queuedCount > 0 && (
412
528
  <div className="pw-queue">
@@ -518,16 +634,8 @@ export function ChatInput({
518
634
  )}
519
635
 
520
636
  {/* 卡片(玻璃态)—— 拖放由 document 级监听统一接管 */}
521
- <div
522
- className="relative w-full"
523
- style={{
524
- borderRadius: 22,
525
- background: "var(--dsw-input-major)",
526
- boxShadow: "var(--dsw-elevation-soft)",
527
- border: "0.5px solid var(--dsw-border-l1)",
528
- }}
529
- >
530
- <div className="px-4 pt-3 flex items-start gap-1.5">
637
+ <div className="pw-composer relative w-full" data-mode={activeWorkflowMode}>
638
+ <div className="pw-composer-body flex items-start gap-1.5">
531
639
  {adopted && (
532
640
  <button
533
641
  type="button"
@@ -555,7 +663,7 @@ export function ChatInput({
555
663
  value={text}
556
664
  disabled={disabled || isBlocked || sendBusy}
557
665
  rows={1}
558
- placeholder={adopted ? (adopted.argumentHint ?? t.inputPlaceholder) : !disabled && isBlocked ? t.blockedComposer : t.inputPlaceholder}
666
+ placeholder={adopted ? (adopted.argumentHint ?? workflowPlaceholder) : !disabled && isBlocked ? t.blockedComposer : workflowPlaceholder}
559
667
  suppressHydrationWarning
560
668
  className="min-w-0 flex-1 resize-none"
561
669
  style={{ fontSize: "var(--piweb-chat-font-size, var(--dsh-content-font-size))", lineHeight: 1.55 }}
@@ -631,19 +739,35 @@ export function ChatInput({
631
739
  </div>
632
740
 
633
741
  {/* 按钮行:左侧只保留命令入口;图片通过拖放或粘贴添加。 */}
634
- <div
635
- className="flex items-center gap-2"
636
- style={{
637
- paddingLeft: 8,
638
- paddingRight: 12,
639
- paddingBottom: 8,
640
- paddingTop: 4,
641
- height: 40,
642
- containerType: "inline-size",
643
- }}
644
- >
645
- {/* 命令菜单通过输入 / 触发(dsh 同款),不再提供 + 启动按钮 */}
646
- <div className="flex-1" />
742
+ <div className="pw-composer-toolbar flex items-center gap-2">
743
+ {workflow && onWorkflowModeChange && (
744
+ <div ref={workflowRef} className="relative flex shrink-0 items-center gap-2">
745
+ <button type="button" className="pw-workflow-add" disabled={isStreaming || disabled} aria-label={`${t.workflowMenu}: ${workflowName}`} title={t.workflowMenu} aria-haspopup="menu" aria-expanded={workflowOpen} onClick={() => setWorkflowOpen((open) => !open)}>
746
+ <span className="pw-workflow-plus" aria-hidden="true" />
747
+ </button>
748
+ {activeWorkflowMode !== "agent" && (
749
+ <span className="pw-workflow-current" data-mode={activeWorkflowMode}>
750
+ <span className="pw-workflow-current-icon" aria-hidden="true">{activeWorkflowMode === "plan" ? <IconWorkflowPlan16 size={14} /> : <IconWorkflowGoal16 size={14} />}</span>
751
+ <span>{workflowName}</span>
752
+ <button type="button" className="pw-workflow-current-dismiss" disabled={isStreaming || disabled} aria-label={`${t.workflowClear}: ${workflowName}`} title={t.workflowClear} onClick={() => { selectWorkflowMode("agent"); taRef.current?.focus(); }}><IconCloseOutline14 size={10} aria-hidden="true" /></button>
753
+ </span>
754
+ )}
755
+ {workflowOpen && (
756
+ <div className="popover pw-workflow-menu" role="menu">
757
+ <div className="pw-workflow-menu-label">{t.workflowMenu}</div>
758
+ {(["agent", "plan", "goal"] as const).map((mode) => (
759
+ <button key={mode} type="button" className="pw-workflow-option" data-mode={mode} role="menuitemradio" aria-checked={activeWorkflowMode === mode} onClick={() => { setWorkflowOpen(false); selectWorkflowMode(mode); taRef.current?.focus(); }}>
760
+ <span className="pw-workflow-option-icon" aria-hidden="true">{mode === "agent" ? <IconWorkflowAgent16 size={16} /> : mode === "plan" ? <IconWorkflowPlan16 size={16} /> : <IconWorkflowGoal16 size={16} />}</span>
761
+ <strong className="pw-workflow-option-name">{mode === "agent" ? t.workflowAgent : mode === "plan" ? t.workflowPlan : t.workflowGoal}</strong>
762
+ <span className="pw-workflow-option-description">{mode === "agent" ? t.workflowAgentHint : mode === "plan" ? t.workflowPlanHint : t.workflowGoalHint}</span>
763
+ <IconCheckOutline14 className="pw-workflow-option-check" size={14} aria-hidden="true" />
764
+ </button>
765
+ ))}
766
+ </div>
767
+ )}
768
+ </div>
769
+ )}
770
+ {!workflow && <div className="flex-1" />}
647
771
 
648
772
  {/* 模型芯片(右组,dsh 分组菜单;/model 命令可受控打开) */}
649
773
  <ModelSelector
@@ -651,6 +775,9 @@ export function ChatInput({
651
775
  thinkingLevel={thinkingLevel}
652
776
  thinkingLevels={thinkingLevels}
653
777
  models={models}
778
+ modelLoading={modelLoading}
779
+ modelLoadError={modelLoadError}
780
+ onRetryModels={onRetryModels}
654
781
  providerNames={providerNames}
655
782
  authByProvider={authByProvider}
656
783
  onSelectModel={onSelectModel}
@@ -22,6 +22,7 @@ import {
22
22
  IconFileOutline16,
23
23
  IconRefreshOutline14,
24
24
  IconSearchOutline16,
25
+ IconSendArrowUp14,
25
26
  IconTerminalOutline14,
26
27
  IconThinkOutline14,
27
28
  IconWarningOutline16,
@@ -78,7 +79,7 @@ function formatMessageClock(time: number, t: Dict): string {
78
79
 
79
80
  const Markdown = memo(function Markdown({ text }: { text: string }) {
80
81
  return (
81
- <div className="md">
82
+ <div className="md select-text">
82
83
  <ReactMarkdown
83
84
  remarkPlugins={[remarkGfm]}
84
85
  rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
@@ -897,72 +898,77 @@ const UserMessage = memo(function UserMessage({ message, onEditMessage }: { mess
897
898
  const images = message.content.filter((content) => content.type === "image");
898
899
  const [editing, setEditing] = useState(false);
899
900
  const [draft, setDraft] = useState(text);
901
+ const [editorWidth, setEditorWidth] = useState(132);
902
+ const editRef = useRef<HTMLTextAreaElement>(null);
903
+ const bubbleRef = useRef<HTMLDivElement>(null);
900
904
  const canEdit = Boolean(onEditMessage && message.id && text);
905
+ useEffect(() => {
906
+ if (!editing || !editRef.current) return;
907
+ editRef.current.style.height = "0px";
908
+ editRef.current.style.height = `${Math.min(320, editRef.current.scrollHeight)}px`;
909
+ }, [draft, editing]);
910
+ const imageStrip = images.length > 0 && (
911
+ <div className="mb-2 flex max-w-[85%] flex-wrap justify-end gap-2">
912
+ {images.map((image, index) => (
913
+ <img key={index} src={`data:${image.mimeType};base64,${image.data}`} alt="" className="max-h-72 max-w-full rounded-2xl object-contain" style={{ border: "0.5px solid var(--dsw-border-l2)" }} />
914
+ ))}
915
+ </div>
916
+ );
917
+ function beginEdit() {
918
+ if (!canEdit) return;
919
+ setEditorWidth(Math.max(132, Math.ceil(bubbleRef.current?.getBoundingClientRect().width ?? 132)));
920
+ setDraft(text);
921
+ setEditing(true);
922
+ }
901
923
  if (editing) {
902
924
  return (
903
925
  <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user">
904
- <div className="msg-user-bubble w-full">
926
+ {imageStrip}
927
+ <div className="msg-user-bubble pw-user-editor" style={{ width: editorWidth }}>
905
928
  <textarea
906
- className="w-full resize-none bg-transparent outline-none"
907
- style={{ minHeight: 68, font: "inherit", color: "inherit" }}
929
+ ref={editRef}
930
+ className="pw-user-editor-input"
908
931
  value={draft}
909
932
  aria-label={t.editMessage}
910
933
  autoFocus
911
934
  onChange={(e) => setDraft(e.target.value)}
912
935
  onKeyDown={(e) => {
913
936
  if (e.key === "Escape") { e.preventDefault(); setEditing(false); }
914
- if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
937
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); }
915
938
  }}
916
939
  />
917
- </div>
918
- <div className="mt-1.5 flex items-center gap-2">
919
- <button type="button" className="btn-outline" style={{ height: 26, padding: "0 10px", fontSize: 12 }} onClick={() => setEditing(false)}>
920
- {t.cancel}
921
- </button>
922
- <button
923
- type="button"
924
- className="btn-primary-white"
925
- style={{ height: 26, padding: "0 12px", fontSize: 12 }}
926
- disabled={!draft.trim() || draft.trim() === text.trim()}
927
- onClick={submit}
928
- >
929
- {t.sendEdit}
930
- </button>
940
+ <div className="pw-user-editor-actions">
941
+ <button type="button" className="pw-user-editor-cancel" aria-label={t.cancel} title={t.cancel} onClick={() => setEditing(false)}><IconCloseOutline14 size={14} /></button>
942
+ <button type="button" className="pw-user-editor-send" aria-label={t.sendEdit} title={t.sendEdit} disabled={!draft.trim()} onClick={submit}><IconSendArrowUp14 size={15} /></button>
943
+ </div>
931
944
  </div>
932
945
  </div>
933
946
  );
934
947
  }
935
948
  function submit() {
936
949
  const next = draft.trim();
937
- if (!next || next === text.trim() || !onEditMessage || !message.id) return;
950
+ if (!next || !onEditMessage || !message.id) return;
938
951
  setEditing(false);
939
952
  onEditMessage(message.id, next);
940
953
  }
941
954
  return (
942
955
  // 回合边界:用户消息前留 28px(比回合内 8px 大得多),长对话里一眼找到“这一轮从哪开始”
943
956
  <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user">
944
- {images.length > 0 && (
945
- <div className="mb-2 flex max-w-[85%] flex-wrap justify-end gap-2">
946
- {images.map((image, index) => (
947
- <img
948
- key={index}
949
- src={`data:${image.mimeType};base64,${image.data}`}
950
- alt=""
951
- className="max-h-72 max-w-full rounded-2xl object-contain"
952
- style={{ border: "0.5px solid var(--dsw-border-l2)" }}
953
- />
954
- ))}
955
- </div>
956
- )}
957
+ {imageStrip}
957
958
  {/* 用户消息也走 Markdown:贴进来的代码块/列表不再是一坨纯文本 */}
958
- {text && <div className="msg-user-bubble"><Markdown text={text} /></div>}
959
+ {text && <div ref={bubbleRef} className={`msg-user-bubble clay-bubble-user${canEdit ? " pw-user-message-clickable" : ""}`} onClick={canEdit ? (event) => {
960
+ if ((event.target as HTMLElement).closest("a, button, input, textarea, [role='button']")) return;
961
+ const selection = window.getSelection();
962
+ if (selection && !selection.isCollapsed) return;
963
+ beginEdit();
964
+ } : undefined}><Markdown text={text} /></div>}
959
965
  {/* 用户消息:复制 + 原地编辑重发(编辑后模型从这条消息重新回答);时钟在图标左侧(dsh clock=start) */}
960
966
  {text && (
961
967
  <MessageActions
962
968
  text={text}
963
969
  clockStart={message.timestamp !== undefined}
964
970
  time={message.timestamp}
965
- onEdit={canEdit ? () => { setDraft(text); setEditing(true); } : undefined}
971
+ onEdit={canEdit ? beginEdit : undefined}
966
972
  />
967
973
  )}
968
974
  </div>
@@ -1084,7 +1090,7 @@ function TurnBlockView({
1084
1090
  const showProcess = items.length > 0 || Boolean(contextFiles?.length) || turnLive || (isLast && Boolean(retryNotice));
1085
1091
 
1086
1092
  return (
1087
- <>
1093
+ <div className="pw-conversation-turn">
1088
1094
  {user && <UserMessage message={user} onEditMessage={onEditMessage} />}
1089
1095
  {showProcess && (
1090
1096
  <div className="pw-turn">
@@ -1115,7 +1121,7 @@ function TurnBlockView({
1115
1121
  </div>
1116
1122
  )}
1117
1123
  {finalIdx >= 0 && <FinalAnswer message={messages[finalIdx]} onFork={onFork} showActions={!isStreaming || !isLast} turnStartMs={user?.timestamp} />}
1118
- </>
1124
+ </div>
1119
1125
  );
1120
1126
  }
1121
1127
 
@@ -1315,10 +1321,10 @@ export function ChatWindow({
1315
1321
  };
1316
1322
 
1317
1323
  return (
1318
- <div className="relative flex h-full min-h-0 flex-col">
1324
+ <div className="pw-chat-window relative flex h-full min-h-0 flex-col">
1319
1325
  <div
1320
1326
  ref={scrollRef}
1321
- className="min-h-0 flex-1 overflow-y-auto"
1327
+ className="pw-chat-scroll min-h-0 flex-1 overflow-y-auto"
1322
1328
  role="log"
1323
1329
  aria-live="polite"
1324
1330
  aria-relevant="additions"
@@ -1330,7 +1336,7 @@ export function ChatWindow({
1330
1336
  if (atBottom) setUnseen(0);
1331
1337
  }}
1332
1338
  >
1333
- <div className="mx-auto flex w-full flex-col px-4 py-6" style={{ maxWidth: "var(--dsh-chat-content-width)" }}>
1339
+ <div className="pw-chat-flow mx-auto flex w-full flex-col px-4 py-6" style={{ maxWidth: "var(--dsh-chat-content-width)" }}>
1334
1340
  {turns.map((turn, ti) => (
1335
1341
  <TurnBlock
1336
1342
  key={turn.key}
@@ -2,9 +2,8 @@
2
2
 
3
3
  /**
4
4
  * 上下文占用:
5
- * 圆环常驻按钮 + 弹窗显示「百分比 / ~已用 / 窗口」与 13 类分段占用条
6
- * (mac 磁盘存储条式的多色分段,含 Free Space 段)。
7
- * 分段口径:全部为字符/4 启发式估算(与 SDK estimateTokens 一致),
5
+ * 圆环常驻按钮 + 弹窗显示 Pi 的上下文用量与内容构成估算。
6
+ * 总量由 SDK 提供;分类是字符/4 启发式估算,不能与总量相加。
8
7
  * 服务端分项(System Prompt / Tools / Memory / Skills / Compacted / Buffer)来自快照,
9
8
  * 消息分项(User / Agent Text / Thinking / Tool Call / Tool Output)弹窗打开时前端统计。
10
9
  * 无对话时不渲染(由父组件经 contextVisible 控制)。
@@ -18,7 +17,7 @@ function fmtTok(n: number): string {
18
17
  return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(Math.round(n));
19
18
  }
20
19
 
21
- /** 13 类分段的定义(顺序即渲染顺序) */
20
+ /** 估算分类的定义(顺序即渲染顺序) */
22
21
  interface Segment {
23
22
  key: string;
24
23
  label: string;
@@ -91,7 +90,6 @@ export function ContextMeter({
91
90
  { key: "agentToolCall", label: t.contextAgentToolCall, tokens: messageSegments.agentToolCall, color: "#D3A15F" },
92
91
  { key: "toolOutput", label: t.contextToolOutput, tokens: messageSegments.toolOutput, color: "var(--dsw-warn)" },
93
92
  { key: "compacted", label: t.contextCompacted, tokens: b.compacted, color: "#A05A78" },
94
- { key: "buffer", label: t.contextAutoCompactBuffer, tokens: b.autoCompactBuffer, color: "var(--dsw-border-l3)" },
95
93
  ]
96
94
  : // 服务端分项缺失(旧快照):退回旧三段口径
97
95
  [
@@ -100,11 +98,9 @@ export function ContextMeter({
100
98
  { key: "tools", label: t.contextTools, tokens: Math.max(0, used - Math.max(0, Math.round((source?.systemChars ?? 0) / 4)) - messageSegments.user - messageSegments.agentText - messageSegments.agentThinking), color: "var(--dsw-warn)" },
101
99
  ];
102
100
 
103
- // Free Space:窗口余量(窗口未知则不显示);分段条不归一化到已用之和
104
- const usedSum = segments.reduce((sum, s) => sum + s.tokens, 0);
105
- const free = window_ > 0 ? Math.max(0, window_ - usedSum) : 0;
106
- const barTotal = window_ > 0 ? window_ : usedSum || 1;
107
- const bar = (n: number) => `${(n / barTotal) * 100}%`;
101
+ // 容量只能按 SDK 总量计算;分类有遗漏与重叠,不能用于推导剩余空间。
102
+ const free = window_ > 0 && tokens !== null ? Math.max(0, window_ - used) : null;
103
+ const usedWidth = window_ > 0 && tokens !== null ? `${Math.max(0, Math.min(100, (used / window_) * 100))}%` : "0%";
108
104
 
109
105
  return (
110
106
  <div ref={ref} className="relative">
@@ -139,21 +135,19 @@ export function ContextMeter({
139
135
  {tokens === null ? "—" : `~${fmtTok(used)}`} / {window_ > 0 ? fmtTok(window_) : "—"}
140
136
  </div>
141
137
 
142
- {/* 分段占用条(mac 磁盘存储条式多色分段,含 Free Space) */}
143
- {usedSum > 0 && (
138
+ {free !== null && (
144
139
  <div className="mt-3 flex h-2 w-full overflow-hidden rounded-full" style={{ background: "var(--dsw-selector)" }} aria-hidden>
145
- {segments.filter((s) => s.tokens > 0).map((s) => (
146
- <span key={s.key} style={{ width: bar(s.tokens), background: s.color }} />
147
- ))}
148
- {free > 0 && <span style={{ width: bar(free), background: "transparent" }} />}
140
+ <span style={{ width: usedWidth, background: "var(--dsw-accent)" }} />
149
141
  </div>
150
142
  )}
143
+ <div className="mt-3" style={{ color: "var(--dsw-label-caption)", fontSize: 11 }}>{t.contextEstimateNote}</div>
151
144
  <div className="mt-2 flex flex-col gap-1" style={{ color: "var(--dsw-label-tertiary)" }}>
152
145
  {segments.filter((s) => s.tokens > 0).map((s) => (
153
146
  <SegmentRow key={s.key} label={s.label} tokens={s.tokens} color={s.color} />
154
147
  ))}
155
- {window_ > 0 && <SegmentRow label={t.contextFreeSpace} tokens={free} color="var(--dsw-selector)" />}
156
148
  </div>
149
+ {b && b.autoCompactBuffer > 0 && <div className="mt-2 flex justify-between gap-2" style={{ color: "var(--dsw-label-tertiary)" }}><span>{t.contextAutoCompactBuffer}</span><span>{fmtTok(b.autoCompactBuffer)}</span></div>}
150
+ {window_ > 0 && <div className="mt-2 flex justify-between gap-2" style={{ color: "var(--dsw-label-secondary)" }}><span>{t.contextFreeSpace}</span><span>{free === null ? "—" : `~${fmtTok(free)}`}</span></div>}
157
151
  </div>
158
152
  )}
159
153
  </div>
@@ -463,7 +463,7 @@ export function FileViewer({
463
463
  <div className="pw-viewer-toolbar hairline-b">
464
464
  <div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-2" style={{ background: "var(--dsw-hover)", height: 26, maxWidth: 360 }}>
465
465
  <IconSearchOutline16 size={12} style={{ flex: "none", color: "var(--dsw-label-caption)" }} />
466
- <input value={treeFilter} onChange={(e) => setTreeFilter(e.target.value)} placeholder={t.growthFilter} className="min-w-0 flex-1 bg-transparent" style={{ fontSize: 12 }} />
466
+ <input value={treeFilter} onChange={(e) => setTreeFilter(e.target.value)} aria-label={t.growthFilter} placeholder={t.growthFilter} className="min-w-0 flex-1 bg-transparent" style={{ fontSize: 12 }} />
467
467
  </div>
468
468
  <span style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
469
469
  {roundNumber ? t.growthRoundLabel.replace("{n}", String(roundNumber)) : ""}
@@ -181,27 +181,29 @@ export function GitPanel({
181
181
  : t.gitPanel;
182
182
 
183
183
  return (
184
- <div className="flex h-full min-h-0 flex-col p-4">
185
- <div className="mb-3 flex items-center gap-2">
184
+ <div className="flex h-full min-h-0 flex-col p-4 select-none">
185
+ <div className="mb-3 flex items-center gap-2 pb-2 border-b border-slate-200/60 dark:border-slate-800/60">
186
186
  {view ? (
187
- <button className="icon-btn" style={{ width: 22, height: 22 }} title={t.gitBack} onClick={() => changeView(null)}>
187
+ <button className="w-7 h-7 rounded-full clay-btn-soft flex items-center justify-center text-slate-600 hover:text-slate-900 transition-transform active:scale-95" title={t.gitBack} onClick={() => changeView(null)}>
188
188
  <IconChevronLeft14 size={13} />
189
189
  </button>
190
190
  ) : (
191
- <IconGitOutline16 size={15} style={{ flex: "none" }} />
191
+ <div className="w-7 h-7 rounded-full clay-inset flex items-center justify-center text-emerald-600">
192
+ <IconGitOutline16 size={15} />
193
+ </div>
192
194
  )}
193
- <span className="min-w-0 flex-1 truncate" style={{ fontSize: 13, fontWeight: 600 }} title={view?.kind === "file" ? view.path : undefined}>
195
+ <span className="min-w-0 flex-1 truncate font-bold text-sm text-slate-800 dark:text-slate-100" title={view?.kind === "file" ? view.path : undefined}>
194
196
  {headerTitle}
195
197
  </span>
196
198
  {view?.kind === "file" && onOpenFile && info?.root && (
197
- <button className="icon-btn" style={{ width: 22, height: 22 }} title={t.fileOpenEditor} onClick={() => onOpenFile(`${info.root}/${view.path}`)}>
199
+ <button className="w-7 h-7 rounded-full clay-btn-soft flex items-center justify-center text-slate-600 hover:text-slate-900 transition-transform active:scale-95" title={t.fileOpenEditor} onClick={() => onOpenFile(`${info.root}/${view.path}`)}>
198
200
  <IconFileOutline16 size={13} />
199
201
  </button>
200
202
  )}
201
- <button className="icon-btn" style={{ width: 22, height: 22 }} title={t.panelRefresh} onClick={() => (view ? void loadDiff(view) : void load())}>
202
- <IconRefreshOutline14 size={13} />
203
+ <button className="w-7 h-7 rounded-full clay-btn-soft flex items-center justify-center text-slate-600 hover:text-slate-900 transition-transform active:scale-95" title={t.panelRefresh} onClick={() => (view ? void loadDiff(view) : void load())}>
204
+ <IconRefreshOutline14 size={13} className={loading || diffLoading ? "piweb-spin" : undefined} />
203
205
  </button>
204
- <button className="icon-btn" style={{ width: 22, height: 22 }} title={t.close} onClick={onClose}>
206
+ <button className="w-7 h-7 rounded-full clay-btn-soft flex items-center justify-center text-slate-600 hover:text-rose-600 transition-colors" title={t.close} onClick={onClose}>
205
207
  <IconCloseOutline14 size={13} />
206
208
  </button>
207
209
  </div>
@@ -214,6 +216,7 @@ export function GitPanel({
214
216
  )}
215
217
 
216
218
  <div className="min-h-0 flex-1 overflow-y-auto">
219
+ {!cwd && <div className="rounded-xl px-3 py-4 text-sm" style={{ color: "var(--dsw-label-caption)", background: "var(--dsw-hover)" }}>请先在工作台选择工作区,再查看 Git 差异。</div>}
217
220
  {/* ---------- 差异视图 ---------- */}
218
221
  {view && (
219
222
  <div>
@@ -267,8 +270,7 @@ export function GitPanel({
267
270
  {!error && info.files.length > 0 && onAskCommit && (
268
271
  <button
269
272
  type="button"
270
- className="btn-outline self-start"
271
- style={{ height: 28, padding: "0 12px", fontSize: 12 }}
273
+ className="clay-btn clay-btn-mint text-white font-semibold px-3.5 py-1.5 rounded-full text-xs self-start shadow-sm active:scale-95"
272
274
  onClick={onAskCommit}
273
275
  title={t.gitAskCommitPrompt}
274
276
  >
@@ -21,6 +21,9 @@ export function ModelSelector({
21
21
  thinkingLevel,
22
22
  thinkingLevels,
23
23
  models,
24
+ modelLoading = false,
25
+ modelLoadError,
26
+ onRetryModels,
24
27
  providerNames,
25
28
  authByProvider,
26
29
  onSelectModel,
@@ -32,6 +35,9 @@ export function ModelSelector({
32
35
  thinkingLevel?: string;
33
36
  thinkingLevels: string[];
34
37
  models: ModelChoice[];
38
+ modelLoading?: boolean;
39
+ modelLoadError?: string | null;
40
+ onRetryModels?: () => void;
35
41
  providerNames: Record<string, string>;
36
42
  authByProvider: Record<string, boolean>;
37
43
  onSelectModel: (provider: string, id: string) => void;
@@ -102,7 +108,8 @@ export function ModelSelector({
102
108
  });
103
109
  }, [open, pane]);
104
110
 
105
- const hasModel = Boolean(model?.id);
111
+ // Pi uses this sentinel when no usable model can be restored.
112
+ const hasModel = Boolean(model?.id && !(model.provider === "unknown" && model.id === "unknown"));
106
113
  const modelName = hasModel ? (model?.name || model?.id || "") : "";
107
114
  const effortLabel = hasModel && thinkingLevel && thinkingLevel !== "off" ? thinkingLevel : undefined;
108
115
  const triggerTitle = hasModel
@@ -110,10 +117,10 @@ export function ModelSelector({
110
117
  : t.selectModel;
111
118
 
112
119
  return (
113
- <div ref={ref} className="relative">
120
+ <div ref={ref} className="pw-model-selector relative min-w-0">
114
121
  <button
115
122
  type="button"
116
- className="chip"
123
+ className="chip min-w-0 max-w-full"
117
124
  data-open={open}
118
125
  title={triggerTitle}
119
126
  aria-label={triggerTitle}
@@ -124,7 +131,7 @@ export function ModelSelector({
124
131
  setPane("model");
125
132
  }}
126
133
  >
127
- <span className="max-w-[220px] truncate" suppressHydrationWarning>{hasModel ? modelName : t.selectModel}</span>
134
+ <span className="min-w-0 max-w-[220px] truncate" suppressHydrationWarning>{hasModel ? modelName : t.selectModel}</span>
128
135
  {effortLabel && (
129
136
  <span style={{ color: "var(--dsw-label-caption)", flex: "none", fontSize: 13 }}>
130
137
  {effortLabel}
@@ -198,9 +205,15 @@ export function ModelSelector({
198
205
  {pane === "model" && (
199
206
  <div className="flex min-h-0 flex-col">
200
207
  <div ref={listRef} className="max-h-[340px] overflow-y-auto py-1">
201
- {groups.length === 0 && (
208
+ {modelLoadError && (
209
+ <div role="alert" className="px-3 py-2" style={{ fontSize: 12, color: "var(--dsw-danger)" }}>
210
+ <span title={modelLoadError}>{t.modelsLoadFailed}</span>{" "}
211
+ {onRetryModels && <button type="button" className="underline" onClick={onRetryModels}>{t.settingsRetry}</button>}
212
+ </div>
213
+ )}
214
+ {groups.length === 0 && (modelLoading || !modelLoadError) && (
202
215
  <div className="px-3 py-3" style={{ fontSize: 13, color: "var(--dsw-label-caption)" }}>
203
- {t.emptyModels}
216
+ {modelLoading ? t.modelsLoading : t.emptyModels}
204
217
  </div>
205
218
  )}
206
219
  {groups.map((g) => (