@hienlh/ppm 0.18.4 → 0.18.6

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 (88) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/assets/skills/ppm/SKILL.md +1 -1
  3. package/assets/skills/ppm/references/http-api.md +1 -1
  4. package/dist/web/assets/{ai-resource-editor-Bb-NNlp1.js → ai-resource-editor-C59ti0ED.js} +1 -1
  5. package/dist/web/assets/{audio-preview-DG2Tw0cO.js → audio-preview-GGksv6U8.js} +1 -1
  6. package/dist/web/assets/chat-tab-CM5hUNpk.js +15 -0
  7. package/dist/web/assets/code-editor-2yL9lCL1.js +10 -0
  8. package/dist/web/assets/{conflict-editor-B6hVffX3.js → conflict-editor-H1zmUXSQ.js} +1 -1
  9. package/dist/web/assets/{csv-preview-hL4Z9HS5.js → csv-preview-Dzzyx_Gv.js} +1 -1
  10. package/dist/web/assets/{database-viewer-CTK5kMaA.js → database-viewer-CiVA--mM.js} +1 -1
  11. package/dist/web/assets/{diff-viewer-DZxnANY4.js → diff-viewer-69nl3mqO.js} +1 -1
  12. package/dist/web/assets/{docx-preview-CrAc7Qjv.js → docx-preview-CFvopEBf.js} +1 -1
  13. package/dist/web/assets/explorer-body-DyPXovdU.js +2 -0
  14. package/dist/web/assets/{explorer-window-content-NS8Y0v8c.js → explorer-window-content-CXFdvs_i.js} +1 -1
  15. package/dist/web/assets/{extension-webview-D6eywKPN.js → extension-webview-DIoKwGV4.js} +1 -1
  16. package/dist/web/assets/{git-log-panel-DAogZ9QV.js → git-log-panel-Copm8mzy.js} +1 -1
  17. package/dist/web/assets/{glide-data-grid-DxfJRN-r.js → glide-data-grid-D0L8SZVt.js} +4 -4
  18. package/dist/web/assets/group-chat-tab-D9cazEAI.js +1 -0
  19. package/dist/web/assets/{image-preview-Byjx8MoL.js → image-preview-vFeN8o43.js} +1 -1
  20. package/dist/web/assets/{index-CcN0TBm4.js → index-BewcuJw5.js} +24 -24
  21. package/dist/web/assets/index-oxsyfDa0.css +2 -0
  22. package/dist/web/assets/keybindings-store-ruNjhVT3.js +1 -0
  23. package/dist/web/assets/{markdown-renderer-CXlEe3ma.js → markdown-renderer-B46VJEHH.js} +2 -2
  24. package/dist/web/assets/markdown-renderer-CYiMPviS.js +1 -0
  25. package/dist/web/assets/{mobile-explorer-sheet-BuqBlP3o.js → mobile-explorer-sheet-BkMZxqs3.js} +1 -1
  26. package/dist/web/assets/notification-store-B3IK7PXf.js +1 -0
  27. package/dist/web/assets/{pdf-preview-F2DWVB85.js → pdf-preview-DU_NRMno.js} +1 -1
  28. package/dist/web/assets/{postgres-viewer-KZqPYfOB.js → postgres-viewer-C0h3Zfk9.js} +2 -2
  29. package/dist/web/assets/{settings-store-CY-4_U3H.js → settings-store-C9YT1Hir.js} +2 -2
  30. package/dist/web/assets/{settings-tab-Khj-w6Sr.js → settings-tab-Cep8gjxG.js} +1 -1
  31. package/dist/web/assets/{sql-query-editor-Cp0_e2P1.js → sql-query-editor-DJofTC9F.js} +1 -1
  32. package/dist/web/assets/{sqlite-viewer-CIv3sagZ.js → sqlite-viewer-D7bskeyk.js} +1 -1
  33. package/dist/web/assets/{system-monitor-tab-CrssTOf4.js → system-monitor-tab-CxgFsQx9.js} +1 -1
  34. package/dist/web/assets/team-member-sheet-Dn5NGkcp.js +2 -0
  35. package/dist/web/assets/{team-member-window-content-D-hXax3c.js → team-member-window-content-DS9V4trr.js} +1 -1
  36. package/dist/web/assets/{terminal-tab-BIQDG-Ti.js → terminal-tab-CMs8AyFJ.js} +3 -3
  37. package/dist/web/assets/tool-cards-HHRTIlFv.js +8 -0
  38. package/dist/web/assets/{use-monaco-theme-Djp1_fKi.js → use-monaco-theme-BLt-n9r1.js} +1 -1
  39. package/dist/web/assets/use-open-team-member-BwtzTuxf.js +1 -0
  40. package/dist/web/assets/{use-websocket-CCYA9-a2.js → use-websocket-DA3Zwl3Z.js} +1 -1
  41. package/dist/web/assets/video-preview-BZhTYMFX.js +1 -0
  42. package/dist/web/index.html +3 -3
  43. package/dist/web/sw.js +1 -1
  44. package/package.json +1 -1
  45. package/src/web/app.tsx +12 -0
  46. package/src/web/components/chat/chat-tab.tsx +11 -0
  47. package/src/web/components/chat/team-activity-panel.tsx +4 -4
  48. package/src/web/components/chat/team-member-activity-format.ts +38 -0
  49. package/src/web/components/chat/team-member-activity-row.tsx +1 -29
  50. package/src/web/components/chat/team-member-sheet.tsx +47 -0
  51. package/src/web/components/chat/team-working-bar.tsx +62 -0
  52. package/src/web/components/chat/use-open-team-member.ts +41 -0
  53. package/src/web/components/os-explorer/actions/collision-prompt-queue.ts +133 -0
  54. package/src/web/components/os-explorer/actions/explorer-actions-clipboard.ts +29 -3
  55. package/src/web/components/os-explorer/actions/explorer-actions-upload.ts +88 -33
  56. package/src/web/components/os-explorer/actions/use-collision-prompt.ts +56 -0
  57. package/src/web/components/os-explorer/actions/use-explorer-actions.ts +29 -44
  58. package/src/web/components/os-explorer/actions/use-permanent-overwrite-prompt.ts +47 -0
  59. package/src/web/components/os-explorer/dnd/use-drop-transfer.tsx +19 -27
  60. package/src/web/components/os-explorer/explorer-dialogs.tsx +12 -1
  61. package/src/web/components/os-explorer/upload/capture-picked-files.ts +18 -0
  62. package/src/web/components/os-explorer/upload/upload-batch-card.tsx +64 -0
  63. package/src/web/components/os-explorer/upload/upload-batch-progress.ts +34 -0
  64. package/src/web/components/os-explorer/upload/upload-batch-row.tsx +79 -0
  65. package/src/web/components/os-explorer/upload/upload-error-message.ts +19 -0
  66. package/src/web/components/os-explorer/upload/upload-progress-panel.tsx +72 -0
  67. package/src/web/components/os-explorer/upload/upload-store.ts +109 -0
  68. package/src/web/components/os-explorer/upload/use-upload-picker.tsx +7 -5
  69. package/src/web/lib/fs-upload-xhr.ts +13 -2
  70. package/dist/web/assets/chat-tab-CgAv-crh.js +0 -15
  71. package/dist/web/assets/code-editor-bX4jlgMy.js +0 -10
  72. package/dist/web/assets/explorer-body-DykJXCTw.js +0 -2
  73. package/dist/web/assets/group-chat-tab-DkVi6qL2.js +0 -1
  74. package/dist/web/assets/index-Bucbpby-.css +0 -2
  75. package/dist/web/assets/keybindings-store-DJIHqbLM.js +0 -1
  76. package/dist/web/assets/markdown-renderer-pYznJDbh.js +0 -1
  77. package/dist/web/assets/notification-store-Bcl5efY2.js +0 -1
  78. package/dist/web/assets/tool-cards-Dx-Rrpri.js +0 -8
  79. package/dist/web/assets/video-preview-cvjNMjWA.js +0 -1
  80. /package/dist/web/assets/{api-themes-CEyYK7AK.js → api-themes-8WTH6KpV.js} +0 -0
  81. /package/dist/web/assets/{can-open-in-ppm-Cu-XYIgu.js → can-open-in-ppm-DXGOSUQc.js} +0 -0
  82. /package/dist/web/assets/{csv-parser-r-Nk1oHT.js → csv-parser-BRtnJCfv.js} +0 -0
  83. /package/dist/web/assets/{edit-diff-preview-HTZPC9VP.js → edit-diff-preview-BivQwZMD.js} +0 -0
  84. /package/dist/web/assets/{lib-BzxL1ARZ.js → lib-B1clP4ai.js} +0 -0
  85. /package/dist/web/assets/{media-url-C5vA0ha9.js → media-url-CUpN5vI1.js} +0 -0
  86. /package/dist/web/assets/{run-in-terminal-B8TDtaM_.js → run-in-terminal-CGW6o37_.js} +0 -0
  87. /package/dist/web/assets/{send-to-chat-CuBAVC8l.js → send-to-chat-YXHdAyLG.js} +0 -0
  88. /package/dist/web/assets/{vendor-xterm-BeaIjRrv.js → vendor-xterm-C1diXHJl.js} +0 -0
@@ -12,6 +12,8 @@ import { openBugReportPopup } from "@/lib/report-bug";
12
12
  import { getAISettings } from "@/lib/api-settings";
13
13
  import { MessageList } from "./message-list";
14
14
  import { BackgroundCommandBar } from "./background-command-bar";
15
+ import { TeamWorkingBar } from "./team-working-bar";
16
+ import { useTeamActivityFeed } from "@/hooks/use-team-activity-feed";
15
17
  import { MessageInput, type ChatAttachment, type MessagePriority } from "./message-input";
16
18
  import { SlashCommandPicker, type SlashItem } from "./slash-command-picker";
17
19
  import { FilePicker } from "./file-picker";
@@ -131,6 +133,12 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
131
133
  killBackgroundShell,
132
134
  } = useChat(sessionId, providerId, projectName);
133
135
 
136
+ // Teammates keep working long after their spawn card scrolled away — a resume
137
+ // arrives by SendMessage and writes no card at all. Poll the roster whenever this
138
+ // session has a team so the working bar below the conversation stays truthful.
139
+ const primaryTeam = teamActivity?.teamNames?.[0] ?? "";
140
+ const { members: teamMembers } = useTeamActivityFeed(primaryTeam, !!primaryTeam);
141
+
134
142
  // Flush pending message once WS connects (replaces unreliable setTimeout)
135
143
  useEffect(() => {
136
144
  if (isConnected && pendingSendRef.current) {
@@ -627,6 +635,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
627
635
  bashPartialOutput={bashPartialOutput}
628
636
  />
629
637
 
638
+ {/* Teammates still working — pinned here so it is the last thing under the conversation */}
639
+ <TeamWorkingBar teamName={primaryTeam} members={teamMembers} projectName={projectName} />
640
+
630
641
  {/* Bottom toolbar */}
631
642
  <div className="border-t border-border bg-panel shrink-0">
632
643
  {/* Unified toolbar: History, Config, Usage, Bug report, Connection */}
@@ -11,7 +11,7 @@ import { RefreshCw } from "lucide-react";
11
11
  import { cn } from "@/lib/utils";
12
12
  import type { TeamMessageItem } from "@/hooks/use-chat";
13
13
  import { useTeamActivityFeed } from "@/hooks/use-team-activity-feed";
14
- import { useWindowStore } from "@/components/floating-window/window-store";
14
+ import { useOpenTeamMember } from "./use-open-team-member";
15
15
  import { usePrefersCoarsePointer } from "@/components/os-explorer/use-coarse-long-press";
16
16
  import { TeamMemberList } from "./team-member-list";
17
17
  import { TeamMessageList } from "./team-message-list";
@@ -35,7 +35,7 @@ function teamLabel(name: string, sessionId?: string | null): string {
35
35
  export function TeamActivityPanel({ teamNames, messages, sessionId, projectName }: TeamActivityPanelProps) {
36
36
  const [selectedTeam, setSelectedTeam] = useState(teamNames[0] ?? "");
37
37
  const [tab, setTab] = useState<TeamTab>("members");
38
- const openWindow = useWindowStore((s) => s.open);
38
+ const openMember = useOpenTeamMember();
39
39
  // Touch needs the 44px minimum even at desktop width; a mouse does not.
40
40
  const coarse = usePrefersCoarsePointer();
41
41
 
@@ -51,9 +51,9 @@ export function TeamActivityPanel({ teamNames, messages, sessionId, projectName
51
51
 
52
52
  const openMemberSession = useCallback(
53
53
  (memberName: string) => {
54
- openWindow("team-member", { teamName: selectedTeam, memberName, projectName });
54
+ openMember({ teamName: selectedTeam, memberName, projectName });
55
55
  },
56
- [openWindow, selectedTeam, projectName],
56
+ [openMember, selectedTeam, projectName],
57
57
  );
58
58
 
59
59
  /**
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Formatting shared by every surface that shows a teammate's activity — the roster
3
+ * row in the team panel and the working bar pinned under the conversation.
4
+ *
5
+ * Kept apart from the components so both read the same answer to "who is this and
6
+ * what is it doing right now" instead of drifting into two phrasings.
7
+ */
8
+
9
+ import type { TeamMemberActivity } from "@/hooks/use-team-activity-feed";
10
+
11
+ /** Trailing agent-type segment — `ak-engineer:tester` reads as `tester`. */
12
+ export function shortAgentType(agentType?: string): string | undefined {
13
+ if (!agentType) return undefined;
14
+ const tail = agentType.split(":").pop();
15
+ return tail && tail !== "general-purpose" ? tail : agentType;
16
+ }
17
+
18
+ /** `1h 4m` / `12m` / `45s` — compact enough for a dense row. */
19
+ export function formatDuration(fromIso?: string, toIso?: string): string | undefined {
20
+ if (!fromIso) return undefined;
21
+ const from = new Date(fromIso).getTime();
22
+ const to = toIso ? new Date(toIso).getTime() : Date.now();
23
+ if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return undefined;
24
+ const totalSeconds = Math.floor((to - from) / 1000);
25
+ const hours = Math.floor(totalSeconds / 3600);
26
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
27
+ if (hours > 0) return `${hours}h ${minutes}m`;
28
+ if (minutes > 0) return `${minutes}m`;
29
+ return `${totalSeconds}s`;
30
+ }
31
+
32
+ /** Best one-line answer to "what is it doing" from the transcript tail. */
33
+ export function currentStep(member: TeamMemberActivity): string | undefined {
34
+ if (member.lastTool) {
35
+ return member.lastToolArg ? `${member.lastTool}: ${member.lastToolArg}` : member.lastTool;
36
+ }
37
+ return member.lastNarrative ?? member.description;
38
+ }
@@ -9,6 +9,7 @@
9
9
  import { Loader2, ChevronRight } from "lucide-react";
10
10
  import { cn } from "@/lib/utils";
11
11
  import type { TeamMemberActivity } from "@/hooks/use-team-activity-feed";
12
+ import { currentStep, formatDuration, shortAgentType } from "./team-member-activity-format";
12
13
 
13
14
  interface TeamMemberActivityRowProps {
14
15
  member: TeamMemberActivity;
@@ -22,35 +23,6 @@ const STATE_LABEL: Record<string, string> = {
22
23
  "no-transcript": "no session",
23
24
  };
24
25
 
25
- /** Trailing agent-type segment — `ak-engineer:tester` reads as `tester`. */
26
- function shortAgentType(agentType?: string): string | undefined {
27
- if (!agentType) return undefined;
28
- const tail = agentType.split(":").pop();
29
- return tail && tail !== "general-purpose" ? tail : agentType;
30
- }
31
-
32
- /** `1h 4m` / `12m` / `45s` — compact enough for a dense row. */
33
- export function formatDuration(fromIso?: string, toIso?: string): string | undefined {
34
- if (!fromIso) return undefined;
35
- const from = new Date(fromIso).getTime();
36
- const to = toIso ? new Date(toIso).getTime() : Date.now();
37
- if (!Number.isFinite(from) || !Number.isFinite(to) || to < from) return undefined;
38
- const totalSeconds = Math.floor((to - from) / 1000);
39
- const hours = Math.floor(totalSeconds / 3600);
40
- const minutes = Math.floor((totalSeconds % 3600) / 60);
41
- if (hours > 0) return `${hours}h ${minutes}m`;
42
- if (minutes > 0) return `${minutes}m`;
43
- return `${totalSeconds}s`;
44
- }
45
-
46
- /** Best one-line answer to "what is it doing" from the transcript tail. */
47
- function currentStep(member: TeamMemberActivity): string | undefined {
48
- if (member.lastTool) {
49
- return member.lastToolArg ? `${member.lastTool}: ${member.lastToolArg}` : member.lastTool;
50
- }
51
- return member.lastNarrative ?? member.description;
52
- }
53
-
54
26
  export function TeamMemberActivityRow({ member, onOpen }: TeamMemberActivityRowProps) {
55
27
  const working = member.workState === "working";
56
28
  const step = currentStep(member);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Full-screen mobile presentation of a teammate's work session.
3
+ *
4
+ * Hosts the same content component the desktop floating window uses, so the steps,
5
+ * refresh and error states are shared code rather than a parallel mobile build.
6
+ * Self-gated singleton, mounted once at the app root beside the other overlays.
7
+ */
8
+
9
+ import { lazy, Suspense } from "react";
10
+ import { X } from "lucide-react";
11
+ import { BottomSheet } from "@/components/ui/mobile-bottom-sheet";
12
+ import { useTeamMemberSheet } from "./use-open-team-member";
13
+
14
+ const TeamMemberWindowContent = lazy(() => import("./team-member-window-content"));
15
+
16
+ export function TeamMemberSheet() {
17
+ const payload = useTeamMemberSheet((s) => s.payload);
18
+ const close = useTeamMemberSheet((s) => s.close);
19
+ if (!payload) return null;
20
+
21
+ return (
22
+ <BottomSheet open onClose={close} zIndex={45} className="flex h-[var(--sheet-vh)] flex-col p-0">
23
+ <div className="flex items-center gap-2 border-b border-border px-3 py-2 shrink-0">
24
+ <span className="truncate text-sm font-medium text-text-primary">
25
+ Session — {payload.memberName}
26
+ </span>
27
+ <button
28
+ type="button"
29
+ onClick={close}
30
+ className="ml-auto flex size-11 items-center justify-center rounded text-text-secondary hover:text-text-primary"
31
+ aria-label="Close"
32
+ >
33
+ <X className="size-5" />
34
+ </button>
35
+ </div>
36
+ <div className="min-h-0 flex-1 overflow-auto">
37
+ <Suspense fallback={<div className="p-4 text-sm text-text-2">Loading…</div>}>
38
+ {/* No floating window backs this sheet, so the id is only an identity for the content. */}
39
+ <TeamMemberWindowContent
40
+ id={`sheet-${payload.memberName}`}
41
+ payload={payload as unknown as Record<string, unknown>}
42
+ />
43
+ </Suspense>
44
+ </div>
45
+ </BottomSheet>
46
+ );
47
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Teammates working right now, pinned under the conversation.
3
+ *
4
+ * A teammate only appears in the transcript when it is *spawned*; every later task
5
+ * reaches it through SendMessage, which resumes the same agent and writes no new
6
+ * card. So after the first round the conversation goes quiet while members are
7
+ * still working, and the only way to notice was to open the team panel.
8
+ *
9
+ * This is deliberately a live overlay rather than chat events: work state is derived
10
+ * from transcripts on disk within a 90s window, so replaying an old session must show
11
+ * nothing here instead of claiming a long-finished teammate is still running.
12
+ */
13
+
14
+ import { Users } from "lucide-react";
15
+ import type { TeamMemberActivity } from "@/hooks/use-team-activity-feed";
16
+ import { currentStep, formatDuration, shortAgentType } from "./team-member-activity-format";
17
+ import { useOpenTeamMember } from "./use-open-team-member";
18
+
19
+ interface TeamWorkingBarProps {
20
+ teamName: string;
21
+ members: TeamMemberActivity[];
22
+ projectName?: string;
23
+ }
24
+
25
+ export function TeamWorkingBar({ teamName, members, projectName }: TeamWorkingBarProps) {
26
+ const openMember = useOpenTeamMember();
27
+ const working = members.filter((m) => m.workState === "working");
28
+ if (!teamName || working.length === 0) return null;
29
+
30
+ return (
31
+ <div className="shrink-0 border-t border-border bg-surface-elevated/60 px-2 py-1.5 space-y-1">
32
+ {working.map((member) => {
33
+ const step = currentStep(member);
34
+ const elapsed = formatDuration(member.startedAt);
35
+ const agentType = shortAgentType(member.agentType);
36
+ return (
37
+ <button
38
+ key={member.name}
39
+ type="button"
40
+ onClick={() => openMember({ teamName, memberName: member.name, projectName })}
41
+ className="flex w-full items-center gap-2 rounded px-1 py-1.5 min-h-[36px] text-left text-xs hover:bg-surface transition-colors"
42
+ title={`Open ${member.name}'s work session`}
43
+ >
44
+ <span className="relative flex size-2 shrink-0">
45
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-60" />
46
+ <span className="relative inline-flex size-2 rounded-full bg-emerald-500" />
47
+ </span>
48
+ <Users className="size-3.5 shrink-0 text-accent-2" />
49
+ <span className="font-medium text-text-primary shrink-0">{member.name}</span>
50
+ {!!agentType && <span className="text-text-3 shrink-0 hidden sm:inline">{agentType}</span>}
51
+ {!!step && (
52
+ <span className="flex-1 truncate text-text-subtle" title={step}>
53
+ {step}
54
+ </span>
55
+ )}
56
+ {!!elapsed && <span className="ml-auto shrink-0 text-text-3 font-mono">{elapsed}</span>}
57
+ </button>
58
+ );
59
+ })}
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Opening a teammate's work session, on whichever presentation the device has.
3
+ *
4
+ * `WindowLayer` renders nothing below the `md` breakpoint, so `openWindow("team-member")`
5
+ * is a silent no-op on a phone — the tap looked broken. Desktop keeps the floating window;
6
+ * mobile gets the full-screen sheet, both hosting the same content component.
7
+ */
8
+
9
+ import { useCallback } from "react";
10
+ import { create } from "zustand";
11
+ import { useIsMobile } from "@/hooks/use-is-mobile";
12
+ import { useWindowStore } from "@/components/floating-window/window-store";
13
+ import type { TeamMemberWindowPayload } from "./team-member-window-content";
14
+
15
+ interface TeamMemberSheetState {
16
+ payload: TeamMemberWindowPayload | null;
17
+ open: (payload: TeamMemberWindowPayload) => void;
18
+ close: () => void;
19
+ }
20
+
21
+ /** Mobile-only: which teammate the full-screen sheet is showing, if any. */
22
+ export const useTeamMemberSheet = create<TeamMemberSheetState>((set) => ({
23
+ payload: null,
24
+ open: (payload) => set({ payload }),
25
+ close: () => set({ payload: null }),
26
+ }));
27
+
28
+ /** Callback that opens a teammate's session the right way for this viewport. */
29
+ export function useOpenTeamMember(): (payload: TeamMemberWindowPayload) => void {
30
+ const isMobile = useIsMobile();
31
+ const openWindow = useWindowStore((s) => s.open);
32
+ const openSheet = useTeamMemberSheet((s) => s.open);
33
+
34
+ return useCallback(
35
+ (payload: TeamMemberWindowPayload) => {
36
+ if (isMobile) openSheet(payload);
37
+ else openWindow("team-member", payload as unknown as Record<string, unknown>);
38
+ },
39
+ [isMobile, openSheet, openWindow],
40
+ );
41
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Pure serializing queue behind the collision-prompt dialog.
3
+ *
4
+ * Uploads and copy/move transfers both call `ctx.resolve()` — sometimes from several jobs
5
+ * running at once (`uploadEntries`' bounded-concurrency queue). Before this module, the
6
+ * dialog was a single React state slot: the second concurrent `resolve()` call silently
7
+ * replaced whichever request was already showing, and the first job's promise never
8
+ * settled — the queue stalled forever with a stuck "Uploading…" toast.
9
+ *
10
+ * Every request now queues here and only one shows at a time. "Apply to all" (set from the
11
+ * dialog when a choice is made) short-circuits every request still queued *in the same
12
+ * batch* without opening another prompt — see `startBatch`/`endBatch`.
13
+ *
14
+ * Pure and DOM-free on purpose, like `upload-queue.ts`: `use-collision-prompt.ts` is the
15
+ * thin React hook wrapping this for re-render notifications.
16
+ */
17
+
18
+ export interface CollisionRequest {
19
+ name: string;
20
+ destination: string;
21
+ }
22
+
23
+ export type CollisionChoice = "replace" | "keep-both" | "skip";
24
+
25
+ interface QueueItem {
26
+ request: CollisionRequest;
27
+ settle(choice: CollisionChoice): void;
28
+ }
29
+
30
+ export interface CollisionQueueSnapshot {
31
+ request: CollisionRequest;
32
+ /** How many more requests are queued behind this one — the dialog's "…and N more". */
33
+ remaining: number;
34
+ }
35
+
36
+ export interface QueueSnapshot<TRequest> {
37
+ request: TRequest;
38
+ remaining: number;
39
+ }
40
+
41
+ /**
42
+ * Generic one-at-a-time prompt queue: every `request()` call queues behind whatever is
43
+ * already showing, `snapshot()` exposes only the head, and `choose()` answers it and
44
+ * advances. Reused by anything that needs a single dialog serialized across concurrent
45
+ * callers — `CollisionPromptQueue` below layers "apply to all" sticky choices on top of one
46
+ * of these for collisions; the rarer permanent-overwrite confirm (no sticky concept needed)
47
+ * uses one directly via `use-permanent-overwrite-prompt.ts`. Both prompts used to be a bare
48
+ * `useState` slot each, with the same "second concurrent caller overwrites the first" race.
49
+ */
50
+ export class SerialPromptQueue<TRequest, TChoice> {
51
+ private queue: Array<{ request: TRequest; settle(choice: TChoice): void }> = [];
52
+
53
+ /** Called whenever the queue or the visible request changes, so a React wrapper can
54
+ * re-render. Never called synchronously from inside the constructor. */
55
+ constructor(private readonly onChange: () => void) {}
56
+
57
+ request(request: TRequest): Promise<TChoice> {
58
+ return new Promise((resolve) => {
59
+ this.queue.push({ request, settle: resolve });
60
+ this.onChange();
61
+ });
62
+ }
63
+
64
+ snapshot(): QueueSnapshot<TRequest> | null {
65
+ const head = this.queue[0];
66
+ return head ? { request: head.request, remaining: this.queue.length - 1 } : null;
67
+ }
68
+
69
+ /** Answers the head request (if any) with `choice` and advances to the next. */
70
+ choose(choice: TChoice): void {
71
+ const item = this.queue.shift();
72
+ if (!item) return;
73
+ item.settle(choice);
74
+ this.onChange();
75
+ }
76
+
77
+ /** Removes and answers every request currently queued (including the head) with the same
78
+ * `choice` — how "apply to all" resolves the rest of a batch in one step. */
79
+ drain(choice: TChoice): void {
80
+ const rest = this.queue.splice(0);
81
+ if (rest.length === 0) return;
82
+ for (const item of rest) item.settle(choice);
83
+ this.onChange();
84
+ }
85
+ }
86
+
87
+ export class CollisionPromptQueue {
88
+ private readonly core: SerialPromptQueue<CollisionRequest, CollisionChoice>;
89
+ private stickyChoice: CollisionChoice | null = null;
90
+ private activeBatches = 0;
91
+
92
+ constructor(onChange: () => void) {
93
+ this.core = new SerialPromptQueue(onChange);
94
+ }
95
+
96
+ /**
97
+ * Marks the start of one batch of parallel `resolve()` calls that should share a single
98
+ * "apply to all" choice (one drop, one paste, one drag). Batches can overlap (a paste
99
+ * started while a drag is still resolving); "apply to all" only resets once every
100
+ * overlapping batch has called `endBatch`.
101
+ */
102
+ startBatch(): void {
103
+ this.activeBatches++;
104
+ }
105
+
106
+ endBatch(): void {
107
+ this.activeBatches = Math.max(0, this.activeBatches - 1);
108
+ if (this.activeBatches === 0) this.stickyChoice = null;
109
+ }
110
+
111
+ /** Raises one collision. Resolves immediately (no dialog) if a prior "apply to all" choice
112
+ * is still sticky for the current batch; otherwise queues behind whatever is showing. */
113
+ request(request: CollisionRequest): Promise<CollisionChoice> {
114
+ if (this.stickyChoice) return Promise.resolve(this.stickyChoice);
115
+ return this.core.request(request);
116
+ }
117
+
118
+ /** The request currently on screen, or `null` when nothing is queued. */
119
+ snapshot(): CollisionQueueSnapshot | null {
120
+ return this.core.snapshot();
121
+ }
122
+
123
+ /** Answers the request on screen. When `applyToAll` is set, the choice also settles every
124
+ * request still queued right now, and sticks for any that arrive before the batch ends. */
125
+ choose(choice: CollisionChoice, applyToAll: boolean): void {
126
+ if (applyToAll) {
127
+ this.stickyChoice = choice;
128
+ this.core.drain(choice);
129
+ } else {
130
+ this.core.choose(choice);
131
+ }
132
+ }
133
+ }
@@ -33,6 +33,13 @@ export interface TransferContext {
33
33
  * delete would contradict what the user just agreed to. Returning false skips the item.
34
34
  */
35
35
  confirmPermanentOverwrite(name: string): Promise<boolean>;
36
+ /**
37
+ * Brackets one batch of parallel `resolve()` calls sharing this context's collision prompt
38
+ * (one paste, one drag, one upload), so "Apply to all" scopes to just that batch. Optional —
39
+ * a caller with no concurrent collisions (or a test building a bare context) can omit them.
40
+ */
41
+ startBatch?(): void;
42
+ endBatch?(): void;
36
43
  }
37
44
 
38
45
  export interface TransferResult {
@@ -56,6 +63,28 @@ export async function transfer(
56
63
  const result: TransferResult = { succeeded: 0, skipped: 0, failed: 0 };
57
64
  const touched = new Set<string>([dstDir]);
58
65
 
66
+ // `transfer()` itself resolves collisions one source at a time, but two *calls* to it can
67
+ // overlap (a paste fired while a drag-drop transfer is still resolving) sharing the same
68
+ // dialog — bracket this call so "Apply to all" only ever covers its own sources.
69
+ ctx.startBatch?.();
70
+ try {
71
+ await transferSources(sources, dstDir, op, ctx, result, touched);
72
+ } finally {
73
+ ctx.endBatch?.();
74
+ }
75
+
76
+ fsChanged(...touched);
77
+ return result;
78
+ }
79
+
80
+ async function transferSources(
81
+ sources: string[],
82
+ dstDir: string,
83
+ op: "copy" | "move",
84
+ ctx: TransferContext,
85
+ result: TransferResult,
86
+ touched: Set<string>,
87
+ ): Promise<void> {
59
88
  for (const source of sources) {
60
89
  const name = source.split(/[/\\]/).filter(Boolean).pop() ?? source;
61
90
  const sourceDir = dirnameOf(source, ctx.sep);
@@ -155,9 +184,6 @@ export async function transfer(
155
184
  toast.error(`${name} was removed but the replacement could not be written`, { description: errorDescription(e) });
156
185
  }
157
186
  }
158
-
159
- fsChanged(...touched);
160
- return result;
161
187
  }
162
188
 
163
189
  /** Put absolute paths on the shared clipboard. `origin` lets the tree keep its own routes. */
@@ -2,16 +2,23 @@
2
2
  * Turns a dropped/picked file batch into `PUT /api/fs/upload` calls, streamed through
3
3
  * `uploadFileXhr` with bounded concurrency. Mirrors `transfer()` in
4
4
  * `explorer-actions-clipboard.ts`: the same collision prompt (`ctx.resolve`), the same
5
- * trash-first "Replace" via the 409 `EEXIST` contract, and a `fsChanged` refresh once the
6
- * batch settles — drag-to-upload and the picker both funnel through this one function.
5
+ * trash-first "Replace" (delete via `/api/fs/delete`, then upload) and a `fsChanged` refresh
6
+ * once the batch settles — drag-to-upload and the picker both funnel through this one
7
+ * function.
8
+ *
9
+ * Per-file state lives in `upload-store.ts`, which backs the persistent progress panel that
10
+ * replaced the old "Uploading N files… NN%" toast — that toast had no room for a cancel
11
+ * button or per-file outcomes, and its single collision-dialog slot is the bug this module
12
+ * used to have (see `use-collision-prompt.ts`).
7
13
  */
8
14
 
9
- import { toast } from "sonner";
10
- import { FsError } from "@/lib/fs-api";
15
+ import { fsApi, FsError } from "@/lib/fs-api";
11
16
  import { uploadFileXhr } from "@/lib/fs-upload-xhr";
12
17
  import { dirnameOf, joinPath } from "../format-file-meta";
13
18
  import { fsChanged } from "../explorer-store";
14
19
  import type { DroppedEntry } from "../upload/collect-dropped-entries";
20
+ import { describeUploadError } from "../upload/upload-error-message";
21
+ import { useUploadStore } from "../upload/upload-store";
15
22
  import { runUploadQueue, type UploadJob } from "../upload/upload-queue";
16
23
  import type { TransferContext } from "./explorer-actions-clipboard";
17
24
  import { freeName } from "./explorer-actions-transfer-helpers";
@@ -35,34 +42,42 @@ async function uploadResolvingCollisions(
35
42
  dstDir: string,
36
43
  sep: string,
37
44
  ctx: TransferContext,
45
+ signal: AbortSignal,
38
46
  onProgress: (bytesLoaded: number) => void,
39
- ): Promise<void> {
47
+ ): Promise<"done" | "skipped"> {
40
48
  const destination = destinationFor(dstDir, entry.relativePath, sep);
41
49
  try {
42
- await uploadFileXhr(destination, entry.file, false, (p) => onProgress(p.loaded));
43
- return;
50
+ await uploadFileXhr(destination, entry.file, false, (p) => onProgress(p.loaded), signal);
51
+ return "done";
44
52
  } catch (e) {
45
53
  if (!(e instanceof FsError) || e.code !== "EEXIST") throw e;
46
54
  }
47
55
 
48
56
  const name = nameOf(entry.relativePath);
49
57
  const choice = await ctx.resolve({ name, destination });
50
- if (choice === "skip") return;
58
+ if (choice === "skip") return "skipped";
51
59
  if (choice === "keep-both") {
52
60
  const containingDir = dirnameOf(destination, sep);
53
61
  const alternative = await freeName(containingDir, name, sep);
54
62
  if (!alternative) throw new Error("No free name available");
55
- await uploadFileXhr(joinPath(containingDir, alternative, sep), entry.file, false, (p) => onProgress(p.loaded));
56
- return;
63
+ await uploadFileXhr(joinPath(containingDir, alternative, sep), entry.file, false, (p) => onProgress(p.loaded), signal);
64
+ return "done";
57
65
  }
58
- // Replace: the server upload route itself makes the overwrite atomic (tmp file + rename),
59
- // so unlike the copy/move `transfer()` there is no separate trash-then-write step here.
60
- await uploadFileXhr(destination, entry.file, true, (p) => onProgress(p.loaded));
61
- }
62
66
 
63
- function uploadLabel(entries: DroppedEntry[], fraction: number): string {
64
- const label = entries.length === 1 ? entries[0]!.file.name : `${entries.length} files`;
65
- return `Uploading ${label}… ${Math.round(fraction * 100)}%`;
67
+ // Replace: trash the existing entry first, same as copy/move's `transfer()` the dialog's
68
+ // "moves the existing item to the Trash first" copy has to hold for every caller of it.
69
+ try {
70
+ await fsApi.remove(destination, false);
71
+ } catch (e) {
72
+ if (!(e instanceof FsError) || e.code !== "NO_TRASH") throw e;
73
+ // No trash backend on this host — ask before doing something permanent instead of
74
+ // silently downgrading to it (mirrors `transfer()`'s own NO_TRASH handling).
75
+ const proceed = await ctx.confirmPermanentOverwrite(name);
76
+ if (!proceed) return "skipped";
77
+ await fsApi.remove(destination, true);
78
+ }
79
+ await uploadFileXhr(destination, entry.file, true, (p) => onProgress(p.loaded), signal);
80
+ return "done";
66
81
  }
67
82
 
68
83
  export async function uploadEntries(
@@ -73,24 +88,64 @@ export async function uploadEntries(
73
88
  ): Promise<void> {
74
89
  if (entries.length === 0) return;
75
90
 
76
- const toastId = toast.loading(uploadLabel(entries, 0));
77
- const jobs: UploadJob<void>[] = entries.map((entry, index) => ({
78
- id: String(index),
79
- size: entry.file.size,
80
- run: (onProgress) => uploadResolvingCollisions(entry, dstDir, sep, ctx, onProgress),
81
- }));
91
+ const batchId = crypto.randomUUID();
92
+ const store = useUploadStore.getState();
93
+ store.addBatch(
94
+ batchId,
95
+ dstDir,
96
+ entries.map((entry, index) => ({
97
+ id: String(index),
98
+ name: nameOf(entry.relativePath),
99
+ relativePath: entry.relativePath,
100
+ size: entry.file.size,
101
+ })),
102
+ );
82
103
 
83
- const results = await runUploadQueue(jobs, CONCURRENCY, (progress) => {
84
- const fraction = progress.bytesTotal > 0 ? progress.bytesLoaded / progress.bytesTotal : 0;
85
- toast.loading(uploadLabel(entries, fraction), { id: toastId });
104
+ const controllers = entries.map((_, index) => {
105
+ const controller = new AbortController();
106
+ store.registerCanceller(batchId, String(index), () => controller.abort());
107
+ return controller;
86
108
  });
87
109
 
88
- fsChanged(dstDir);
89
- const succeeded = results.filter((r) => r.ok).length;
90
- const failed = results.length - succeeded;
91
- if (failed === 0) {
92
- toast.success(`Uploaded ${succeeded} item${succeeded === 1 ? "" : "s"}`, { id: toastId });
93
- } else {
94
- toast.error(`Uploaded ${succeeded}, ${failed} failed`, { id: toastId });
110
+ // Serializes this batch's collision prompts through the shared queue (see
111
+ // `use-collision-prompt.ts`) and scopes "Apply to all" to just these entries.
112
+ ctx.startBatch?.();
113
+ try {
114
+ const jobs: UploadJob<void>[] = entries.map((entry, index) => {
115
+ const id = String(index);
116
+ const controller = controllers[index]!;
117
+ return {
118
+ id,
119
+ size: entry.file.size,
120
+ run: async (onProgress) => {
121
+ if (controller.signal.aborted) {
122
+ store.setItemState(batchId, id, "cancelled");
123
+ throw new DOMException("Upload aborted", "AbortError");
124
+ }
125
+ store.setItemState(batchId, id, "uploading");
126
+ try {
127
+ const outcome = await uploadResolvingCollisions(
128
+ entry, dstDir, sep, ctx, controller.signal,
129
+ (loaded) => {
130
+ onProgress(loaded);
131
+ store.setItemProgress(batchId, id, loaded);
132
+ },
133
+ );
134
+ store.setItemState(batchId, id, outcome === "skipped" ? "skipped" : "done");
135
+ } catch (e) {
136
+ const cancelled = e instanceof DOMException && e.name === "AbortError";
137
+ store.setItemState(batchId, id, cancelled ? "cancelled" : "failed", cancelled ? undefined : describeUploadError(e));
138
+ throw e;
139
+ }
140
+ },
141
+ };
142
+ });
143
+
144
+ // The aggregate progress callback is unused here — the panel reads per-item state
145
+ // straight from the store, updated above as each job settles.
146
+ await runUploadQueue(jobs, CONCURRENCY, () => {});
147
+ } finally {
148
+ ctx.endBatch?.();
149
+ fsChanged(dstDir);
95
150
  }
96
151
  }