stagent 0.9.5 → 0.10.0
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 +5 -42
- package/dist/cli.js +42 -18
- package/docs/.coverage-gaps.json +13 -55
- package/docs/.last-generated +1 -1
- package/docs/features/provider-runtimes.md +4 -0
- package/docs/features/schedules.md +32 -4
- package/docs/features/settings.md +28 -5
- package/docs/features/tables.md +9 -2
- package/docs/features/workflows.md +10 -4
- package/docs/journeys/developer.md +15 -1
- package/docs/journeys/personal-use.md +21 -4
- package/docs/superpowers/plans/2026-04-07-instance-bootstrap.md +1691 -0
- package/docs/superpowers/plans/2026-04-08-schedule-orchestration.md +2983 -0
- package/docs/superpowers/plans/2026-04-11-schedule-maxturns-api-control.md +551 -0
- package/docs/superpowers/plans/2026-04-11-task-create-profile-validation.md +864 -0
- package/docs/superpowers/plans/2026-04-11-task-runtime-stagent-mcp-injection.md +739 -0
- package/docs/superpowers/specs/2026-04-08-chat-sse-resilience-hotfix-design.md +201 -0
- package/docs/superpowers/specs/2026-04-08-schedule-orchestration-design.md +371 -0
- package/docs/superpowers/specs/2026-04-08-swarm-visibility-design.md +213 -0
- package/package.json +3 -2
- package/src/__tests__/instrumentation-smoke.test.ts +15 -0
- package/src/app/analytics/page.tsx +1 -21
- package/src/app/api/chat/conversations/[id]/messages/route.ts +22 -1
- package/src/app/api/diagnostics/chat-streams/route.ts +65 -0
- package/src/app/api/instance/config/route.ts +41 -0
- package/src/app/api/instance/init/route.ts +34 -0
- package/src/app/api/instance/upgrade/check/route.ts +26 -0
- package/src/app/api/instance/upgrade/route.ts +96 -0
- package/src/app/api/instance/upgrade/status/route.ts +35 -0
- package/src/app/api/memory/route.ts +0 -11
- package/src/app/api/notifications/route.ts +4 -2
- package/src/app/api/projects/[id]/route.ts +5 -155
- package/src/app/api/projects/__tests__/delete-project.test.ts +10 -19
- package/src/app/api/schedules/[id]/execute/route.ts +111 -0
- package/src/app/api/schedules/[id]/route.ts +9 -1
- package/src/app/api/schedules/__tests__/execute-route.test.ts +118 -0
- package/src/app/api/schedules/route.ts +3 -12
- package/src/app/api/settings/openai/login/route.ts +22 -0
- package/src/app/api/settings/openai/logout/route.ts +7 -0
- package/src/app/api/settings/openai/route.ts +21 -1
- package/src/app/api/settings/providers/route.ts +35 -8
- package/src/app/api/tables/[id]/enrich/__tests__/route.test.ts +153 -0
- package/src/app/api/tables/[id]/enrich/plan/route.ts +98 -0
- package/src/app/api/tables/[id]/enrich/route.ts +147 -0
- package/src/app/api/tables/[id]/enrich/runs/route.ts +25 -0
- package/src/app/api/tasks/[id]/execute/route.ts +0 -21
- package/src/app/api/workflows/[id]/resume/route.ts +59 -0
- package/src/app/api/workflows/[id]/status/route.ts +22 -8
- package/src/app/api/workspace/context/route.ts +2 -0
- package/src/app/api/workspace/fix-data-dir/route.ts +81 -0
- package/src/app/chat/page.tsx +11 -0
- package/src/app/inbox/page.tsx +12 -5
- package/src/app/layout.tsx +42 -21
- package/src/app/page.tsx +0 -2
- package/src/app/settings/page.tsx +6 -9
- package/src/components/chat/__tests__/chat-session-provider.test.tsx +408 -0
- package/src/components/chat/chat-command-popover.tsx +2 -2
- package/src/components/chat/chat-input.tsx +2 -3
- package/src/components/chat/chat-session-provider.tsx +720 -0
- package/src/components/chat/chat-shell.tsx +92 -401
- package/src/components/instance/__tests__/instance-section.test.tsx +125 -0
- package/src/components/instance/instance-section.tsx +382 -0
- package/src/components/instance/upgrade-badge.tsx +219 -0
- package/src/components/notifications/__tests__/batch-proposal-review.test.tsx +95 -0
- package/src/components/notifications/__tests__/notification-item.test.tsx +106 -0
- package/src/components/notifications/batch-proposal-review.tsx +20 -5
- package/src/components/notifications/inbox-list.tsx +11 -2
- package/src/components/notifications/notification-item.tsx +56 -2
- package/src/components/notifications/pending-approval-host.tsx +56 -37
- package/src/components/schedules/schedule-create-sheet.tsx +19 -1
- package/src/components/schedules/schedule-edit-sheet.tsx +20 -1
- package/src/components/schedules/schedule-form.tsx +31 -0
- package/src/components/settings/__tests__/providers-runtimes-section.test.tsx +149 -0
- package/src/components/settings/auth-method-selector.tsx +19 -4
- package/src/components/settings/auth-status-badge.tsx +28 -3
- package/src/components/settings/openai-chatgpt-auth-control.tsx +278 -0
- package/src/components/settings/openai-runtime-section.tsx +7 -1
- package/src/components/settings/providers-runtimes-section.tsx +138 -19
- package/src/components/shared/app-sidebar.tsx +4 -3
- package/src/components/shared/command-palette.tsx +4 -5
- package/src/components/shared/theme-toggle.tsx +5 -24
- package/src/components/shared/workspace-indicator.tsx +61 -2
- package/src/components/tables/__tests__/table-enrichment-sheet.test.tsx +130 -0
- package/src/components/tables/table-create-sheet.tsx +4 -0
- package/src/components/tables/table-enrichment-runs.tsx +103 -0
- package/src/components/tables/table-enrichment-sheet.tsx +538 -0
- package/src/components/tables/table-spreadsheet.tsx +29 -5
- package/src/components/tables/table-toolbar.tsx +10 -1
- package/src/components/tasks/kanban-board.tsx +1 -0
- package/src/components/tasks/kanban-column.tsx +53 -14
- package/src/components/tasks/task-bento-grid.tsx +19 -0
- package/src/components/tasks/task-card.tsx +26 -3
- package/src/components/tasks/task-chip-bar.tsx +24 -0
- package/src/components/tasks/task-result-renderer.tsx +1 -1
- package/src/components/workflows/delay-step-body.tsx +109 -0
- package/src/components/workflows/hooks/use-workflow-status.ts +50 -0
- package/src/components/workflows/loop-status-view.tsx +1 -1
- package/src/components/workflows/shared/step-result.tsx +78 -0
- package/src/components/workflows/shared/workflow-header.tsx +141 -0
- package/src/components/workflows/shared/workflow-loading-skeleton.tsx +36 -0
- package/src/components/workflows/swarm-dashboard.tsx +2 -15
- package/src/components/workflows/views/loop-pattern-view.tsx +137 -0
- package/src/components/workflows/views/sequence-pattern-view.tsx +511 -0
- package/src/components/workflows/workflow-form-view.tsx +133 -16
- package/src/components/workflows/workflow-status-view.tsx +30 -740
- package/src/instrumentation-node.ts +94 -0
- package/src/instrumentation.ts +4 -48
- package/src/lib/agents/__tests__/claude-agent.test.ts +199 -0
- package/src/lib/agents/__tests__/execution-manager.test.ts +1 -27
- package/src/lib/agents/__tests__/failure-reason.test.ts +68 -0
- package/src/lib/agents/__tests__/learned-context.test.ts +0 -11
- package/src/lib/agents/__tests__/learning-session.test.ts +158 -0
- package/src/lib/agents/__tests__/pattern-extractor.test.ts +48 -0
- package/src/lib/agents/claude-agent.ts +155 -18
- package/src/lib/agents/execution-manager.ts +0 -35
- package/src/lib/agents/learned-context.ts +0 -12
- package/src/lib/agents/learning-session.ts +18 -5
- package/src/lib/agents/profiles/__tests__/registry.test.ts +6 -4
- package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +70 -0
- package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +32 -0
- package/src/lib/agents/runtime/__tests__/openai-codex-auth.test.ts +118 -0
- package/src/lib/agents/runtime/codex-app-server-client.ts +11 -5
- package/src/lib/agents/runtime/openai-codex-auth.ts +389 -0
- package/src/lib/agents/runtime/openai-codex.ts +29 -60
- package/src/lib/agents/runtime/types.ts +8 -0
- package/src/lib/book/chapter-mapping.ts +11 -0
- package/src/lib/book/content.ts +10 -0
- package/src/lib/chat/__tests__/active-streams.test.ts +49 -0
- package/src/lib/chat/__tests__/finalize-safety-net.test.ts +139 -0
- package/src/lib/chat/__tests__/reconcile.test.ts +137 -0
- package/src/lib/chat/__tests__/stream-telemetry.test.ts +151 -0
- package/src/lib/chat/active-streams.ts +27 -0
- package/src/lib/chat/codex-engine.ts +16 -17
- package/src/lib/chat/context-builder.ts +5 -3
- package/src/lib/chat/engine.ts +50 -3
- package/src/lib/chat/reconcile.ts +117 -0
- package/src/lib/chat/stagent-tools.ts +1 -0
- package/src/lib/chat/stream-telemetry.ts +132 -0
- package/src/lib/chat/suggested-prompts.ts +28 -1
- package/src/lib/chat/system-prompt.ts +26 -1
- package/src/lib/chat/tool-catalog.ts +2 -1
- package/src/lib/chat/tools/__tests__/enrich-table-tool.test.ts +127 -0
- package/src/lib/chat/tools/__tests__/schedule-tools.test.ts +261 -0
- package/src/lib/chat/tools/__tests__/task-tools.test.ts +352 -0
- package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +217 -0
- package/src/lib/chat/tools/document-tools.ts +29 -13
- package/src/lib/chat/tools/helpers.ts +39 -0
- package/src/lib/chat/tools/notification-tools.ts +9 -5
- package/src/lib/chat/tools/project-tools.ts +33 -0
- package/src/lib/chat/tools/schedule-tools.ts +44 -11
- package/src/lib/chat/tools/table-tools.ts +71 -0
- package/src/lib/chat/tools/task-tools.ts +84 -20
- package/src/lib/chat/tools/workflow-tools.ts +234 -32
- package/src/lib/constants/settings.ts +8 -18
- package/src/lib/data/__tests__/clear.test.ts +56 -2
- package/src/lib/data/clear.ts +20 -15
- package/src/lib/data/delete-project.ts +171 -0
- package/src/lib/db/__tests__/bootstrap.test.ts +1 -1
- package/src/lib/db/bootstrap.ts +45 -16
- package/src/lib/db/index.ts +5 -0
- package/src/lib/db/migrations/0009_add_app_instances.sql +25 -0
- package/src/lib/db/migrations/0024_add_workflow_resume_at.sql +10 -0
- package/src/lib/db/migrations/0025_drop_app_instances.sql +3 -0
- package/src/lib/db/migrations/0026_drop_license.sql +3 -0
- package/src/lib/db/migrations/meta/_journal.json +21 -0
- package/src/lib/db/schema.ts +68 -23
- package/src/lib/environment/workspace-context.ts +13 -1
- package/src/lib/import/dedup.ts +4 -54
- package/src/lib/instance/__tests__/bootstrap.test.ts +362 -0
- package/src/lib/instance/__tests__/detect.test.ts +115 -0
- package/src/lib/instance/__tests__/fingerprint.test.ts +48 -0
- package/src/lib/instance/__tests__/git-ops.test.ts +95 -0
- package/src/lib/instance/__tests__/settings.test.ts +83 -0
- package/src/lib/instance/__tests__/upgrade-poller.test.ts +131 -0
- package/src/lib/instance/bootstrap.ts +270 -0
- package/src/lib/instance/detect.ts +49 -0
- package/src/lib/instance/fingerprint.ts +78 -0
- package/src/lib/instance/git-ops.ts +95 -0
- package/src/lib/instance/settings.ts +61 -0
- package/src/lib/instance/types.ts +77 -0
- package/src/lib/instance/upgrade-poller.ts +153 -0
- package/src/lib/notifications/__tests__/visibility.test.ts +51 -0
- package/src/lib/notifications/visibility.ts +33 -0
- package/src/lib/schedules/__tests__/collision-check.test.ts +93 -0
- package/src/lib/schedules/__tests__/config.test.ts +62 -0
- package/src/lib/schedules/__tests__/firing-metrics.test.ts +99 -0
- package/src/lib/schedules/__tests__/integration.test.ts +82 -0
- package/src/lib/schedules/__tests__/slot-claim.test.ts +242 -0
- package/src/lib/schedules/__tests__/tick-scheduler.test.ts +102 -0
- package/src/lib/schedules/__tests__/turn-budget.test.ts +228 -0
- package/src/lib/schedules/collision-check.ts +105 -0
- package/src/lib/schedules/config.ts +53 -0
- package/src/lib/schedules/scheduler.ts +232 -13
- package/src/lib/schedules/slot-claim.ts +105 -0
- package/src/lib/settings/__tests__/openai-auth.test.ts +101 -0
- package/src/lib/settings/__tests__/openai-login-manager.test.ts +64 -0
- package/src/lib/settings/__tests__/runtime-setup.test.ts +33 -0
- package/src/lib/settings/openai-auth.ts +105 -10
- package/src/lib/settings/openai-login-manager.ts +260 -0
- package/src/lib/settings/runtime-setup.ts +14 -4
- package/src/lib/tables/__tests__/enrichment-planner.test.ts +124 -0
- package/src/lib/tables/__tests__/enrichment.test.ts +147 -0
- package/src/lib/tables/enrichment-planner.ts +454 -0
- package/src/lib/tables/enrichment.ts +328 -0
- package/src/lib/tables/query-builder.ts +5 -2
- package/src/lib/tables/trigger-evaluator.ts +3 -2
- package/src/lib/theme.ts +71 -0
- package/src/lib/usage/ledger.ts +2 -18
- package/src/lib/util/__tests__/similarity.test.ts +106 -0
- package/src/lib/util/similarity.ts +77 -0
- package/src/lib/utils/format-timestamp.ts +24 -0
- package/src/lib/utils/stagent-paths.ts +12 -0
- package/src/lib/validators/__tests__/blueprint.test.ts +172 -0
- package/src/lib/validators/__tests__/settings.test.ts +10 -0
- package/src/lib/validators/blueprint.ts +70 -9
- package/src/lib/validators/profile.ts +2 -2
- package/src/lib/validators/settings.ts +3 -1
- package/src/lib/workflows/__tests__/delay.test.ts +196 -0
- package/src/lib/workflows/__tests__/engine.test.ts +8 -0
- package/src/lib/workflows/__tests__/loop-executor.test.ts +54 -0
- package/src/lib/workflows/__tests__/post-action.test.ts +108 -0
- package/src/lib/workflows/blueprints/instantiator.ts +22 -1
- package/src/lib/workflows/blueprints/types.ts +10 -2
- package/src/lib/workflows/delay.ts +106 -0
- package/src/lib/workflows/engine.ts +207 -4
- package/src/lib/workflows/loop-executor.ts +349 -24
- package/src/lib/workflows/post-action.ts +91 -0
- package/src/lib/workflows/types.ts +166 -1
- package/src/app/api/license/checkout/route.ts +0 -28
- package/src/app/api/license/portal/route.ts +0 -26
- package/src/app/api/license/route.ts +0 -89
- package/src/app/api/license/usage/route.ts +0 -63
- package/src/app/api/marketplace/browse/route.ts +0 -15
- package/src/app/api/marketplace/import/route.ts +0 -28
- package/src/app/api/marketplace/publish/route.ts +0 -40
- package/src/app/api/onboarding/email/route.ts +0 -53
- package/src/app/api/settings/telemetry/route.ts +0 -14
- package/src/app/api/sync/export/route.ts +0 -54
- package/src/app/api/sync/restore/route.ts +0 -37
- package/src/app/api/sync/sessions/route.ts +0 -24
- package/src/app/auth/callback/route.ts +0 -73
- package/src/app/marketplace/page.tsx +0 -19
- package/src/components/analytics/analytics-gate-card.tsx +0 -101
- package/src/components/marketplace/blueprint-card.tsx +0 -61
- package/src/components/marketplace/marketplace-browser.tsx +0 -131
- package/src/components/onboarding/email-capture-card.tsx +0 -104
- package/src/components/settings/activation-form.tsx +0 -95
- package/src/components/settings/cloud-account-section.tsx +0 -147
- package/src/components/settings/cloud-sync-section.tsx +0 -155
- package/src/components/settings/subscription-section.tsx +0 -410
- package/src/components/settings/telemetry-section.tsx +0 -80
- package/src/components/shared/premium-gate-overlay.tsx +0 -50
- package/src/components/shared/schedule-gate-dialog.tsx +0 -64
- package/src/components/shared/upgrade-banner.tsx +0 -112
- package/src/hooks/use-supabase-auth.ts +0 -79
- package/src/lib/billing/email.ts +0 -54
- package/src/lib/billing/products.ts +0 -80
- package/src/lib/billing/stripe.ts +0 -101
- package/src/lib/cloud/supabase-browser.ts +0 -32
- package/src/lib/cloud/supabase-client.ts +0 -56
- package/src/lib/license/__tests__/features.test.ts +0 -56
- package/src/lib/license/__tests__/key-format.test.ts +0 -88
- package/src/lib/license/__tests__/manager.test.ts +0 -64
- package/src/lib/license/__tests__/tier-limits.test.ts +0 -79
- package/src/lib/license/cloud-validation.ts +0 -60
- package/src/lib/license/features.ts +0 -44
- package/src/lib/license/key-format.ts +0 -101
- package/src/lib/license/limit-check.ts +0 -111
- package/src/lib/license/limit-queries.ts +0 -51
- package/src/lib/license/manager.ts +0 -345
- package/src/lib/license/notifications.ts +0 -59
- package/src/lib/license/tier-limits.ts +0 -71
- package/src/lib/marketplace/marketplace-client.ts +0 -107
- package/src/lib/sync/cloud-sync.ts +0 -235
- package/src/lib/telemetry/conversion-events.ts +0 -71
- package/src/lib/telemetry/queue.ts +0 -122
- package/src/lib/validators/license.ts +0 -33
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChatSessionProvider — layout-level provider that owns chat session state.
|
|
5
|
+
*
|
|
6
|
+
* Why this exists:
|
|
7
|
+
*
|
|
8
|
+
* Before this provider, every piece of chat-domain state (conversations,
|
|
9
|
+
* messagesByConversation, activeId, isStreaming, abortController) lived in
|
|
10
|
+
* local useState hooks inside `ChatShell`. ChatShell is rendered from
|
|
11
|
+
* `src/app/chat/page.tsx`, which is a route-level component — so navigating
|
|
12
|
+
* away from /chat via the sidebar unmounted ChatShell and destroyed all
|
|
13
|
+
* state. In-flight SSE reader loops ran off into the void, partial assistant
|
|
14
|
+
* messages were lost from client memory (though the server-side
|
|
15
|
+
* finalizeStreamingMessage() salvaged them into the DB), and on return to
|
|
16
|
+
* /chat the `handleSelectConversation` catch block would call
|
|
17
|
+
* `setMessages([])`, wiping visible turn history entirely.
|
|
18
|
+
*
|
|
19
|
+
* By hoisting state into a provider rendered from `src/app/layout.tsx`
|
|
20
|
+
* around `<main>{children}</main>`, the provider — and everything it holds —
|
|
21
|
+
* persists across child-route transitions. ChatShell becomes a thin "view"
|
|
22
|
+
* that reads from the provider via `useChatSession()`. The SSE reader loop
|
|
23
|
+
* runs inside the provider callback, so view unmounts no longer touch it.
|
|
24
|
+
*
|
|
25
|
+
* See `features/chat-session-persistence-provider.md` for the full spec.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
createContext,
|
|
30
|
+
useCallback,
|
|
31
|
+
useContext,
|
|
32
|
+
useEffect,
|
|
33
|
+
useMemo,
|
|
34
|
+
useRef,
|
|
35
|
+
useState,
|
|
36
|
+
type ReactNode,
|
|
37
|
+
} from "react";
|
|
38
|
+
import { useRouter } from "next/navigation";
|
|
39
|
+
import { toast } from "sonner";
|
|
40
|
+
import type { ConversationRow, ChatMessageRow } from "@/lib/db/schema";
|
|
41
|
+
import {
|
|
42
|
+
DEFAULT_CHAT_MODEL,
|
|
43
|
+
CHAT_MODELS,
|
|
44
|
+
getRuntimeForModel,
|
|
45
|
+
type ChatModelOption,
|
|
46
|
+
} from "@/lib/chat/types";
|
|
47
|
+
import type { MentionReference } from "@/hooks/use-chat-autocomplete";
|
|
48
|
+
|
|
49
|
+
// ── Types ──────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
interface StreamingState {
|
|
52
|
+
conversationId: string;
|
|
53
|
+
assistantMsgId: string;
|
|
54
|
+
abortController: AbortController;
|
|
55
|
+
startedAt: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface ChatSessionValue {
|
|
59
|
+
// State
|
|
60
|
+
conversations: ConversationRow[];
|
|
61
|
+
activeId: string | null;
|
|
62
|
+
messages: ChatMessageRow[]; // messages for the active conversation
|
|
63
|
+
isStreaming: boolean;
|
|
64
|
+
modelId: string;
|
|
65
|
+
availableModels: ChatModelOption[];
|
|
66
|
+
hydrated: boolean;
|
|
67
|
+
|
|
68
|
+
// Actions
|
|
69
|
+
hydrate: (payload: {
|
|
70
|
+
conversations: ConversationRow[];
|
|
71
|
+
initialActiveId: string | null;
|
|
72
|
+
}) => void;
|
|
73
|
+
setActiveConversation: (id: string | null, opts?: { skipLoad?: boolean }) => void;
|
|
74
|
+
sendMessage: (content: string, mentions?: MentionReference[]) => Promise<void>;
|
|
75
|
+
stopStreaming: () => void;
|
|
76
|
+
createConversation: () => Promise<string | null>;
|
|
77
|
+
deleteConversation: (id: string) => Promise<void>;
|
|
78
|
+
renameConversation: (id: string, title: string) => Promise<void>;
|
|
79
|
+
setMessageStatus: (
|
|
80
|
+
messageId: string,
|
|
81
|
+
status: "pending" | "streaming" | "complete" | "error"
|
|
82
|
+
) => void;
|
|
83
|
+
setModelId: (modelId: string) => Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const ChatSessionContext = createContext<ChatSessionValue | null>(null);
|
|
87
|
+
|
|
88
|
+
// ── Provider ───────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wraps the app and owns all chat session state. Rendered from
|
|
92
|
+
* `src/app/layout.tsx` around `<main>{children}</main>` so it survives
|
|
93
|
+
* sidebar navigation.
|
|
94
|
+
*/
|
|
95
|
+
export function ChatSessionProvider({ children }: { children: ReactNode }) {
|
|
96
|
+
const router = useRouter();
|
|
97
|
+
|
|
98
|
+
// ── State ────────────────────────────────────────────────────────────
|
|
99
|
+
// Keyed by conversation id so multiple conversations can hold messages
|
|
100
|
+
// without clobbering each other.
|
|
101
|
+
const [conversations, setConversations] = useState<ConversationRow[]>([]);
|
|
102
|
+
const [messagesByConversation, setMessagesByConversation] = useState<
|
|
103
|
+
Record<string, ChatMessageRow[]>
|
|
104
|
+
>({});
|
|
105
|
+
const [activeId, setActiveId] = useState<string | null>(null);
|
|
106
|
+
const [streamingState, setStreamingState] = useState<StreamingState | null>(
|
|
107
|
+
null
|
|
108
|
+
);
|
|
109
|
+
const [modelId, setModelIdState] = useState<string>(DEFAULT_CHAT_MODEL);
|
|
110
|
+
const [availableModels, setAvailableModels] =
|
|
111
|
+
useState<ChatModelOption[]>(CHAT_MODELS);
|
|
112
|
+
const [hydrated, setHydrated] = useState(false);
|
|
113
|
+
|
|
114
|
+
// Refs for values read from async callbacks that mustn't see stale state.
|
|
115
|
+
const activeIdRef = useRef<string | null>(null);
|
|
116
|
+
activeIdRef.current = activeId;
|
|
117
|
+
const modelIdRef = useRef<string>(modelId);
|
|
118
|
+
modelIdRef.current = modelId;
|
|
119
|
+
|
|
120
|
+
// ── One-time model + available-models fetch ──────────────────────────
|
|
121
|
+
// Runs once per page load (provider lives in root layout, not /chat page).
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
let cancelled = false;
|
|
124
|
+
fetch("/api/settings/chat")
|
|
125
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
126
|
+
.then((data) => {
|
|
127
|
+
if (!cancelled && data?.defaultModel) {
|
|
128
|
+
setModelIdState(data.defaultModel);
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
.catch(() => {});
|
|
132
|
+
|
|
133
|
+
fetch("/api/chat/models")
|
|
134
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
135
|
+
.then((models) => {
|
|
136
|
+
if (!cancelled && models?.length) {
|
|
137
|
+
setAvailableModels(models);
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
.catch(() => {});
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
cancelled = true;
|
|
144
|
+
};
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
// ── Hydration from server-rendered page ──────────────────────────────
|
|
148
|
+
// ChatShell calls this on mount with the conversations loaded by
|
|
149
|
+
// `src/app/chat/page.tsx`. On first call we populate everything. On
|
|
150
|
+
// subsequent calls (remount after navigation) we only refresh the
|
|
151
|
+
// conversation list — we do NOT clobber in-memory streaming state or
|
|
152
|
+
// messagesByConversation, which may contain a partial assistant message
|
|
153
|
+
// that is still streaming.
|
|
154
|
+
const hydrate = useCallback(
|
|
155
|
+
(payload: {
|
|
156
|
+
conversations: ConversationRow[];
|
|
157
|
+
initialActiveId: string | null;
|
|
158
|
+
}) => {
|
|
159
|
+
setConversations(payload.conversations);
|
|
160
|
+
setHydrated((already) => {
|
|
161
|
+
if (already) return true;
|
|
162
|
+
// First-time hydration: restore active id from URL/prop, then from localStorage.
|
|
163
|
+
let restoredId = payload.initialActiveId;
|
|
164
|
+
if (!restoredId) {
|
|
165
|
+
try {
|
|
166
|
+
restoredId = localStorage.getItem("stagent-active-chat") || null;
|
|
167
|
+
} catch {
|
|
168
|
+
/* localStorage unavailable */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (
|
|
172
|
+
restoredId &&
|
|
173
|
+
payload.conversations.some((c) => c.id === restoredId)
|
|
174
|
+
) {
|
|
175
|
+
setActiveId(restoredId);
|
|
176
|
+
// Fetch messages for the restored conversation. On failure we
|
|
177
|
+
// do NOT clear — we leave messages as-is (empty on first load)
|
|
178
|
+
// and surface a toast.
|
|
179
|
+
void loadMessagesForConversation(restoredId);
|
|
180
|
+
}
|
|
181
|
+
return true;
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
// loadMessagesForConversation is stable via useCallback below
|
|
185
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
186
|
+
[]
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
// ── Message loading ──────────────────────────────────────────────────
|
|
190
|
+
const loadMessagesForConversation = useCallback(
|
|
191
|
+
async (conversationId: string): Promise<void> => {
|
|
192
|
+
try {
|
|
193
|
+
const res = await fetch(
|
|
194
|
+
`/api/chat/conversations/${conversationId}/messages`
|
|
195
|
+
);
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
// IMPORTANT: do NOT clear existing messages on failure. The old
|
|
198
|
+
// ChatShell catch-all was `setMessages([])`, which wiped visible
|
|
199
|
+
// turn history on any fetch hiccup. Preserve what we have and
|
|
200
|
+
// surface a non-blocking toast.
|
|
201
|
+
toast.error("Failed to load conversation messages");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const rows = (await res.json()) as ChatMessageRow[];
|
|
205
|
+
// Clean up stale "streaming" rows from interrupted prior sessions.
|
|
206
|
+
// The server's reconcile sweep handles this as a safety net, but
|
|
207
|
+
// normalize on the client so the UI never shows a permanent spinner.
|
|
208
|
+
const cleaned = rows.map((m) =>
|
|
209
|
+
m.status === "streaming"
|
|
210
|
+
? { ...m, status: "complete" as const }
|
|
211
|
+
: m
|
|
212
|
+
);
|
|
213
|
+
setMessagesByConversation((prev) => ({
|
|
214
|
+
...prev,
|
|
215
|
+
[conversationId]: cleaned,
|
|
216
|
+
}));
|
|
217
|
+
} catch (err) {
|
|
218
|
+
// Network failure — same policy, do NOT clear.
|
|
219
|
+
console.warn(
|
|
220
|
+
"[chat-session] loadMessagesForConversation failed:",
|
|
221
|
+
err
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
[]
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
// ── Conversation selection ───────────────────────────────────────────
|
|
229
|
+
const setActiveConversation = useCallback(
|
|
230
|
+
(id: string | null, opts?: { skipLoad?: boolean }) => {
|
|
231
|
+
setActiveId(id);
|
|
232
|
+
try {
|
|
233
|
+
if (id) localStorage.setItem("stagent-active-chat", id);
|
|
234
|
+
else localStorage.removeItem("stagent-active-chat");
|
|
235
|
+
} catch {
|
|
236
|
+
/* localStorage unavailable */
|
|
237
|
+
}
|
|
238
|
+
// Only update URL when we're on /chat. If the user clicked a
|
|
239
|
+
// conversation from a different route (unlikely today but possible
|
|
240
|
+
// via future deep links), leave their current location alone.
|
|
241
|
+
if (typeof window !== "undefined" && window.location.pathname === "/chat") {
|
|
242
|
+
router.replace(id ? `/chat?c=${id}` : "/chat", { scroll: false });
|
|
243
|
+
}
|
|
244
|
+
if (id && !opts?.skipLoad && !messagesByConversation[id]) {
|
|
245
|
+
void loadMessagesForConversation(id);
|
|
246
|
+
}
|
|
247
|
+
// Also refresh conversation metadata (title, model, etc.) in the
|
|
248
|
+
// background. Failure is non-blocking.
|
|
249
|
+
if (id) {
|
|
250
|
+
fetch(`/api/chat/conversations/${id}`)
|
|
251
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
252
|
+
.then((conv) => {
|
|
253
|
+
if (conv?.modelId) setModelIdState(conv.modelId);
|
|
254
|
+
})
|
|
255
|
+
.catch(() => {});
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
[messagesByConversation, loadMessagesForConversation, router]
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// ── Conversation CRUD ────────────────────────────────────────────────
|
|
262
|
+
const createConversation = useCallback(async (): Promise<string | null> => {
|
|
263
|
+
try {
|
|
264
|
+
const res = await fetch("/api/chat/conversations", {
|
|
265
|
+
method: "POST",
|
|
266
|
+
headers: { "Content-Type": "application/json" },
|
|
267
|
+
body: JSON.stringify({
|
|
268
|
+
runtimeId: getRuntimeForModel(modelIdRef.current),
|
|
269
|
+
modelId: modelIdRef.current,
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) return null;
|
|
273
|
+
const conversation = (await res.json()) as ConversationRow;
|
|
274
|
+
setConversations((prev) => [conversation, ...prev]);
|
|
275
|
+
// Set empty messages BEFORE activating so the conversation has an
|
|
276
|
+
// entry in messagesByConversation. Use skipLoad to prevent
|
|
277
|
+
// setActiveConversation from firing an async loadMessagesForConversation
|
|
278
|
+
// that would race with the optimistic messages added by sendMessage().
|
|
279
|
+
setMessagesByConversation((prev) => ({
|
|
280
|
+
...prev,
|
|
281
|
+
[conversation.id]: [],
|
|
282
|
+
}));
|
|
283
|
+
setActiveConversation(conversation.id, { skipLoad: true });
|
|
284
|
+
return conversation.id;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}, [setActiveConversation]);
|
|
289
|
+
|
|
290
|
+
const deleteConversation = useCallback(
|
|
291
|
+
async (id: string) => {
|
|
292
|
+
try {
|
|
293
|
+
await fetch(`/api/chat/conversations/${id}`, { method: "DELETE" });
|
|
294
|
+
setConversations((prev) => prev.filter((c) => c.id !== id));
|
|
295
|
+
setMessagesByConversation((prev) => {
|
|
296
|
+
const next = { ...prev };
|
|
297
|
+
delete next[id];
|
|
298
|
+
return next;
|
|
299
|
+
});
|
|
300
|
+
if (activeIdRef.current === id) {
|
|
301
|
+
setActiveConversation(null);
|
|
302
|
+
}
|
|
303
|
+
} catch {
|
|
304
|
+
toast.error("Failed to delete conversation");
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
[setActiveConversation]
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const renameConversation = useCallback(async (id: string, title: string) => {
|
|
311
|
+
try {
|
|
312
|
+
const res = await fetch(`/api/chat/conversations/${id}`, {
|
|
313
|
+
method: "PATCH",
|
|
314
|
+
headers: { "Content-Type": "application/json" },
|
|
315
|
+
body: JSON.stringify({ title }),
|
|
316
|
+
});
|
|
317
|
+
if (res.ok) {
|
|
318
|
+
const updated = (await res.json()) as ConversationRow;
|
|
319
|
+
setConversations((prev) =>
|
|
320
|
+
prev.map((c) => (c.id === id ? updated : c))
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
} catch {
|
|
324
|
+
toast.error("Failed to rename conversation");
|
|
325
|
+
}
|
|
326
|
+
}, []);
|
|
327
|
+
|
|
328
|
+
// ── Message status (used by inline permission / question UI) ─────────
|
|
329
|
+
const setMessageStatus = useCallback(
|
|
330
|
+
(
|
|
331
|
+
messageId: string,
|
|
332
|
+
status: "pending" | "streaming" | "complete" | "error"
|
|
333
|
+
) => {
|
|
334
|
+
setMessagesByConversation((prev) => {
|
|
335
|
+
const next: Record<string, ChatMessageRow[]> = {};
|
|
336
|
+
for (const [convId, msgs] of Object.entries(prev)) {
|
|
337
|
+
next[convId] = msgs.map((m) =>
|
|
338
|
+
m.id === messageId ? { ...m, status } : m
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return next;
|
|
342
|
+
});
|
|
343
|
+
},
|
|
344
|
+
[]
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// ── Model selection ──────────────────────────────────────────────────
|
|
348
|
+
const setModelId = useCallback(async (newModelId: string) => {
|
|
349
|
+
setModelIdState(newModelId);
|
|
350
|
+
const currentActive = activeIdRef.current;
|
|
351
|
+
if (currentActive) {
|
|
352
|
+
const newRuntimeId = getRuntimeForModel(newModelId);
|
|
353
|
+
try {
|
|
354
|
+
await fetch(`/api/chat/conversations/${currentActive}`, {
|
|
355
|
+
method: "PATCH",
|
|
356
|
+
headers: { "Content-Type": "application/json" },
|
|
357
|
+
body: JSON.stringify({ modelId: newModelId, runtimeId: newRuntimeId }),
|
|
358
|
+
});
|
|
359
|
+
} catch {
|
|
360
|
+
/* non-fatal */
|
|
361
|
+
}
|
|
362
|
+
setConversations((prev) =>
|
|
363
|
+
prev.map((c) =>
|
|
364
|
+
c.id === currentActive
|
|
365
|
+
? { ...c, modelId: newModelId, runtimeId: newRuntimeId }
|
|
366
|
+
: c
|
|
367
|
+
)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
}, []);
|
|
371
|
+
|
|
372
|
+
// ── Streaming: sendMessage + stopStreaming ──────────────────────────
|
|
373
|
+
// The SSE reader loop runs inside the provider. If the consumer view
|
|
374
|
+
// (ChatShell) unmounts mid-stream, this loop continues — state updates
|
|
375
|
+
// go to the provider, which is still mounted from the root layout.
|
|
376
|
+
const sendMessage = useCallback(
|
|
377
|
+
async (content: string, mentions?: MentionReference[]): Promise<void> => {
|
|
378
|
+
let conversationId = activeIdRef.current;
|
|
379
|
+
|
|
380
|
+
// Create conversation on first message if none active
|
|
381
|
+
if (!conversationId) {
|
|
382
|
+
conversationId = await createConversation();
|
|
383
|
+
if (!conversationId) return;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Optimistic user message
|
|
387
|
+
const userMsg: ChatMessageRow = {
|
|
388
|
+
id: crypto.randomUUID(),
|
|
389
|
+
conversationId,
|
|
390
|
+
role: "user",
|
|
391
|
+
content,
|
|
392
|
+
metadata: null,
|
|
393
|
+
status: "complete",
|
|
394
|
+
createdAt: new Date(),
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Placeholder assistant message
|
|
398
|
+
const assistantMsgId = crypto.randomUUID();
|
|
399
|
+
const assistantMsg: ChatMessageRow = {
|
|
400
|
+
id: assistantMsgId,
|
|
401
|
+
conversationId,
|
|
402
|
+
role: "assistant",
|
|
403
|
+
content: "",
|
|
404
|
+
metadata: null,
|
|
405
|
+
status: "streaming",
|
|
406
|
+
createdAt: new Date(),
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
setMessagesByConversation((prev) => ({
|
|
410
|
+
...prev,
|
|
411
|
+
[conversationId!]: [...(prev[conversationId!] ?? []), userMsg, assistantMsg],
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
const controller = new AbortController();
|
|
415
|
+
const startedAt = Date.now();
|
|
416
|
+
setStreamingState({
|
|
417
|
+
conversationId,
|
|
418
|
+
assistantMsgId,
|
|
419
|
+
abortController: controller,
|
|
420
|
+
startedAt,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Capture conversationId in a local (non-null) binding for callbacks
|
|
424
|
+
const convId = conversationId;
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
const res = await fetch(
|
|
428
|
+
`/api/chat/conversations/${convId}/messages`,
|
|
429
|
+
{
|
|
430
|
+
method: "POST",
|
|
431
|
+
headers: { "Content-Type": "application/json" },
|
|
432
|
+
body: JSON.stringify({ content, mentions }),
|
|
433
|
+
signal: controller.signal,
|
|
434
|
+
}
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
if (!res.ok || !res.body) {
|
|
438
|
+
throw new Error("Failed to send message");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
const reader = res.body.getReader();
|
|
442
|
+
const decoder = new TextDecoder();
|
|
443
|
+
let buffer = "";
|
|
444
|
+
|
|
445
|
+
// Helper: update the single assistant message being streamed,
|
|
446
|
+
// without touching any other conversation's state.
|
|
447
|
+
const updateAssistant = (
|
|
448
|
+
updater: (msg: ChatMessageRow) => ChatMessageRow
|
|
449
|
+
) => {
|
|
450
|
+
setMessagesByConversation((prev) => {
|
|
451
|
+
const msgs = prev[convId] ?? [];
|
|
452
|
+
return {
|
|
453
|
+
...prev,
|
|
454
|
+
[convId]: msgs.map((m) =>
|
|
455
|
+
m.id === assistantMsgId ? updater(m) : m
|
|
456
|
+
),
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const appendMessage = (msg: ChatMessageRow) => {
|
|
462
|
+
setMessagesByConversation((prev) => ({
|
|
463
|
+
...prev,
|
|
464
|
+
[convId]: [...(prev[convId] ?? []), msg],
|
|
465
|
+
}));
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
while (true) {
|
|
469
|
+
const { done, value } = await reader.read();
|
|
470
|
+
if (done) {
|
|
471
|
+
console.info("[chat-stream] client.stream.done", {
|
|
472
|
+
conversationId: convId,
|
|
473
|
+
messageId: assistantMsgId,
|
|
474
|
+
durationMs: Date.now() - startedAt,
|
|
475
|
+
});
|
|
476
|
+
break;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
buffer += decoder.decode(value, { stream: true });
|
|
480
|
+
const lines = buffer.split("\n");
|
|
481
|
+
buffer = lines.pop() ?? "";
|
|
482
|
+
|
|
483
|
+
for (const line of lines) {
|
|
484
|
+
if (!line.startsWith("data: ")) continue;
|
|
485
|
+
const json = line.slice(6);
|
|
486
|
+
try {
|
|
487
|
+
const event = JSON.parse(json);
|
|
488
|
+
if (event.type === "status") {
|
|
489
|
+
updateAssistant((m) => ({
|
|
490
|
+
...m,
|
|
491
|
+
metadata: JSON.stringify({
|
|
492
|
+
statusPhase: event.phase,
|
|
493
|
+
statusMessage: event.message,
|
|
494
|
+
}),
|
|
495
|
+
}));
|
|
496
|
+
} else if (event.type === "delta") {
|
|
497
|
+
updateAssistant((m) => ({
|
|
498
|
+
...m,
|
|
499
|
+
content: m.content + event.content,
|
|
500
|
+
}));
|
|
501
|
+
} else if (event.type === "done") {
|
|
502
|
+
updateAssistant((m) => {
|
|
503
|
+
const existing = m.metadata
|
|
504
|
+
? (() => {
|
|
505
|
+
try {
|
|
506
|
+
return JSON.parse(m.metadata!);
|
|
507
|
+
} catch {
|
|
508
|
+
return {};
|
|
509
|
+
}
|
|
510
|
+
})()
|
|
511
|
+
: {};
|
|
512
|
+
if (event.quickAccess?.length) {
|
|
513
|
+
existing.quickAccess = event.quickAccess;
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
...m,
|
|
517
|
+
id: event.messageId,
|
|
518
|
+
status: "complete",
|
|
519
|
+
metadata: JSON.stringify(existing),
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
// Refresh conversation title from server (auto-generated on
|
|
523
|
+
// first exchange).
|
|
524
|
+
fetch(`/api/chat/conversations/${convId}`)
|
|
525
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
526
|
+
.then((conv) => {
|
|
527
|
+
if (conv) {
|
|
528
|
+
setConversations((prev) =>
|
|
529
|
+
prev.map((c) =>
|
|
530
|
+
c.id === convId
|
|
531
|
+
? { ...c, title: conv.title, updatedAt: new Date() }
|
|
532
|
+
: c
|
|
533
|
+
)
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
})
|
|
537
|
+
.catch(() => {});
|
|
538
|
+
|
|
539
|
+
} else if (
|
|
540
|
+
event.type === "permission_request" ||
|
|
541
|
+
event.type === "question"
|
|
542
|
+
) {
|
|
543
|
+
const systemMsg: ChatMessageRow = {
|
|
544
|
+
id: event.messageId,
|
|
545
|
+
conversationId: convId,
|
|
546
|
+
role: "system",
|
|
547
|
+
content:
|
|
548
|
+
event.type === "permission_request"
|
|
549
|
+
? `Permission required: ${event.toolName}`
|
|
550
|
+
: "Agent has a question",
|
|
551
|
+
metadata: JSON.stringify(
|
|
552
|
+
event.type === "permission_request"
|
|
553
|
+
? {
|
|
554
|
+
type: "permission_request",
|
|
555
|
+
requestId: event.requestId,
|
|
556
|
+
toolName: event.toolName,
|
|
557
|
+
toolInput: event.toolInput,
|
|
558
|
+
}
|
|
559
|
+
: {
|
|
560
|
+
type: "question",
|
|
561
|
+
requestId: event.requestId,
|
|
562
|
+
questions: event.questions,
|
|
563
|
+
}
|
|
564
|
+
),
|
|
565
|
+
status: "pending",
|
|
566
|
+
createdAt: new Date(),
|
|
567
|
+
};
|
|
568
|
+
appendMessage(systemMsg);
|
|
569
|
+
} else if (event.type === "screenshot") {
|
|
570
|
+
updateAssistant((m) => {
|
|
571
|
+
const meta = m.metadata
|
|
572
|
+
? (() => {
|
|
573
|
+
try {
|
|
574
|
+
return JSON.parse(m.metadata!);
|
|
575
|
+
} catch {
|
|
576
|
+
return {};
|
|
577
|
+
}
|
|
578
|
+
})()
|
|
579
|
+
: {};
|
|
580
|
+
const attachments = Array.isArray(meta.attachments)
|
|
581
|
+
? meta.attachments
|
|
582
|
+
: [];
|
|
583
|
+
attachments.push({
|
|
584
|
+
documentId: event.documentId,
|
|
585
|
+
thumbnailUrl: event.thumbnailUrl,
|
|
586
|
+
originalUrl: event.originalUrl,
|
|
587
|
+
width: event.width,
|
|
588
|
+
height: event.height,
|
|
589
|
+
});
|
|
590
|
+
return {
|
|
591
|
+
...m,
|
|
592
|
+
metadata: JSON.stringify({ ...meta, attachments }),
|
|
593
|
+
};
|
|
594
|
+
});
|
|
595
|
+
} else if (event.type === "error") {
|
|
596
|
+
updateAssistant((m) => ({
|
|
597
|
+
...m,
|
|
598
|
+
content: m.content || event.message,
|
|
599
|
+
status: "error",
|
|
600
|
+
}));
|
|
601
|
+
}
|
|
602
|
+
} catch {
|
|
603
|
+
// Ignore malformed SSE data
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
} catch (error) {
|
|
608
|
+
const isAbort = (error as Error).name === "AbortError";
|
|
609
|
+
if (isAbort) {
|
|
610
|
+
console.info("[chat-stream] client.stream.user-abort", {
|
|
611
|
+
conversationId: convId,
|
|
612
|
+
messageId: assistantMsgId,
|
|
613
|
+
durationMs: Date.now() - startedAt,
|
|
614
|
+
});
|
|
615
|
+
} else {
|
|
616
|
+
console.info("[chat-stream] client.stream.reader-error", {
|
|
617
|
+
conversationId: convId,
|
|
618
|
+
messageId: assistantMsgId,
|
|
619
|
+
durationMs: Date.now() - startedAt,
|
|
620
|
+
error: (error as Error).message,
|
|
621
|
+
});
|
|
622
|
+
setMessagesByConversation((prev) => {
|
|
623
|
+
const msgs = prev[convId] ?? [];
|
|
624
|
+
return {
|
|
625
|
+
...prev,
|
|
626
|
+
[convId]: msgs.map((m) =>
|
|
627
|
+
m.id === assistantMsgId
|
|
628
|
+
? {
|
|
629
|
+
...m,
|
|
630
|
+
content:
|
|
631
|
+
m.content || "Failed to get response. Please try again.",
|
|
632
|
+
status: "error",
|
|
633
|
+
}
|
|
634
|
+
: m
|
|
635
|
+
),
|
|
636
|
+
};
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
} finally {
|
|
640
|
+
setStreamingState(null);
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
[createConversation]
|
|
644
|
+
);
|
|
645
|
+
|
|
646
|
+
const stopStreaming = useCallback(() => {
|
|
647
|
+
setStreamingState((current) => {
|
|
648
|
+
current?.abortController.abort();
|
|
649
|
+
return current;
|
|
650
|
+
});
|
|
651
|
+
}, []);
|
|
652
|
+
|
|
653
|
+
// ── Derived: messages for the active conversation ───────────────────
|
|
654
|
+
const messages = useMemo<ChatMessageRow[]>(
|
|
655
|
+
() => (activeId ? messagesByConversation[activeId] ?? [] : []),
|
|
656
|
+
[activeId, messagesByConversation]
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
const isStreaming = streamingState !== null;
|
|
660
|
+
|
|
661
|
+
const value = useMemo<ChatSessionValue>(
|
|
662
|
+
() => ({
|
|
663
|
+
conversations,
|
|
664
|
+
activeId,
|
|
665
|
+
messages,
|
|
666
|
+
isStreaming,
|
|
667
|
+
modelId,
|
|
668
|
+
availableModels,
|
|
669
|
+
hydrated,
|
|
670
|
+
hydrate,
|
|
671
|
+
setActiveConversation,
|
|
672
|
+
sendMessage,
|
|
673
|
+
stopStreaming,
|
|
674
|
+
createConversation,
|
|
675
|
+
deleteConversation,
|
|
676
|
+
renameConversation,
|
|
677
|
+
setMessageStatus,
|
|
678
|
+
setModelId,
|
|
679
|
+
}),
|
|
680
|
+
[
|
|
681
|
+
conversations,
|
|
682
|
+
activeId,
|
|
683
|
+
messages,
|
|
684
|
+
isStreaming,
|
|
685
|
+
modelId,
|
|
686
|
+
availableModels,
|
|
687
|
+
hydrated,
|
|
688
|
+
hydrate,
|
|
689
|
+
setActiveConversation,
|
|
690
|
+
sendMessage,
|
|
691
|
+
stopStreaming,
|
|
692
|
+
createConversation,
|
|
693
|
+
deleteConversation,
|
|
694
|
+
renameConversation,
|
|
695
|
+
setMessageStatus,
|
|
696
|
+
setModelId,
|
|
697
|
+
]
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
return (
|
|
701
|
+
<ChatSessionContext.Provider value={value}>
|
|
702
|
+
{children}
|
|
703
|
+
</ChatSessionContext.Provider>
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Consume chat session state and actions. Throws if called outside a
|
|
709
|
+
* `ChatSessionProvider` — that is always a bug and we'd rather fail loud
|
|
710
|
+
* than render stale state.
|
|
711
|
+
*/
|
|
712
|
+
export function useChatSession(): ChatSessionValue {
|
|
713
|
+
const ctx = useContext(ChatSessionContext);
|
|
714
|
+
if (!ctx) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
"useChatSession must be used within a ChatSessionProvider"
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
return ctx;
|
|
720
|
+
}
|