@geminilight/mindos 0.6.29 → 0.6.31

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 (110) hide show
  1. package/README.md +10 -4
  2. package/README_zh.md +10 -4
  3. package/app/app/api/acp/config/route.ts +82 -0
  4. package/app/app/api/acp/detect/route.ts +71 -48
  5. package/app/app/api/acp/install/route.ts +51 -0
  6. package/app/app/api/acp/session/route.ts +141 -11
  7. package/app/app/api/ask/route.ts +126 -18
  8. package/app/app/api/export/route.ts +105 -0
  9. package/app/app/api/workflows/route.ts +156 -0
  10. package/app/app/globals.css +2 -2
  11. package/app/app/page.tsx +7 -2
  12. package/app/app/trash/page.tsx +7 -0
  13. package/app/app/view/[...path]/ViewPageClient.tsx +234 -2
  14. package/app/components/ActivityBar.tsx +12 -4
  15. package/app/components/AskModal.tsx +4 -1
  16. package/app/components/ExportModal.tsx +220 -0
  17. package/app/components/FileTree.tsx +42 -11
  18. package/app/components/HomeContent.tsx +92 -20
  19. package/app/components/MarkdownView.tsx +45 -10
  20. package/app/components/Panel.tsx +1 -0
  21. package/app/components/RightAskPanel.tsx +5 -1
  22. package/app/components/Sidebar.tsx +10 -1
  23. package/app/components/SidebarLayout.tsx +6 -0
  24. package/app/components/TrashPageClient.tsx +263 -0
  25. package/app/components/agents/AgentDetailContent.tsx +263 -47
  26. package/app/components/agents/AgentsContentPage.tsx +11 -0
  27. package/app/components/agents/AgentsPanelA2aTab.tsx +285 -46
  28. package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
  29. package/app/components/agents/agents-content-model.ts +2 -2
  30. package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
  31. package/app/components/ask/AskContent.tsx +197 -239
  32. package/app/components/ask/FileChip.tsx +82 -17
  33. package/app/components/ask/MentionPopover.tsx +21 -3
  34. package/app/components/ask/MessageList.tsx +30 -9
  35. package/app/components/ask/SlashCommandPopover.tsx +21 -3
  36. package/app/components/ask/ToolCallBlock.tsx +102 -18
  37. package/app/components/changes/ChangesContentPage.tsx +58 -14
  38. package/app/components/explore/ExploreContent.tsx +4 -7
  39. package/app/components/explore/UseCaseCard.tsx +18 -1
  40. package/app/components/explore/use-cases.generated.ts +76 -0
  41. package/app/components/explore/use-cases.yaml +185 -0
  42. package/app/components/panels/AgentsPanel.tsx +1 -0
  43. package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
  44. package/app/components/panels/DiscoverPanel.tsx +1 -1
  45. package/app/components/panels/WorkflowsPanel.tsx +206 -0
  46. package/app/components/renderers/workflow-yaml/StepEditor.tsx +164 -0
  47. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +211 -0
  48. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +269 -0
  49. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
  50. package/app/components/renderers/workflow-yaml/execution.ts +229 -0
  51. package/app/components/renderers/workflow-yaml/index.ts +6 -0
  52. package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
  53. package/app/components/renderers/workflow-yaml/parser.ts +172 -0
  54. package/app/components/renderers/workflow-yaml/selectors.tsx +574 -0
  55. package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
  56. package/app/components/renderers/workflow-yaml/types.ts +46 -0
  57. package/app/components/settings/AiTab.tsx +191 -174
  58. package/app/components/settings/AppearanceTab.tsx +168 -77
  59. package/app/components/settings/KnowledgeTab.tsx +131 -136
  60. package/app/components/settings/McpTab.tsx +11 -11
  61. package/app/components/settings/Primitives.tsx +60 -0
  62. package/app/components/settings/SettingsContent.tsx +15 -8
  63. package/app/components/settings/SyncTab.tsx +12 -12
  64. package/app/components/settings/UninstallTab.tsx +8 -18
  65. package/app/components/settings/UpdateTab.tsx +82 -82
  66. package/app/components/settings/types.ts +17 -8
  67. package/app/hooks/useAcpConfig.ts +96 -0
  68. package/app/hooks/useAcpDetection.ts +69 -14
  69. package/app/hooks/useAcpRegistry.ts +46 -11
  70. package/app/hooks/useAskModal.ts +12 -5
  71. package/app/hooks/useAskPanel.ts +8 -5
  72. package/app/hooks/useAskSession.ts +19 -2
  73. package/app/hooks/useImageUpload.ts +152 -0
  74. package/app/lib/acp/acp-tools.ts +3 -1
  75. package/app/lib/acp/agent-descriptors.ts +274 -0
  76. package/app/lib/acp/bridge.ts +6 -0
  77. package/app/lib/acp/index.ts +20 -4
  78. package/app/lib/acp/registry.ts +74 -7
  79. package/app/lib/acp/session.ts +490 -28
  80. package/app/lib/acp/subprocess.ts +307 -21
  81. package/app/lib/acp/types.ts +158 -20
  82. package/app/lib/actions.ts +57 -3
  83. package/app/lib/agent/model.ts +18 -3
  84. package/app/lib/agent/stream-consumer.ts +18 -0
  85. package/app/lib/agent/to-agent-messages.ts +25 -2
  86. package/app/lib/agent/tools.ts +56 -9
  87. package/app/lib/core/export.ts +116 -0
  88. package/app/lib/core/trash.ts +241 -0
  89. package/app/lib/fs.ts +47 -0
  90. package/app/lib/hooks/usePinnedFiles.ts +90 -0
  91. package/app/lib/i18n/generated/explore-i18n.generated.ts +138 -0
  92. package/app/lib/i18n/index.ts +3 -0
  93. package/app/lib/i18n/modules/knowledge.ts +124 -6
  94. package/app/lib/i18n/modules/navigation.ts +2 -0
  95. package/app/lib/i18n/modules/onboarding.ts +2 -134
  96. package/app/lib/i18n/modules/panels.ts +146 -2
  97. package/app/lib/i18n/modules/settings.ts +12 -0
  98. package/app/lib/pi-integration/skills.ts +21 -6
  99. package/app/lib/renderers/index.ts +2 -2
  100. package/app/lib/settings.ts +10 -0
  101. package/app/lib/types.ts +12 -1
  102. package/app/next-env.d.ts +1 -1
  103. package/app/package.json +11 -3
  104. package/app/scripts/generate-explore.ts +145 -0
  105. package/package.json +1 -1
  106. package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
  107. package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
  108. package/app/components/explore/use-cases.ts +0 -58
  109. package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
  110. package/app/components/renderers/workflow/manifest.ts +0 -14
@@ -0,0 +1,96 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import type { AcpAgentOverride } from '@/lib/acp/agent-descriptors';
5
+
6
+ interface AcpConfigState {
7
+ configs: Record<string, AcpAgentOverride>;
8
+ loading: boolean;
9
+ saving: boolean;
10
+ error: string | null;
11
+ /** Save a per-agent override. */
12
+ save: (agentId: string, config: AcpAgentOverride) => Promise<boolean>;
13
+ /** Reset a single agent to defaults. */
14
+ reset: (agentId: string) => Promise<boolean>;
15
+ /** Refresh from server. */
16
+ refresh: () => void;
17
+ }
18
+
19
+ export function useAcpConfig(): AcpConfigState {
20
+ const [configs, setConfigs] = useState<Record<string, AcpAgentOverride>>({});
21
+ const [loading, setLoading] = useState(true);
22
+ const [saving, setSaving] = useState(false);
23
+ const [error, setError] = useState<string | null>(null);
24
+ const [trigger, setTrigger] = useState(0);
25
+
26
+ const refresh = useCallback(() => setTrigger((n) => n + 1), []);
27
+
28
+ useEffect(() => {
29
+ let cancelled = false;
30
+ setLoading(true);
31
+ setError(null);
32
+
33
+ fetch('/api/acp/config')
34
+ .then((res) => {
35
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
36
+ return res.json();
37
+ })
38
+ .then((data) => {
39
+ if (cancelled) return;
40
+ setConfigs(data.agents ?? {});
41
+ })
42
+ .catch((err) => {
43
+ if (cancelled) return;
44
+ setError((err as Error).message);
45
+ })
46
+ .finally(() => {
47
+ if (!cancelled) setLoading(false);
48
+ });
49
+
50
+ return () => { cancelled = true; };
51
+ }, [trigger]);
52
+
53
+ const save = useCallback(async (agentId: string, config: AcpAgentOverride): Promise<boolean> => {
54
+ setSaving(true);
55
+ setError(null);
56
+ try {
57
+ const res = await fetch('/api/acp/config', {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json' },
60
+ body: JSON.stringify({ agentId, config }),
61
+ });
62
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
63
+ const data = await res.json();
64
+ setConfigs(data.agents ?? {});
65
+ return true;
66
+ } catch (err) {
67
+ setError((err as Error).message);
68
+ return false;
69
+ } finally {
70
+ setSaving(false);
71
+ }
72
+ }, []);
73
+
74
+ const reset = useCallback(async (agentId: string): Promise<boolean> => {
75
+ setSaving(true);
76
+ setError(null);
77
+ try {
78
+ const res = await fetch('/api/acp/config', {
79
+ method: 'DELETE',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ agentId }),
82
+ });
83
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
84
+ const data = await res.json();
85
+ setConfigs(data.agents ?? {});
86
+ return true;
87
+ } catch (err) {
88
+ setError((err as Error).message);
89
+ return false;
90
+ } finally {
91
+ setSaving(false);
92
+ }
93
+ }, []);
94
+
95
+ return { configs, loading, saving, error, save, reset, refresh };
96
+ }
@@ -1,17 +1,23 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
 
5
5
  export interface DetectedAgent {
6
6
  id: string;
7
7
  name: string;
8
8
  binaryPath: string;
9
+ resolvedCommand?: {
10
+ cmd: string;
11
+ args: string[];
12
+ source: 'user-override' | 'descriptor' | 'registry';
13
+ };
9
14
  }
10
15
 
11
16
  export interface NotInstalledAgent {
12
17
  id: string;
13
18
  name: string;
14
19
  installCmd: string;
20
+ packageName?: string;
15
21
  }
16
22
 
17
23
  interface AcpDetectionState {
@@ -22,44 +28,93 @@ interface AcpDetectionState {
22
28
  refresh: () => void;
23
29
  }
24
30
 
31
+ const STORAGE_KEY = 'mindos:acp-detection';
32
+ const STALE_TTL_MS = 30 * 60 * 1000;
33
+ const REVALIDATE_TTL_MS = 5 * 60 * 1000;
34
+
35
+ interface DetectionCache {
36
+ installed: DetectedAgent[];
37
+ notInstalled: NotInstalledAgent[];
38
+ ts: number;
39
+ }
40
+
41
+ function readStorage(): DetectionCache | null {
42
+ try {
43
+ const raw = sessionStorage.getItem(STORAGE_KEY);
44
+ if (!raw) return null;
45
+ const parsed = JSON.parse(raw);
46
+ if (typeof parsed.ts !== 'number' || Date.now() - parsed.ts > STALE_TTL_MS) return null;
47
+ return parsed;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ function writeStorage(installed: DetectedAgent[], notInstalled: NotInstalledAgent[]) {
54
+ try {
55
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ installed, notInstalled, ts: Date.now() }));
56
+ } catch { /* quota exceeded */ }
57
+ }
58
+
25
59
  export function useAcpDetection(): AcpDetectionState {
26
- const [installedAgents, setInstalledAgents] = useState<DetectedAgent[]>([]);
27
- const [notInstalledAgents, setNotInstalledAgents] = useState<NotInstalledAgent[]>([]);
28
- const [loading, setLoading] = useState(true);
60
+ const cached = useRef(readStorage());
61
+ const [installedAgents, setInstalledAgents] = useState<DetectedAgent[]>(cached.current?.installed ?? []);
62
+ const [notInstalledAgents, setNotInstalledAgents] = useState<NotInstalledAgent[]>(cached.current?.notInstalled ?? []);
63
+ const [loading, setLoading] = useState(!cached.current);
29
64
  const [error, setError] = useState<string | null>(null);
30
65
  const [trigger, setTrigger] = useState(0);
66
+ const inflight = useRef(false);
67
+
68
+ const forceRef = useRef(false);
31
69
 
32
70
  const refresh = useCallback(() => {
71
+ try { sessionStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
72
+ cached.current = null;
73
+ forceRef.current = true;
33
74
  setTrigger((n) => n + 1);
34
75
  }, []);
35
76
 
36
77
  useEffect(() => {
37
- let cancelled = false;
38
- setLoading(true);
78
+ const isForce = forceRef.current;
79
+ forceRef.current = false;
80
+
81
+ const fresh = cached.current && Date.now() - cached.current.ts < REVALIDATE_TTL_MS;
82
+ if (fresh && trigger === 0) return;
83
+
84
+ if (inflight.current) return;
85
+ inflight.current = true;
86
+
87
+ const hasCachedData = installedAgents.length > 0 || notInstalledAgents.length > 0;
88
+ if (!hasCachedData) setLoading(true);
39
89
  setError(null);
40
90
 
41
- fetch('/api/acp/detect')
91
+ let cancelled = false;
92
+
93
+ fetch(`/api/acp/detect${isForce ? '?force=1' : ''}`)
42
94
  .then((res) => {
43
95
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
44
96
  return res.json();
45
97
  })
46
98
  .then((data) => {
47
99
  if (cancelled) return;
48
- setInstalledAgents(data.installed ?? []);
49
- setNotInstalledAgents(data.notInstalled ?? []);
100
+ const inst: DetectedAgent[] = data.installed ?? [];
101
+ const notInst: NotInstalledAgent[] = data.notInstalled ?? [];
102
+ writeStorage(inst, notInst);
103
+ cached.current = { installed: inst, notInstalled: notInst, ts: Date.now() };
104
+ setInstalledAgents(inst);
105
+ setNotInstalledAgents(notInst);
50
106
  })
51
107
  .catch((err) => {
52
108
  if (cancelled) return;
53
- setError((err as Error).message);
109
+ if (!hasCachedData) setError((err as Error).message);
54
110
  })
55
111
  .finally(() => {
112
+ inflight.current = false;
56
113
  if (!cancelled) setLoading(false);
57
114
  });
58
115
 
59
- return () => {
60
- cancelled = true;
61
- };
62
- }, [trigger]);
116
+ return () => { cancelled = true; };
117
+ }, [trigger]); // eslint-disable-line react-hooks/exhaustive-deps
63
118
 
64
119
  return { installedAgents, notInstalledAgents, loading, error, refresh };
65
120
  }
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useRef } from 'react';
4
4
  import type { AcpRegistryEntry } from '@/lib/acp/types';
5
5
 
6
6
  interface AcpRegistryState {
@@ -10,21 +10,54 @@ interface AcpRegistryState {
10
10
  retry: () => void;
11
11
  }
12
12
 
13
+ const STORAGE_KEY = 'mindos:acp-registry';
14
+ const STALE_TTL_MS = 30 * 60 * 1000; // 30 min — show stale data instantly
15
+ const REVALIDATE_TTL_MS = 10 * 60 * 1000; // 10 min — background refresh interval
16
+
17
+ function readStorage(): { agents: AcpRegistryEntry[]; ts: number } | null {
18
+ try {
19
+ const raw = sessionStorage.getItem(STORAGE_KEY);
20
+ if (!raw) return null;
21
+ const parsed = JSON.parse(raw);
22
+ if (!Array.isArray(parsed.agents) || typeof parsed.ts !== 'number') return null;
23
+ if (Date.now() - parsed.ts > STALE_TTL_MS) return null;
24
+ return parsed;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function writeStorage(agents: AcpRegistryEntry[]) {
31
+ try {
32
+ sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ agents, ts: Date.now() }));
33
+ } catch { /* quota exceeded — ignore */ }
34
+ }
35
+
13
36
  export function useAcpRegistry(): AcpRegistryState {
14
- const [agents, setAgents] = useState<AcpRegistryEntry[]>([]);
15
- const [loading, setLoading] = useState(true);
37
+ const cached = useRef(readStorage());
38
+ const [agents, setAgents] = useState<AcpRegistryEntry[]>(cached.current?.agents ?? []);
39
+ const [loading, setLoading] = useState(!cached.current);
16
40
  const [error, setError] = useState<string | null>(null);
17
41
  const [trigger, setTrigger] = useState(0);
42
+ const inflight = useRef(false);
18
43
 
19
44
  const retry = useCallback(() => {
20
45
  setTrigger((n) => n + 1);
21
46
  }, []);
22
47
 
23
48
  useEffect(() => {
24
- let cancelled = false;
25
- setLoading(true);
49
+ const fresh = cached.current && Date.now() - cached.current.ts < REVALIDATE_TTL_MS;
50
+ if (fresh && trigger === 0) return;
51
+
52
+ if (inflight.current) return;
53
+ inflight.current = true;
54
+
55
+ const hasCachedData = agents.length > 0;
56
+ if (!hasCachedData) setLoading(true);
26
57
  setError(null);
27
58
 
59
+ let cancelled = false;
60
+
28
61
  fetch('/api/acp/registry')
29
62
  .then((res) => {
30
63
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -32,20 +65,22 @@ export function useAcpRegistry(): AcpRegistryState {
32
65
  })
33
66
  .then((data) => {
34
67
  if (cancelled) return;
35
- setAgents(data.registry?.agents ?? []);
68
+ const list: AcpRegistryEntry[] = data.registry?.agents ?? [];
69
+ writeStorage(list);
70
+ cached.current = { agents: list, ts: Date.now() };
71
+ setAgents(list);
36
72
  })
37
73
  .catch((err) => {
38
74
  if (cancelled) return;
39
- setError((err as Error).message);
75
+ if (!hasCachedData) setError((err as Error).message);
40
76
  })
41
77
  .finally(() => {
78
+ inflight.current = false;
42
79
  if (!cancelled) setLoading(false);
43
80
  });
44
81
 
45
- return () => {
46
- cancelled = true;
47
- };
48
- }, [trigger]);
82
+ return () => { cancelled = true; };
83
+ }, [trigger]); // eslint-disable-line react-hooks/exhaustive-deps
49
84
 
50
85
  return { agents, loading, error, retry };
51
86
  }
@@ -8,13 +8,19 @@ import { useSyncExternalStore, useCallback } from 'react';
8
8
  * No external dependencies (no zustand needed).
9
9
  */
10
10
 
11
+ export interface AcpAgentSelection {
12
+ id: string;
13
+ name: string;
14
+ }
15
+
11
16
  interface AskModalState {
12
17
  open: boolean;
13
18
  initialMessage: string;
14
19
  source: 'user' | 'guide' | 'guide-next'; // who triggered the open
20
+ acpAgent: AcpAgentSelection | null;
15
21
  }
16
22
 
17
- let state: AskModalState = { open: false, initialMessage: '', source: 'user' };
23
+ let state: AskModalState = { open: false, initialMessage: '', source: 'user', acpAgent: null };
18
24
  const listeners = new Set<() => void>();
19
25
 
20
26
  function emit() { listeners.forEach(l => l()); }
@@ -24,13 +30,13 @@ function subscribe(listener: () => void) {
24
30
  }
25
31
  function getSnapshot() { return state; }
26
32
 
27
- export function openAskModal(message = '', source: AskModalState['source'] = 'user') {
28
- state = { open: true, initialMessage: message, source };
33
+ export function openAskModal(message = '', source: AskModalState['source'] = 'user', acpAgent: AcpAgentSelection | null = null) {
34
+ state = { open: true, initialMessage: message, source, acpAgent };
29
35
  emit();
30
36
  }
31
37
 
32
38
  export function closeAskModal() {
33
- state = { open: false, initialMessage: '', source: 'user' };
39
+ state = { open: false, initialMessage: '', source: 'user', acpAgent: null };
34
40
  emit();
35
41
  }
36
42
 
@@ -40,7 +46,8 @@ export function useAskModal() {
40
46
  open: snap.open,
41
47
  initialMessage: snap.initialMessage,
42
48
  source: snap.source,
43
- openWith: useCallback((message: string, source: AskModalState['source'] = 'user') => openAskModal(message, source), []),
49
+ acpAgent: snap.acpAgent,
50
+ openWith: useCallback((message: string, source: AskModalState['source'] = 'user', acpAgent: AcpAgentSelection | null = null) => openAskModal(message, source, acpAgent), []),
44
51
  close: useCallback(() => closeAskModal(), []),
45
52
  };
46
53
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { useState, useEffect, useCallback } from 'react';
4
4
  import { RIGHT_ASK_DEFAULT_WIDTH, RIGHT_ASK_MIN_WIDTH, RIGHT_ASK_MAX_WIDTH } from '@/components/RightAskPanel';
5
- import { useAskModal } from './useAskModal';
5
+ import { useAskModal, type AcpAgentSelection } from './useAskModal';
6
6
 
7
7
  export interface AskPanelState {
8
8
  askPanelOpen: boolean;
@@ -12,6 +12,7 @@ export interface AskPanelState {
12
12
  desktopAskPopupOpen: boolean;
13
13
  askInitialMessage: string;
14
14
  askOpenSource: 'user' | 'guide' | 'guide-next';
15
+ askAcpAgent: AcpAgentSelection | null;
15
16
  toggleAskPanel: () => void;
16
17
  closeAskPanel: () => void;
17
18
  closeDesktopAskPopup: () => void;
@@ -33,6 +34,7 @@ export function useAskPanel(): AskPanelState {
33
34
  const [askInitialMessage, setAskInitialMessage] = useState('');
34
35
  const [askMaximized, setAskMaximized] = useState(false);
35
36
  const [askOpenSource, setAskOpenSource] = useState<'user' | 'guide' | 'guide-next'>('user');
37
+ const [askAcpAgent, setAskAcpAgent] = useState<AcpAgentSelection | null>(null);
36
38
 
37
39
  const askModal = useAskModal();
38
40
 
@@ -62,6 +64,7 @@ export function useAskPanel(): AskPanelState {
62
64
  if (askModal.open) {
63
65
  setAskInitialMessage(askModal.initialMessage);
64
66
  setAskOpenSource(askModal.source);
67
+ setAskAcpAgent(askModal.acpAgent);
65
68
  if (askMode === 'popup') {
66
69
  setDesktopAskPopupOpen(true);
67
70
  } else {
@@ -69,17 +72,17 @@ export function useAskPanel(): AskPanelState {
69
72
  }
70
73
  askModal.close();
71
74
  }
72
- }, [askModal.open, askModal.initialMessage, askModal.source, askModal.close, askMode]);
75
+ }, [askModal.open, askModal.initialMessage, askModal.source, askModal.acpAgent, askModal.close, askMode]);
73
76
 
74
77
  const toggleAskPanel = useCallback(() => {
75
78
  if (askMode === 'popup') {
76
79
  setDesktopAskPopupOpen(v => {
77
- if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); }
80
+ if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); setAskAcpAgent(null); }
78
81
  return !v;
79
82
  });
80
83
  } else {
81
84
  setAskPanelOpen(v => {
82
- if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); }
85
+ if (!v) { setAskInitialMessage(''); setAskOpenSource('user'); setAskAcpAgent(null); }
83
86
  return !v;
84
87
  });
85
88
  }
@@ -114,7 +117,7 @@ export function useAskPanel(): AskPanelState {
114
117
 
115
118
  return {
116
119
  askPanelOpen, askPanelWidth, askMaximized, askMode, desktopAskPopupOpen,
117
- askInitialMessage, askOpenSource,
120
+ askInitialMessage, askOpenSource, askAcpAgent,
118
121
  toggleAskPanel, closeAskPanel, closeDesktopAskPopup,
119
122
  handleAskWidthChange, handleAskWidthCommit, handleAskModeSwitch, toggleAskMaximized,
120
123
  };
@@ -21,7 +21,10 @@ export function sessionTitle(s: ChatSession): string {
21
21
  const firstUser = s.messages.find((m) => m.role === 'user');
22
22
  if (!firstUser) return '(empty session)';
23
23
  const line = firstUser.content.replace(/\s+/g, ' ').trim();
24
- return line.length > 42 ? `${line.slice(0, 42)}...` : line;
24
+ if (!line && firstUser.images && firstUser.images.length > 0) {
25
+ return `[${firstUser.images.length} image${firstUser.images.length > 1 ? 's' : ''}]`;
26
+ }
27
+ return line.length > 42 ? `${line.slice(0, 42)}...` : line || '(empty session)';
25
28
  }
26
29
 
27
30
  async function fetchSessions(): Promise<ChatSession[]> {
@@ -38,10 +41,24 @@ async function fetchSessions(): Promise<ChatSession[]> {
38
41
 
39
42
  async function upsertSession(session: ChatSession): Promise<void> {
40
43
  try {
44
+ // Strip base64 image data before persisting (images are session-only, not stored)
45
+ const stripped: ChatSession = {
46
+ ...session,
47
+ messages: session.messages.map(m => {
48
+ if (!m.images || m.images.length === 0) return m;
49
+ return {
50
+ ...m,
51
+ images: m.images.map(img => ({
52
+ ...img,
53
+ data: '', // Strip base64 data — images are ephemeral
54
+ })),
55
+ };
56
+ }),
57
+ };
41
58
  await fetch('/api/ask-sessions', {
42
59
  method: 'POST',
43
60
  headers: { 'Content-Type': 'application/json' },
44
- body: JSON.stringify({ session }),
61
+ body: JSON.stringify({ session: stripped }),
45
62
  });
46
63
  } catch {
47
64
  // ignore persistence errors
@@ -0,0 +1,152 @@
1
+ 'use client';
2
+
3
+ import { useState, useCallback } from 'react';
4
+ import type { ImagePart, ImageMimeType } from '@/lib/types';
5
+
6
+ const ALLOWED_IMAGE_TYPES: Set<string> = new Set([
7
+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp',
8
+ ]);
9
+
10
+ const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB per image
11
+ const MAX_IMAGES = 4;
12
+ const MAX_DIMENSION = 1920; // Compress to this max width/height
13
+
14
+ function isImageMimeType(type: string): type is ImageMimeType {
15
+ return ALLOWED_IMAGE_TYPES.has(type);
16
+ }
17
+
18
+ /** Compress image to JPEG if over size/dimension limits */
19
+ async function compressImage(file: File): Promise<{ data: string; mimeType: ImageMimeType }> {
20
+ const mimeType = file.type as ImageMimeType;
21
+
22
+ // If small enough, use as-is (skip canvas compression to preserve GIF animation)
23
+ if (file.size <= MAX_IMAGE_SIZE) {
24
+ const buffer = await file.arrayBuffer();
25
+ const bytes = new Uint8Array(buffer);
26
+ let binary = '';
27
+ const chunkSize = 0x8000;
28
+ for (let i = 0; i < bytes.length; i += chunkSize) {
29
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
30
+ }
31
+ return { data: btoa(binary), mimeType };
32
+ }
33
+
34
+ // Compress via canvas
35
+ return new Promise((resolve, reject) => {
36
+ const img = new Image();
37
+ img.onload = () => {
38
+ let { width, height } = img;
39
+ if (width > MAX_DIMENSION || height > MAX_DIMENSION) {
40
+ const ratio = Math.min(MAX_DIMENSION / width, MAX_DIMENSION / height);
41
+ width = Math.round(width * ratio);
42
+ height = Math.round(height * ratio);
43
+ }
44
+ const canvas = document.createElement('canvas');
45
+ canvas.width = width;
46
+ canvas.height = height;
47
+ const ctx = canvas.getContext('2d');
48
+ if (!ctx) { reject(new Error('Canvas not supported')); return; }
49
+ ctx.drawImage(img, 0, 0, width, height);
50
+ const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
51
+ const base64 = dataUrl.split(',')[1];
52
+ resolve({ data: base64, mimeType: 'image/jpeg' });
53
+ URL.revokeObjectURL(img.src);
54
+ };
55
+ img.onerror = () => { reject(new Error('Failed to load image')); URL.revokeObjectURL(img.src); };
56
+ img.src = URL.createObjectURL(file);
57
+ });
58
+ }
59
+
60
+ /** Convert clipboard/DataTransfer items to File[] */
61
+ function extractImageFiles(items: DataTransferItemList | DataTransferItem[]): File[] {
62
+ const files: File[] = [];
63
+ const list = Array.from(items);
64
+ for (const item of list) {
65
+ if (item.kind === 'file' && ALLOWED_IMAGE_TYPES.has(item.type)) {
66
+ const file = item.getAsFile();
67
+ if (file) files.push(file);
68
+ }
69
+ }
70
+ return files;
71
+ }
72
+
73
+ export function useImageUpload() {
74
+ const [images, setImages] = useState<ImagePart[]>([]);
75
+ const [imageError, setImageError] = useState('');
76
+
77
+ const addImages = useCallback(async (files: File[]) => {
78
+ setImageError('');
79
+ const toProcess = files.slice(0, MAX_IMAGES);
80
+
81
+ const results: ImagePart[] = [];
82
+ for (const file of toProcess) {
83
+ if (!isImageMimeType(file.type)) {
84
+ setImageError(`Unsupported image type: ${file.type}`);
85
+ continue;
86
+ }
87
+ try {
88
+ const { data, mimeType } = await compressImage(file);
89
+ results.push({ type: 'image', data, mimeType });
90
+ } catch {
91
+ setImageError(`Failed to process image: ${file.name}`);
92
+ }
93
+ }
94
+
95
+ setImages(prev => {
96
+ const merged = [...prev, ...results];
97
+ if (merged.length > MAX_IMAGES) {
98
+ setImageError(`Maximum ${MAX_IMAGES} images allowed`);
99
+ return merged.slice(0, MAX_IMAGES);
100
+ }
101
+ return merged;
102
+ });
103
+ }, []);
104
+
105
+ /** Handle paste event — returns true if images were found */
106
+ const handlePaste = useCallback(async (e: ClipboardEvent | React.ClipboardEvent): Promise<boolean> => {
107
+ const items = e.clipboardData?.items;
108
+ if (!items) return false;
109
+ const files = extractImageFiles(items);
110
+ if (files.length === 0) return false;
111
+ await addImages(files);
112
+ return true;
113
+ }, [addImages]);
114
+
115
+ /** Handle drop event */
116
+ const handleDrop = useCallback(async (e: DragEvent | React.DragEvent) => {
117
+ const items = e.dataTransfer?.items;
118
+ if (!items) return;
119
+ const files = extractImageFiles(items);
120
+ if (files.length > 0) {
121
+ e.preventDefault();
122
+ await addImages(files);
123
+ }
124
+ }, [addImages]);
125
+
126
+ /** Handle file input change */
127
+ const handleFileSelect = useCallback(async (files: FileList | null) => {
128
+ if (!files) return;
129
+ const imageFiles = Array.from(files).filter(f => isImageMimeType(f.type));
130
+ await addImages(imageFiles);
131
+ }, [addImages]);
132
+
133
+ const removeImage = useCallback((idx: number) => {
134
+ setImages(prev => prev.filter((_, i) => i !== idx));
135
+ }, []);
136
+
137
+ const clearImages = useCallback(() => {
138
+ setImages([]);
139
+ setImageError('');
140
+ }, []);
141
+
142
+ return {
143
+ images,
144
+ imageError,
145
+ addImages,
146
+ handlePaste,
147
+ handleDrop,
148
+ handleFileSelect,
149
+ removeImage,
150
+ clearImages,
151
+ };
152
+ }
@@ -7,6 +7,7 @@ import { Type, type Static } from '@sinclair/typebox';
7
7
  import type { AgentTool } from '@mariozechner/pi-agent-core';
8
8
  import { getAcpAgents, findAcpAgent } from './registry';
9
9
  import { createSessionFromEntry, prompt, closeSession } from './session';
10
+ import { getMindRoot } from '../fs';
10
11
 
11
12
  function textResult(text: string) {
12
13
  return { content: [{ type: 'text' as const, text }], details: {} };
@@ -75,7 +76,8 @@ export const acpTools: AgentTool<any>[] = [
75
76
  return textResult(`ACP agent not found: ${params.agent_id}. Use list_acp_agents to see available agents.`);
76
77
  }
77
78
 
78
- const session = await createSessionFromEntry(entry);
79
+ const cwd = getMindRoot();
80
+ const session = await createSessionFromEntry(entry, { cwd });
79
81
 
80
82
  try {
81
83
  const response = await prompt(session.id, params.message);