shraga 0.0.2 → 0.1.1
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.
- package/README.md +82 -23
- package/defaults/agents/summarizer.md +16 -0
- package/defaults/agents/trace-extractor.md +84 -0
- package/defaults/bin/claude +45 -0
- package/defaults/bin/claude-revive +17 -0
- package/defaults/extensions/README.md +70 -0
- package/defaults/extensions/selftest.ext.ts +43 -0
- package/defaults/extensions/stripe-webhook.ext.ts +58 -0
- package/defaults/gmail-triage-prompt.md +42 -0
- package/defaults/scripts/README +4 -0
- package/defaults/scripts/agent-once.ts +67 -0
- package/defaults/scripts/backfill-slack-usernames.ts +82 -0
- package/defaults/scripts/notifier-throttle.ts +44 -0
- package/defaults/scripts/summarize-conversations.ts +5 -0
- package/defaults/shraga.config.ts +29 -0
- package/defaults/skills/add-skill.md +14 -0
- package/defaults/skills/artifacts.md +116 -0
- package/defaults/skills/code-review.md +26 -0
- package/defaults/skills/communications.md +54 -0
- package/defaults/skills/context-audit.md +87 -0
- package/defaults/skills/debug.md +10 -0
- package/defaults/skills/garden.md +179 -0
- package/defaults/skills/github-contributor.md +35 -0
- package/defaults/skills/identity.md +30 -0
- package/defaults/skills/mcp-server.md +62 -0
- package/defaults/skills/mcps-sync.md +105 -0
- package/defaults/skills/plan.md +9 -0
- package/defaults/skills/platform.md +177 -0
- package/defaults/skills/reconcile.md +239 -0
- package/defaults/skills/scheduler.md +192 -0
- package/defaults/skills/self-aware.md +136 -0
- package/defaults/skills/shraga-know.md +333 -0
- package/defaults/skills/stripe.md +55 -0
- package/defaults/skills/write-tests.md +10 -0
- package/defaults/skills-defaults.json +1 -0
- package/defaults/system-prompt.md +46 -0
- package/defaults/workspace/context.md +28 -0
- package/defaults/workspace.md +50 -0
- package/defaults/zdotdir/.gitignore +8 -0
- package/defaults/zdotdir/.zlogin +3 -0
- package/defaults/zdotdir/.zprofile +1 -0
- package/defaults/zdotdir/.zshenv +4 -0
- package/defaults/zdotdir/.zshrc +3 -0
- package/dist/client/assets/index-BoHttkMt.js +1940 -0
- package/dist/client/assets/index-DdibEb2O.css +10 -0
- package/dist/client/index.html +22 -0
- package/package.json +60 -15
- package/src/cli.ts +71 -46
- package/src/client/App.tsx +510 -0
- package/src/client/components/ArtifactCard.tsx +26 -0
- package/src/client/components/ArtifactPanel.tsx +138 -0
- package/src/client/components/AuthedImage.tsx +85 -0
- package/src/client/components/AutocompleteTextarea.tsx +149 -0
- package/src/client/components/ChatView.tsx +866 -0
- package/src/client/components/CliAuthConsent.tsx +98 -0
- package/src/client/components/ConfigPanel.tsx +328 -0
- package/src/client/components/ConversationHeader.tsx +156 -0
- package/src/client/components/ConversationPane.tsx +277 -0
- package/src/client/components/LoginPage.tsx +81 -0
- package/src/client/components/MachineStats.tsx +77 -0
- package/src/client/components/McpManager.tsx +209 -0
- package/src/client/components/MessageInput.tsx +263 -0
- package/src/client/components/OAuthConsent.tsx +103 -0
- package/src/client/components/SchedulesManager.tsx +99 -0
- package/src/client/components/Sidebar.tsx +235 -0
- package/src/client/components/SkillsManager.tsx +280 -0
- package/src/client/components/SmartChart.tsx +167 -0
- package/src/client/components/Toast.tsx +54 -0
- package/src/client/components/WorkspaceTree.tsx +313 -0
- package/src/client/components/ZoomableImage.tsx +123 -0
- package/src/client/components/artifact-presets.ts +10 -0
- package/src/client/components/schedules/ScheduleEditor.tsx +264 -0
- package/src/client/components/schedules/ScheduleList.tsx +271 -0
- package/src/client/components/ui/accordion.tsx +50 -0
- package/src/client/components/ui/button.tsx +43 -0
- package/src/client/components/ui/dialog.tsx +82 -0
- package/src/client/components/ui/input.tsx +19 -0
- package/src/client/components/ui/scroll-area.tsx +39 -0
- package/src/client/components/ui/textarea.tsx +18 -0
- package/src/client/globals.css +51 -0
- package/src/client/hooks/useAgentSocket.ts +79 -0
- package/src/client/hooks/useArtifacts.ts +89 -0
- package/src/client/hooks/useAuth.ts +127 -0
- package/src/client/hooks/useConversation.ts +412 -0
- package/src/client/hooks/useDarkMode.ts +57 -0
- package/src/client/hooks/useIsMobile.ts +23 -0
- package/src/client/hooks/usePush.ts +127 -0
- package/src/client/hooks/useSchedules.ts +73 -0
- package/src/client/hooks/useUnread.ts +238 -0
- package/src/client/lib/desktopAttention.ts +75 -0
- package/src/client/lib/firebase.ts +32 -0
- package/src/client/lib/googleAuthNative.ts +94 -0
- package/src/client/lib/native.ts +43 -0
- package/src/client/lib/schedule-types.ts +34 -0
- package/src/client/lib/sessionApi.ts +58 -0
- package/src/client/lib/slots.tsx +79 -0
- package/src/client/lib/storage.ts +39 -0
- package/src/client/lib/utils.ts +26 -0
- package/src/client/lib/workspaceContext.tsx +54 -0
- package/src/client/lib/ws.ts +203 -0
- package/src/client/main.tsx +14 -0
- package/src/mcp-stdio-bridge.ts +70 -0
- package/src/scripts/summarize-conversations.ts +5 -0
- package/src/scripts/typecheck.ts +43 -0
- package/src/server/agents.ts +54 -0
- package/src/server/api-keys.ts +63 -0
- package/src/server/artifacts/artifacts.export.ts +85 -0
- package/src/server/artifacts/artifacts.handler.ts +93 -0
- package/src/server/artifacts/artifacts.routes.ts +43 -0
- package/src/server/artifacts/artifacts.service.ts +100 -0
- package/src/server/artifacts/artifacts.types.ts +31 -0
- package/src/server/auth.ts +262 -0
- package/src/server/claude.ts +394 -0
- package/src/server/commands.ts +21 -0
- package/src/server/contacts.ts +177 -0
- package/src/server/conversation-summarizer.ts +204 -0
- package/src/server/data-sync.ts +664 -0
- package/src/server/directives.ts +91 -0
- package/src/server/engine/claude-code.ts +514 -0
- package/src/server/engine/index.ts +41 -0
- package/src/server/engine/registry.ts +21 -0
- package/src/server/engine/shared.ts +47 -0
- package/src/server/engine/types.ts +48 -0
- package/src/server/env-resolve.ts +71 -0
- package/src/server/env-sanitize.ts +9 -0
- package/src/server/events/bus.ts +29 -0
- package/src/server/events/dispatcher.ts +48 -0
- package/src/server/events/routes.ts +19 -0
- package/src/server/events/types.ts +9 -0
- package/src/server/extensions.ts +101 -0
- package/src/server/features.ts +109 -0
- package/src/server/file-inject.ts +45 -0
- package/src/server/hooks.ts +142 -0
- package/src/server/idempotency.ts +25 -0
- package/src/server/index.ts +1715 -0
- package/src/server/integrity-audit.ts +132 -0
- package/src/server/mcp-catalog.ts +70 -0
- package/src/server/mcp-oauth.ts +198 -0
- package/src/server/mcp-progress.ts +45 -0
- package/src/server/mcp-server.ts +456 -0
- package/src/server/mcp-sidecar.ts +87 -0
- package/src/server/mcp.ts +291 -0
- package/src/server/model-aliases.ts +76 -0
- package/src/server/paths.ts +24 -0
- package/src/server/polls.ts +175 -0
- package/src/server/push/apns.ts +113 -0
- package/src/server/push/fcm.ts +108 -0
- package/src/server/push/push.ts +66 -0
- package/src/server/push/store.ts +84 -0
- package/src/server/push/triggers.ts +99 -0
- package/src/server/scheduler/builtins.ts +157 -0
- package/src/server/scheduler/engine.ts +432 -0
- package/src/server/scheduler/index.ts +4 -0
- package/src/server/scheduler/runner.ts +334 -0
- package/src/server/scheduler/storage.ts +98 -0
- package/src/server/scheduler/timing.ts +70 -0
- package/src/server/scheduler/types.ts +62 -0
- package/src/server/sdk-utils.ts +45 -0
- package/src/server/seed.ts +174 -0
- package/src/server/session-bus.ts +18 -0
- package/src/server/sessions.ts +559 -0
- package/src/server/shraga-config.ts +167 -0
- package/src/server/skills.ts +372 -0
- package/src/server/slack/api.ts +37 -0
- package/src/server/slack/bot.ts +391 -0
- package/src/server/slack/context-cache.ts +42 -0
- package/src/server/slack/feature.ts +59 -0
- package/src/server/slack/mention-rewrite.ts +59 -0
- package/src/server/slack/oauth.ts +102 -0
- package/src/server/slack/questions.ts +112 -0
- package/src/server/slack/sessions.ts +139 -0
- package/src/server/stats.ts +106 -0
- package/src/server/summarize.ts +11 -0
- package/src/server/turn-context.ts +61 -0
- package/src/server/unclaw-config.ts +19 -0
- package/src/server/unread.ts +79 -0
- package/src/server/user-context.ts +33 -0
- package/src/server/vendor-sync.ts +52 -0
- package/src/server/voice-provider.ts +74 -0
- package/src/server/workspace.ts +249 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
|
2
|
+
import { AgentSocket, type ServerEvent, type AskQuestion, type QuestionAnswers } from '@/lib/ws';
|
|
3
|
+
import { randomUUID } from '@/lib/utils';
|
|
4
|
+
import { apiFetch, historyToMessages } from '@/lib/sessionApi';
|
|
5
|
+
|
|
6
|
+
export interface Attachment {
|
|
7
|
+
url: string;
|
|
8
|
+
name: string;
|
|
9
|
+
mimeType: string;
|
|
10
|
+
path: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type MessageBlock =
|
|
14
|
+
| { type: 'text'; text: string }
|
|
15
|
+
| { type: 'thinking'; text: string }
|
|
16
|
+
| { type: 'image'; src: string }
|
|
17
|
+
| { type: 'file'; src: string; name: string; mimeType: string }
|
|
18
|
+
| { type: 'context'; label: string; text: string }
|
|
19
|
+
| { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
|
|
20
|
+
| { type: 'tool_result'; toolUseId: string; output: string }
|
|
21
|
+
| { type: 'permission_request'; id: string; tool: string; input: Record<string, unknown> }
|
|
22
|
+
| { type: 'question_request'; id: string; questions: AskQuestion[] }
|
|
23
|
+
| { type: 'compact_marker'; summary: string; compactedCount: number };
|
|
24
|
+
|
|
25
|
+
export interface ChatMessage {
|
|
26
|
+
id: string;
|
|
27
|
+
role: 'user' | 'assistant';
|
|
28
|
+
blocks: MessageBlock[];
|
|
29
|
+
ts?: number;
|
|
30
|
+
senderName?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ConversationState {
|
|
34
|
+
messages: ChatMessage[];
|
|
35
|
+
setMessages: Dispatch<SetStateAction<ChatMessage[]>>;
|
|
36
|
+
busy: boolean;
|
|
37
|
+
setBusy: (b: boolean) => void;
|
|
38
|
+
sendMessage: (
|
|
39
|
+
text: string,
|
|
40
|
+
sessionId?: string,
|
|
41
|
+
attachments?: Attachment[],
|
|
42
|
+
truncateAt?: number,
|
|
43
|
+
/** Opaque bag passed straight through to the server; the core never interprets it. */
|
|
44
|
+
sendOptions?: Record<string, unknown>,
|
|
45
|
+
/** Display-only: `false` suppresses the user bubble (a synthetic/agent-first opener). */
|
|
46
|
+
echo?: boolean,
|
|
47
|
+
) => void;
|
|
48
|
+
steer: (text: string) => void;
|
|
49
|
+
queue: string[];
|
|
50
|
+
addToQueue: (text: string) => void;
|
|
51
|
+
removeFromQueue: (index: number) => void;
|
|
52
|
+
respondPermission: (id: string, allow: boolean, allowAll?: boolean) => void;
|
|
53
|
+
respondQuestion: (id: string, answers: QuestionAnswers) => void;
|
|
54
|
+
cancel: () => void;
|
|
55
|
+
reset: () => void;
|
|
56
|
+
setSessionId: (id: string | undefined) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Append a block to the last assistant message, or create a new one. */
|
|
60
|
+
export function appendToAssistant(prev: ChatMessage[], block: MessageBlock): ChatMessage[] {
|
|
61
|
+
const last = prev[prev.length - 1];
|
|
62
|
+
if (last?.role === 'assistant') {
|
|
63
|
+
if (block.type === 'text' || block.type === 'thinking') {
|
|
64
|
+
const blocks = [...last.blocks];
|
|
65
|
+
const lastBlock = blocks[blocks.length - 1];
|
|
66
|
+
if (lastBlock?.type === block.type) {
|
|
67
|
+
blocks[blocks.length - 1] = { ...lastBlock, text: (lastBlock as { text: string }).text + block.text };
|
|
68
|
+
return [...prev.slice(0, -1), { ...last, blocks }];
|
|
69
|
+
}
|
|
70
|
+
return [...prev.slice(0, -1), { ...last, blocks: [...blocks, block] }];
|
|
71
|
+
}
|
|
72
|
+
return [...prev.slice(0, -1), { ...last, blocks: [...last.blocks, block] }];
|
|
73
|
+
}
|
|
74
|
+
return [...prev, { id: randomUUID(), role: 'assistant', blocks: [block] }];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function appendErrorToAssistant(prev: ChatMessage[], text: string): ChatMessage[] {
|
|
78
|
+
const last = prev[prev.length - 1];
|
|
79
|
+
const block: MessageBlock = { type: 'text', text };
|
|
80
|
+
if (last?.role === 'assistant') {
|
|
81
|
+
return [...prev.slice(0, -1), { ...last, blocks: [...last.blocks, block] }];
|
|
82
|
+
}
|
|
83
|
+
return [...prev, { id: randomUUID(), role: 'assistant', blocks: [block] }];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Update an existing tool_use block's input (for streamed tool calls where input arrives later). */
|
|
87
|
+
export function updateToolUseInput(prev: ChatMessage[], toolUseId: string, input: unknown): ChatMessage[] {
|
|
88
|
+
for (let i = prev.length - 1; i >= 0; i--) {
|
|
89
|
+
const msg = prev[i];
|
|
90
|
+
if (msg.role !== 'assistant') continue;
|
|
91
|
+
const idx = msg.blocks.findIndex((b) => b.type === 'tool_use' && b.toolUseId === toolUseId);
|
|
92
|
+
if (idx === -1) continue;
|
|
93
|
+
const blocks = [...msg.blocks];
|
|
94
|
+
blocks[idx] = { ...blocks[idx], input } as MessageBlock;
|
|
95
|
+
return [...prev.slice(0, i), { ...msg, blocks }, ...prev.slice(i + 1)];
|
|
96
|
+
}
|
|
97
|
+
return prev;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ConversationOptions {
|
|
101
|
+
getToken: () => Promise<string | null>;
|
|
102
|
+
/** Session this conversation is bound to (undefined = a fresh "new chat" until its first reply). */
|
|
103
|
+
initialSessionId?: string;
|
|
104
|
+
/** Fired when the server assigns/changes this conversation's session id (new chat, fork, turn done). */
|
|
105
|
+
onNewSession?: (id: string) => void;
|
|
106
|
+
/** Per-turn directives event (model/turns/thinking/engine). */
|
|
107
|
+
onDirectivesChanged?: (directives: { model?: string; turns?: number; thinking?: string; engine?: string }) => void;
|
|
108
|
+
/** Participant count changed after a server-side refetch (multi-user sessions). */
|
|
109
|
+
onParticipantsChanged?: (multi: boolean) => void;
|
|
110
|
+
/** Raw passthrough of every socket event (pane filters by its own session: artifacts, model_resolved, title). */
|
|
111
|
+
onEvent?: (event: ServerEvent) => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Per-conversation state + event handling over a SHARED {@link AgentSocket}.
|
|
116
|
+
* Multiple instances coexist (one per open tab/pane); each filters socket events to
|
|
117
|
+
* its own `sessionId`, so several conversations stream live concurrently. Socket lifecycle,
|
|
118
|
+
* connection status, and cross-session/global events live in `useAgentSocket`.
|
|
119
|
+
*/
|
|
120
|
+
export function useConversation(socket: AgentSocket | null, opts: ConversationOptions): ConversationState {
|
|
121
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
122
|
+
const [busy, setBusy] = useState(false);
|
|
123
|
+
const [queue, setQueue] = useState<string[]>([]);
|
|
124
|
+
|
|
125
|
+
const sessionIdRef = useRef<string | undefined>(opts.initialSessionId);
|
|
126
|
+
// True between this pane's send and the turn's done/error — scopes untagged events (session_id,
|
|
127
|
+
// directives) to the pane that actually started the turn over the shared socket.
|
|
128
|
+
const expectingTurnRef = useRef(false);
|
|
129
|
+
const queueRef = useRef(queue);
|
|
130
|
+
queueRef.current = queue;
|
|
131
|
+
const getTokenRef = useRef(opts.getToken);
|
|
132
|
+
getTokenRef.current = opts.getToken;
|
|
133
|
+
const onNewSessionRef = useRef(opts.onNewSession);
|
|
134
|
+
onNewSessionRef.current = opts.onNewSession;
|
|
135
|
+
const onDirectivesChangedRef = useRef(opts.onDirectivesChanged);
|
|
136
|
+
onDirectivesChangedRef.current = opts.onDirectivesChanged;
|
|
137
|
+
const onParticipantsChangedRef = useRef(opts.onParticipantsChanged);
|
|
138
|
+
onParticipantsChangedRef.current = opts.onParticipantsChanged;
|
|
139
|
+
const onEventRef = useRef(opts.onEvent);
|
|
140
|
+
onEventRef.current = opts.onEvent;
|
|
141
|
+
const socketRef = useRef(socket);
|
|
142
|
+
socketRef.current = socket;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* True only when an incoming event is explicitly tagged for THIS conversation. Untagged content is
|
|
146
|
+
* never "mine" — over one shared socket that would make every pane duplicate it. (Untagged
|
|
147
|
+
* session_id/directives are routed separately via expectingTurnRef, not this check.)
|
|
148
|
+
*/
|
|
149
|
+
const isMine = useCallback((sid: string | undefined) => sid != null && sid === sessionIdRef.current, []);
|
|
150
|
+
|
|
151
|
+
/** Refetch authoritative history for this session (background catch-up / new server-side messages). */
|
|
152
|
+
const refetch = useCallback(async () => {
|
|
153
|
+
const sid = sessionIdRef.current;
|
|
154
|
+
if (!sid) return;
|
|
155
|
+
try {
|
|
156
|
+
const res = await apiFetch(`/api/sessions/${sid}/messages`, getTokenRef.current);
|
|
157
|
+
const data = await res.json();
|
|
158
|
+
setMessages(historyToMessages(data));
|
|
159
|
+
setBusy(!!data.busy);
|
|
160
|
+
onParticipantsChangedRef.current?.(Array.isArray(data.participants) && data.participants.length > 1);
|
|
161
|
+
} catch {
|
|
162
|
+
/* transient — leave current view in place */
|
|
163
|
+
}
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!socket) return;
|
|
168
|
+
const off = socket.on((event: ServerEvent) => {
|
|
169
|
+
onEventRef.current?.(event);
|
|
170
|
+
|
|
171
|
+
// Add-on socket events (not in the typed ServerEvent union) are handled by an add-on's
|
|
172
|
+
// conversationController, which subscribes to the same socket — the core ignores them here.
|
|
173
|
+
|
|
174
|
+
// `compact_marker` isn't in the typed ServerEvent union — guard before the switch.
|
|
175
|
+
const raw = event as unknown as { type: string; sessionId?: string; summary?: string; compactedCount?: number };
|
|
176
|
+
if (raw.type === 'compact_marker') {
|
|
177
|
+
if (!isMine(raw.sessionId)) return;
|
|
178
|
+
return setMessages((prev) => [
|
|
179
|
+
...prev,
|
|
180
|
+
{
|
|
181
|
+
id: randomUUID(),
|
|
182
|
+
role: 'assistant' as const,
|
|
183
|
+
blocks: [{ type: 'compact_marker' as const, summary: raw.summary ?? '', compactedCount: raw.compactedCount ?? 0 }],
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
switch (event.type) {
|
|
189
|
+
case 'thinking_delta':
|
|
190
|
+
if (!isMine(event.sessionId)) return;
|
|
191
|
+
return setMessages((prev) => appendToAssistant(prev, { type: 'thinking', text: event.text }));
|
|
192
|
+
case 'text_delta':
|
|
193
|
+
if (!isMine(event.sessionId)) return;
|
|
194
|
+
return setMessages((prev) => appendToAssistant(prev, { type: 'text', text: event.text }));
|
|
195
|
+
case 'tool_use':
|
|
196
|
+
if (!isMine(event.sessionId)) return;
|
|
197
|
+
return setMessages((prev) =>
|
|
198
|
+
appendToAssistant(prev, { type: 'tool_use', tool: event.tool, toolUseId: event.toolUseId, input: event.input }),
|
|
199
|
+
);
|
|
200
|
+
case 'tool_use_input':
|
|
201
|
+
if (!isMine(event.sessionId)) return;
|
|
202
|
+
return setMessages((prev) => updateToolUseInput(prev, event.toolUseId, event.input));
|
|
203
|
+
case 'tool_result':
|
|
204
|
+
if (!isMine(event.sessionId)) return;
|
|
205
|
+
return setMessages((prev) =>
|
|
206
|
+
appendToAssistant(prev, { type: 'tool_result', toolUseId: event.toolUseId, output: event.output }),
|
|
207
|
+
);
|
|
208
|
+
case 'tool_result_image':
|
|
209
|
+
if (!isMine(event.sessionId)) return;
|
|
210
|
+
return setMessages((prev) => appendToAssistant(prev, { type: 'image', src: event.dataUrl }));
|
|
211
|
+
case 'permission_request':
|
|
212
|
+
if (!isMine(event.sessionId)) return;
|
|
213
|
+
return setMessages((prev) =>
|
|
214
|
+
appendToAssistant(prev, { type: 'permission_request', id: event.id, tool: event.tool, input: event.input }),
|
|
215
|
+
);
|
|
216
|
+
case 'question_request':
|
|
217
|
+
if (!isMine(event.sessionId)) return;
|
|
218
|
+
return setMessages((prev) =>
|
|
219
|
+
appendToAssistant(prev, { type: 'question_request', id: event.id, questions: event.questions }),
|
|
220
|
+
);
|
|
221
|
+
case 'directives':
|
|
222
|
+
// Untagged — applies to whichever pane just started a turn (one shared socket fans this to
|
|
223
|
+
// every pane). Gate on expectingTurn so an idle pane doesn't adopt another's directives.
|
|
224
|
+
if (!expectingTurnRef.current) return;
|
|
225
|
+
return onDirectivesChangedRef.current?.(event.directives);
|
|
226
|
+
case 'session_id':
|
|
227
|
+
// Untagged. Only the pane awaiting a fresh session (just sent with no id) may adopt it —
|
|
228
|
+
// otherwise every open pane would hijack this id off the shared socket.
|
|
229
|
+
if (!expectingTurnRef.current || sessionIdRef.current) return;
|
|
230
|
+
sessionIdRef.current = event.sessionId;
|
|
231
|
+
return onNewSessionRef.current?.(event.sessionId);
|
|
232
|
+
case 'forked':
|
|
233
|
+
// Server forked a busy session on send — only the pane that owned the source adopts it.
|
|
234
|
+
if (event.sourceSessionId !== sessionIdRef.current) return;
|
|
235
|
+
sessionIdRef.current = event.sessionId;
|
|
236
|
+
return onNewSessionRef.current?.(event.sessionId);
|
|
237
|
+
case 'session_busy':
|
|
238
|
+
if (event.sessionId === sessionIdRef.current) setBusy(event.busy);
|
|
239
|
+
return;
|
|
240
|
+
case 'session_stream':
|
|
241
|
+
// Live deltas for a turn initiated by ANOTHER socket/device viewing the same session.
|
|
242
|
+
if (event.sessionId !== sessionIdRef.current) return;
|
|
243
|
+
return applyStream(setMessages, event.event);
|
|
244
|
+
case 'session_messages_changed':
|
|
245
|
+
if (event.sessionId === sessionIdRef.current) void refetch();
|
|
246
|
+
return;
|
|
247
|
+
case 'done': {
|
|
248
|
+
if (event.sessionId !== sessionIdRef.current) return;
|
|
249
|
+
if (event.stopReason === 'max_turns_reached') {
|
|
250
|
+
setMessages((prev) =>
|
|
251
|
+
appendToAssistant(prev, {
|
|
252
|
+
type: 'text',
|
|
253
|
+
text: '\n\n---\n⚠️ Reached the maximum number of steps for this turn. Send "continue" to pick up where I left off.',
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
onNewSessionRef.current?.(event.sessionId);
|
|
258
|
+
const q = queueRef.current;
|
|
259
|
+
if (q.length > 0) {
|
|
260
|
+
const combined = q.join('\n\n');
|
|
261
|
+
setQueue([]);
|
|
262
|
+
setMessages((prev) => prev.concat({ id: randomUUID(), role: 'user', blocks: [{ type: 'text', text: combined }] }));
|
|
263
|
+
socketRef.current?.send({ type: 'message', text: combined, sessionId: event.sessionId });
|
|
264
|
+
return; // queue flush keeps the turn alive — stay "expecting".
|
|
265
|
+
}
|
|
266
|
+
expectingTurnRef.current = false;
|
|
267
|
+
return setBusy(false);
|
|
268
|
+
}
|
|
269
|
+
case 'error':
|
|
270
|
+
if (!isMine(event.sessionId)) return;
|
|
271
|
+
expectingTurnRef.current = false;
|
|
272
|
+
setBusy(false);
|
|
273
|
+
setQueue([]);
|
|
274
|
+
return setMessages((prev) => appendErrorToAssistant(prev, `⚠️ Error: ${event.message}`));
|
|
275
|
+
case 'disconnected':
|
|
276
|
+
// Any in-flight turn is interrupted on socket loss.
|
|
277
|
+
return setBusy(false);
|
|
278
|
+
case 'reconnected':
|
|
279
|
+
// Catch up on anything streamed while the socket was down.
|
|
280
|
+
if (sessionIdRef.current) void refetch();
|
|
281
|
+
return;
|
|
282
|
+
default:
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
return () => {
|
|
287
|
+
off();
|
|
288
|
+
};
|
|
289
|
+
}, [socket, isMine, refetch]);
|
|
290
|
+
|
|
291
|
+
const sendMessage = useCallback(
|
|
292
|
+
(text: string, sessionId?: string, attachments?: Attachment[], truncateAt?: number, sendOptions?: Record<string, unknown>, echo = true) => {
|
|
293
|
+
if (busy || !socketRef.current) return;
|
|
294
|
+
if (sessionId) sessionIdRef.current = sessionId;
|
|
295
|
+
expectingTurnRef.current = true; // claim this turn's untagged session_id/directives.
|
|
296
|
+
setBusy(true);
|
|
297
|
+
const attBlocks: MessageBlock[] = (attachments ?? []).map((a) =>
|
|
298
|
+
a.mimeType.startsWith('image/')
|
|
299
|
+
? { type: 'image' as const, src: a.url }
|
|
300
|
+
: { type: 'file' as const, src: a.url, name: a.name, mimeType: a.mimeType },
|
|
301
|
+
);
|
|
302
|
+
if (echo) {
|
|
303
|
+
setMessages((prev) => prev.concat({ id: randomUUID(), role: 'user', blocks: [...attBlocks, { type: 'text', text }] }));
|
|
304
|
+
}
|
|
305
|
+
// `sendOptions` is an opaque add-on bag (never interpreted here) spread onto the wire message.
|
|
306
|
+
const sent = socketRef.current.sendOrQueue({ type: 'message', text, sessionId, attachments, truncateAt, ...sendOptions });
|
|
307
|
+
if (!sent) {
|
|
308
|
+
setMessages((prev) => appendErrorToAssistant(prev, '⚠️ Reconnecting — your message will be sent automatically.'));
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
[busy],
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const steer = useCallback(
|
|
315
|
+
(text: string) => {
|
|
316
|
+
if (!busy || !socketRef.current) return;
|
|
317
|
+
setMessages((prev) => prev.concat({ id: randomUUID(), role: 'user', blocks: [{ type: 'text', text: `↩ ${text}` }] }));
|
|
318
|
+
socketRef.current.steer(text, sessionIdRef.current);
|
|
319
|
+
},
|
|
320
|
+
[busy],
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
const addToQueue = useCallback((text: string) => setQueue((q) => [...q, text]), []);
|
|
324
|
+
const removeFromQueue = useCallback((index: number) => setQueue((q) => q.filter((_, i) => i !== index)), []);
|
|
325
|
+
|
|
326
|
+
const respondPermission = useCallback((id: string, allow: boolean, allowAll?: boolean) => {
|
|
327
|
+
socketRef.current?.respondPermission(id, allow, allowAll);
|
|
328
|
+
setMessages((prev) => removeRequestBlock(prev, 'permission_request', id));
|
|
329
|
+
}, []);
|
|
330
|
+
|
|
331
|
+
const respondQuestion = useCallback((id: string, answers: QuestionAnswers) => {
|
|
332
|
+
socketRef.current?.respondQuestion(id, answers);
|
|
333
|
+
setMessages((prev) => removeRequestBlock(prev, 'question_request', id));
|
|
334
|
+
}, []);
|
|
335
|
+
|
|
336
|
+
const cancel = useCallback(() => {
|
|
337
|
+
socketRef.current?.cancel(sessionIdRef.current);
|
|
338
|
+
expectingTurnRef.current = false;
|
|
339
|
+
setBusy(false);
|
|
340
|
+
setQueue([]);
|
|
341
|
+
}, []);
|
|
342
|
+
|
|
343
|
+
const reset = useCallback(() => {
|
|
344
|
+
setMessages([]);
|
|
345
|
+
setBusy(false);
|
|
346
|
+
setQueue([]);
|
|
347
|
+
sessionIdRef.current = undefined;
|
|
348
|
+
expectingTurnRef.current = false;
|
|
349
|
+
}, []);
|
|
350
|
+
|
|
351
|
+
const setSessionId = useCallback((id: string | undefined) => {
|
|
352
|
+
sessionIdRef.current = id;
|
|
353
|
+
}, []);
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
messages,
|
|
357
|
+
setMessages,
|
|
358
|
+
busy,
|
|
359
|
+
setBusy,
|
|
360
|
+
sendMessage,
|
|
361
|
+
steer,
|
|
362
|
+
queue,
|
|
363
|
+
addToQueue,
|
|
364
|
+
removeFromQueue,
|
|
365
|
+
respondPermission,
|
|
366
|
+
respondQuestion,
|
|
367
|
+
cancel,
|
|
368
|
+
reset,
|
|
369
|
+
setSessionId,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
type StreamEvent = Extract<ServerEvent, { type: 'session_stream' }>['event'];
|
|
374
|
+
|
|
375
|
+
/** Apply a `session_stream`-wrapped delta (same shapes as top-level deltas). */
|
|
376
|
+
function applyStream(setMessages: Dispatch<SetStateAction<ChatMessage[]>>, event: StreamEvent) {
|
|
377
|
+
switch (event.type) {
|
|
378
|
+
case 'thinking_delta':
|
|
379
|
+
return setMessages((prev) => appendToAssistant(prev, { type: 'thinking', text: event.text }));
|
|
380
|
+
case 'text_delta':
|
|
381
|
+
return setMessages((prev) => appendToAssistant(prev, { type: 'text', text: event.text }));
|
|
382
|
+
case 'tool_use':
|
|
383
|
+
return setMessages((prev) =>
|
|
384
|
+
appendToAssistant(prev, { type: 'tool_use', tool: event.tool, toolUseId: event.toolUseId, input: event.input }),
|
|
385
|
+
);
|
|
386
|
+
case 'tool_use_input':
|
|
387
|
+
return setMessages((prev) => updateToolUseInput(prev, event.toolUseId, event.input));
|
|
388
|
+
case 'tool_result':
|
|
389
|
+
return setMessages((prev) =>
|
|
390
|
+
appendToAssistant(prev, { type: 'tool_result', toolUseId: event.toolUseId, output: event.output }),
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Remove a pending permission/question request block once answered. */
|
|
396
|
+
function removeRequestBlock(
|
|
397
|
+
prev: ChatMessage[],
|
|
398
|
+
blockType: 'permission_request' | 'question_request',
|
|
399
|
+
id: string,
|
|
400
|
+
): ChatMessage[] {
|
|
401
|
+
const updated = [...prev];
|
|
402
|
+
for (let i = updated.length - 1; i >= 0; i--) {
|
|
403
|
+
const msg = updated[i];
|
|
404
|
+
if (msg.role !== 'assistant') continue;
|
|
405
|
+
const blockIdx = msg.blocks.findIndex((b) => b.type === blockType && (b as { id: string }).id === id);
|
|
406
|
+
if (blockIdx !== -1) {
|
|
407
|
+
updated[i] = { ...msg, blocks: msg.blocks.filter((_, j) => j !== blockIdx) };
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return updated;
|
|
412
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
// Shared, reactive dark-mode store. A module-level singleton (not per-hook useState) so EVERY
|
|
4
|
+
// consumer — App's toggle, ChatView — observes the same value and re-renders together.
|
|
5
|
+
// Without this, toggling in one component left the others stale.
|
|
6
|
+
function read(): boolean {
|
|
7
|
+
const stored = localStorage.getItem('dark-mode');
|
|
8
|
+
if (stored !== null) return stored === 'true';
|
|
9
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let dark = typeof window !== 'undefined' ? read() : true;
|
|
13
|
+
const listeners = new Set<() => void>();
|
|
14
|
+
|
|
15
|
+
function apply() {
|
|
16
|
+
document.documentElement.classList.toggle('dark', dark);
|
|
17
|
+
localStorage.setItem('dark-mode', String(dark));
|
|
18
|
+
listeners.forEach((l) => l());
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Apply once at module load so the class reflects the initial value before any toggle.
|
|
22
|
+
if (typeof window !== 'undefined') apply();
|
|
23
|
+
|
|
24
|
+
function setDark(next: boolean) {
|
|
25
|
+
if (next === dark) return;
|
|
26
|
+
dark = next;
|
|
27
|
+
apply();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function subscribe(cb: () => void) {
|
|
31
|
+
listeners.add(cb);
|
|
32
|
+
// Cross-tab sync: another tab toggling dark-mode writes localStorage.
|
|
33
|
+
const onStorage = (e: StorageEvent) => {
|
|
34
|
+
if (e.key === 'dark-mode') setDark(read());
|
|
35
|
+
};
|
|
36
|
+
window.addEventListener('storage', onStorage);
|
|
37
|
+
return () => {
|
|
38
|
+
listeners.delete(cb);
|
|
39
|
+
window.removeEventListener('storage', onStorage);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Non-reactive read of the current mode — for imperative call sites (e.g. an add-on spawning a themed widget) that need
|
|
44
|
+
* the value once, outside React. */
|
|
45
|
+
export function isDarkMode(): boolean {
|
|
46
|
+
return dark;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Imperative toggle — for non-React call sites (e.g. the global keyboard shortcut). */
|
|
50
|
+
export function toggleDarkMode() {
|
|
51
|
+
setDark(!dark);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function useDarkMode() {
|
|
55
|
+
const value = useSyncExternalStore(subscribe, () => dark, () => dark);
|
|
56
|
+
return { dark: value, toggle: () => setDark(!dark) };
|
|
57
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/** Tailwind `sm` breakpoint — below this we present tabs only (no split panes). */
|
|
4
|
+
const MOBILE_MAX = 639;
|
|
5
|
+
|
|
6
|
+
// Narrow viewport OR a touch phone held in landscape (short side under the breakpoint). The second
|
|
7
|
+
// clause keeps a phone classified as "mobile" in BOTH orientations, so rotating it never crosses the
|
|
8
|
+
// breakpoint — which would rebuild the workspace model, remount panes, and drop any live pane session.
|
|
9
|
+
const MOBILE_QUERY = `(max-width: ${MOBILE_MAX}px), (pointer: coarse) and (max-height: ${MOBILE_MAX}px)`;
|
|
10
|
+
|
|
11
|
+
/** True on narrow viewports and touch phones (either orientation). Reacts to resize/rotate. */
|
|
12
|
+
export function useIsMobile(): boolean {
|
|
13
|
+
const [isMobile, setIsMobile] = useState(() => window.matchMedia(MOBILE_QUERY).matches);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const mql = window.matchMedia(MOBILE_QUERY);
|
|
17
|
+
const onChange = () => setIsMobile(mql.matches);
|
|
18
|
+
mql.addEventListener('change', onChange);
|
|
19
|
+
return () => mql.removeEventListener('change', onChange);
|
|
20
|
+
}, []);
|
|
21
|
+
|
|
22
|
+
return isMobile;
|
|
23
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { initNative, push, isVisible, onVisibilityChange } from '@/lib/native';
|
|
3
|
+
import type { PushMessage } from '@/lib/native';
|
|
4
|
+
import type { AgentSocket } from '@/lib/ws';
|
|
5
|
+
import { sameOrigin } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
/** Where a notification tap should take the user. Pure + exported so it's unit-verifiable. */
|
|
8
|
+
export type TapNav =
|
|
9
|
+
| { action: 'switch'; url: string; session?: string } // different instance → full navigation
|
|
10
|
+
| { action: 'session'; session: string } // same instance → open the session tab
|
|
11
|
+
| { action: 'none' };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Decide tap routing from the push payload (`m.data = {instance, session, kind}`) relative to
|
|
15
|
+
* where we're running. A foreign `instance` wins (navigate there, carrying the session); same
|
|
16
|
+
* instance just opens the session; nothing actionable → 'none'.
|
|
17
|
+
*/
|
|
18
|
+
export function computeTapNav(currentOrigin: string, data: Record<string, unknown>): TapNav {
|
|
19
|
+
const instance = typeof data.instance === 'string' ? data.instance : undefined;
|
|
20
|
+
const session = typeof data.session === 'string' ? data.session : undefined;
|
|
21
|
+
if (instance && !sameOrigin(instance, currentOrigin)) {
|
|
22
|
+
return { action: 'switch', url: instance, session };
|
|
23
|
+
}
|
|
24
|
+
if (session) return { action: 'session', session };
|
|
25
|
+
return { action: 'none' };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface UsePushOpts {
|
|
29
|
+
socket: AgentSocket | null;
|
|
30
|
+
getToken: () => Promise<string | null>;
|
|
31
|
+
pushEnabled: boolean;
|
|
32
|
+
activeSessionId: string | undefined;
|
|
33
|
+
/** Navigate to another instance (full reload), optionally deep-linking a session. */
|
|
34
|
+
switchTo: (url: string, session?: string) => void;
|
|
35
|
+
/** Open a session tab on THIS instance. */
|
|
36
|
+
openSession: (sessionId: string) => void;
|
|
37
|
+
/** Surface a foreground push as an in-app toast (avoids the OS double-notifying). */
|
|
38
|
+
onForegroundMessage?: (m: PushMessage) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Native push lifecycle + client presence. No-ops every push path in a plain browser
|
|
43
|
+
* (`initNative()` resolves false). Presence is emitted regardless of native — the server uses it
|
|
44
|
+
* to suppress a push for the screen the user is actively viewing.
|
|
45
|
+
*/
|
|
46
|
+
export function usePush(opts: UsePushOpts) {
|
|
47
|
+
const { socket, getToken, pushEnabled, activeSessionId, switchTo, openSession, onForegroundMessage } = opts;
|
|
48
|
+
const [isNative, setIsNative] = useState(false);
|
|
49
|
+
|
|
50
|
+
// Keep handler deps in refs so the register/subscribe effect runs once (no resubscribe churn).
|
|
51
|
+
const switchToRef = useRef(switchTo);
|
|
52
|
+
switchToRef.current = switchTo;
|
|
53
|
+
const openSessionRef = useRef(openSession);
|
|
54
|
+
openSessionRef.current = openSession;
|
|
55
|
+
const onMsgRef = useRef(onForegroundMessage);
|
|
56
|
+
onMsgRef.current = onForegroundMessage;
|
|
57
|
+
const getTokenRef = useRef(getToken);
|
|
58
|
+
getTokenRef.current = getToken;
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
initNative().then(setIsNative);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
// POST the device token to THIS instance's backend. Re-callable on boot + future re-registration.
|
|
65
|
+
const registerToken = useCallback(async () => {
|
|
66
|
+
try {
|
|
67
|
+
const { platform, token, topic } = await push.register();
|
|
68
|
+
const auth = await getTokenRef.current();
|
|
69
|
+
const res = await fetch('/api/push/register', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: `Bearer ${auth}` } : {}) },
|
|
72
|
+
body: JSON.stringify({ token, platform, topic }),
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
console.warn('[push] register POST failed', res.status);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
console.log('[push] registered token with', window.location.origin);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
console.warn('[push] token registration failed', err);
|
|
81
|
+
}
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
// Push registration + tap/message subscriptions. Gated on native + feature flag → pure web no-ops.
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!isNative || !pushEnabled || push.capability !== 'native') return;
|
|
87
|
+
let cancelled = false;
|
|
88
|
+
|
|
89
|
+
(async () => {
|
|
90
|
+
const perm = await push.requestPermission().catch((e) => {
|
|
91
|
+
console.warn('[push] requestPermission failed', e);
|
|
92
|
+
return 'denied' as const;
|
|
93
|
+
});
|
|
94
|
+
if (cancelled || perm !== 'granted') return;
|
|
95
|
+
await registerToken();
|
|
96
|
+
})();
|
|
97
|
+
|
|
98
|
+
const offTap = push.onTap((m) => {
|
|
99
|
+
const nav = computeTapNav(window.location.origin, m.data ?? {});
|
|
100
|
+
console.log('[push] tap →', nav.action);
|
|
101
|
+
if (nav.action === 'switch') switchToRef.current(nav.url, nav.session);
|
|
102
|
+
else if (nav.action === 'session') openSessionRef.current(nav.session);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const offMsg = push.onMessage((m) => {
|
|
106
|
+
console.log('[push] foreground message');
|
|
107
|
+
onMsgRef.current?.(m);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return () => {
|
|
111
|
+
cancelled = true;
|
|
112
|
+
offTap();
|
|
113
|
+
offMsg();
|
|
114
|
+
};
|
|
115
|
+
}, [isNative, pushEnabled, registerToken]);
|
|
116
|
+
|
|
117
|
+
// Client presence (Page Visibility + active session) → lets the server skip notifying a screen
|
|
118
|
+
// the user is actively looking at. Runs in every environment; harmless when push is off.
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!socket) return;
|
|
121
|
+
const report = () => socket.sendClientPresence(activeSessionId, isVisible());
|
|
122
|
+
report();
|
|
123
|
+
return onVisibilityChange(report);
|
|
124
|
+
}, [socket, activeSessionId]);
|
|
125
|
+
|
|
126
|
+
return { isNative };
|
|
127
|
+
}
|