@suchitraswain/nightcode-cli 1.0.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/bin/nightcode.cjs +10 -0
- package/bin/nightcode.ts +5 -0
- package/package.json +50 -0
- package/src/bootstrap-env.ts +33 -0
- package/src/components/border.tsx +18 -0
- package/src/components/command-menu/commands.tsx +147 -0
- package/src/components/command-menu/filter-commands.ts +8 -0
- package/src/components/command-menu/index.tsx +74 -0
- package/src/components/command-menu/types.ts +20 -0
- package/src/components/command-menu/use-command-menu.ts +113 -0
- package/src/components/dialog-search-list.tsx +127 -0
- package/src/components/dialogs/agents-dialog.tsx +47 -0
- package/src/components/dialogs/index.tsx +4 -0
- package/src/components/dialogs/models-dialog.tsx +41 -0
- package/src/components/dialogs/sessions-dialog.tsx +94 -0
- package/src/components/dialogs/theme-dialog.tsx +58 -0
- package/src/components/header.tsx +10 -0
- package/src/components/input-bar.tsx +611 -0
- package/src/components/messages/bot-message.tsx +160 -0
- package/src/components/messages/error-message.tsx +36 -0
- package/src/components/messages/index.tsx +3 -0
- package/src/components/messages/user-message.tsx +36 -0
- package/src/components/session-shell.tsx +65 -0
- package/src/components/spinner.tsx +14 -0
- package/src/components/status-bar.tsx +23 -0
- package/src/hooks/use-chat.ts +107 -0
- package/src/hosted-config.ts +6 -0
- package/src/index.tsx +29 -0
- package/src/layouts/root-layout.tsx +25 -0
- package/src/layouts/themed-root.tsx +21 -0
- package/src/lib/api-client.ts +25 -0
- package/src/lib/auth.ts +38 -0
- package/src/lib/http-errors.ts +18 -0
- package/src/lib/local-tools.ts +170 -0
- package/src/lib/oauth.ts +166 -0
- package/src/lib/upgrade.ts +27 -0
- package/src/providers/dialog/index.tsx +123 -0
- package/src/providers/dialog/types.ts +6 -0
- package/src/providers/keyboard-layer/index.tsx +98 -0
- package/src/providers/prompt-config/index.tsx +52 -0
- package/src/providers/theme/index.tsx +75 -0
- package/src/providers/toast/index.tsx +118 -0
- package/src/providers/toast/types.ts +9 -0
- package/src/screens/home.tsx +39 -0
- package/src/screens/new-session.tsx +82 -0
- package/src/screens/session.tsx +171 -0
- package/src/theme.ts +568 -0
- package/vendor/shared/api-types.ts +11 -0
- package/vendor/shared/index.ts +20 -0
- package/vendor/shared/models.ts +72 -0
- package/vendor/shared/schemas.ts +87 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createContext, useContext, useState, useCallback } from "react";
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import type { ThemeColors, Theme } from "../../theme";
|
|
7
|
+
import { DEFAULT_THEME, THEMES } from "../../theme";
|
|
8
|
+
|
|
9
|
+
const CONFIG_DIR = join(homedir(), ".nightcode");
|
|
10
|
+
const THEME_PREFERENCES_PATH = join(CONFIG_DIR, "preferences.json");
|
|
11
|
+
|
|
12
|
+
type ThemePreferences = {
|
|
13
|
+
themeName: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function getInitialTheme(): Theme {
|
|
17
|
+
try {
|
|
18
|
+
const preferences = JSON.parse(
|
|
19
|
+
readFileSync(THEME_PREFERENCES_PATH, "utf8"),
|
|
20
|
+
) as Partial<ThemePreferences>;
|
|
21
|
+
const savedTheme = THEMES.find((theme) => theme.name === preferences.themeName);
|
|
22
|
+
return savedTheme ?? DEFAULT_THEME;
|
|
23
|
+
} catch {
|
|
24
|
+
return DEFAULT_THEME;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function persistTheme(theme: Theme) {
|
|
29
|
+
try {
|
|
30
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
31
|
+
writeFileSync(
|
|
32
|
+
THEME_PREFERENCES_PATH,
|
|
33
|
+
JSON.stringify({ themeName: theme.name } satisfies ThemePreferences, null, 2),
|
|
34
|
+
"utf8",
|
|
35
|
+
);
|
|
36
|
+
} catch {
|
|
37
|
+
// Ignore preference write failures so theme switching still works for this session.
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type ThemeContextValue = {
|
|
42
|
+
colors: ThemeColors;
|
|
43
|
+
currentTheme: Theme;
|
|
44
|
+
setTheme: (theme: Theme) => void;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
48
|
+
|
|
49
|
+
export function useTheme(): ThemeContextValue {
|
|
50
|
+
const value = useContext(ThemeContext);
|
|
51
|
+
if (!value) {
|
|
52
|
+
throw new Error("useTheme must be used within a ThemeProvider");
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
type ThemeProviderProps = {
|
|
58
|
+
children: ReactNode;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export function ThemeProvider({ children }: ThemeProviderProps) {
|
|
62
|
+
const [currentTheme, setCurrentTheme] = useState<Theme>(getInitialTheme);
|
|
63
|
+
|
|
64
|
+
const setTheme = useCallback((theme: Theme) => {
|
|
65
|
+
setCurrentTheme(theme);
|
|
66
|
+
persistTheme(theme);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<ThemeContext.Provider
|
|
71
|
+
value={{ colors: currentTheme.colors, currentTheme, setTheme }}>
|
|
72
|
+
{children}
|
|
73
|
+
</ThemeContext.Provider>
|
|
74
|
+
);
|
|
75
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useCallback,
|
|
7
|
+
useMemo
|
|
8
|
+
} from "react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { useTerminalDimensions } from "@opentui/react";
|
|
11
|
+
import type { ToastOptions, ToastVariant } from "./types";
|
|
12
|
+
import { DEFAULT_DURATION } from "./types";
|
|
13
|
+
import { SplitBorderChars } from "../../components/border";
|
|
14
|
+
import { useTheme } from "../theme";
|
|
15
|
+
|
|
16
|
+
export type ToastContextValue = {
|
|
17
|
+
show: (options: ToastOptions) => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
21
|
+
|
|
22
|
+
export function useToast(): ToastContextValue {
|
|
23
|
+
const value = useContext(ToastContext);
|
|
24
|
+
if (!value) {
|
|
25
|
+
throw new Error("useToast must be used within a ToastProvider");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return value;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type ToastProviderProps = {
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function ToastProvider({ children }: ToastProviderProps) {
|
|
36
|
+
const [currentToast, setCurrentToast] = useState<ToastOptions | null>(null);
|
|
37
|
+
const timeoutHandleRef = useRef<NodeJS.Timeout | null>(null);
|
|
38
|
+
|
|
39
|
+
const clearCurrentTimeout = useCallback(() => {
|
|
40
|
+
if (timeoutHandleRef.current) {
|
|
41
|
+
clearTimeout(timeoutHandleRef.current);
|
|
42
|
+
timeoutHandleRef.current = null;
|
|
43
|
+
}
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const show = useCallback((options: ToastOptions) => {
|
|
47
|
+
const duration = options.duration ?? DEFAULT_DURATION;
|
|
48
|
+
|
|
49
|
+
clearCurrentTimeout();
|
|
50
|
+
|
|
51
|
+
setCurrentToast({
|
|
52
|
+
variant: options.variant ?? "info",
|
|
53
|
+
...options,
|
|
54
|
+
duration,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
timeoutHandleRef.current = setTimeout(() => {
|
|
58
|
+
setCurrentToast(null);
|
|
59
|
+
}, duration).unref();
|
|
60
|
+
}, [clearCurrentTimeout]);
|
|
61
|
+
|
|
62
|
+
const value = useMemo(() => ({ show }), [show]);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<ToastContext.Provider value={value}>
|
|
66
|
+
{children}
|
|
67
|
+
<Toast currentToast={currentToast} />
|
|
68
|
+
</ToastContext.Provider>
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type ToastProps = {
|
|
73
|
+
currentToast: ToastOptions | null;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function Toast({ currentToast }: ToastProps) {
|
|
77
|
+
const { width } = useTerminalDimensions();
|
|
78
|
+
const { colors } = useTheme();
|
|
79
|
+
|
|
80
|
+
if (!currentToast) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const variantColors: Record<ToastVariant, string> = {
|
|
85
|
+
success: colors.success,
|
|
86
|
+
error: colors.error,
|
|
87
|
+
info: colors.info,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const borderColor = currentToast.variant
|
|
91
|
+
? variantColors[currentToast.variant]
|
|
92
|
+
: variantColors.info;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<box
|
|
96
|
+
position="absolute"
|
|
97
|
+
justifyContent="center"
|
|
98
|
+
alignItems="flex-start"
|
|
99
|
+
top={2}
|
|
100
|
+
right={2}
|
|
101
|
+
width={Math.max(1, Math.min(60, width - 6))}
|
|
102
|
+
paddingLeft={2}
|
|
103
|
+
paddingRight={2}
|
|
104
|
+
paddingTop={1}
|
|
105
|
+
paddingBottom={1}
|
|
106
|
+
backgroundColor={colors.surface}
|
|
107
|
+
borderColor={borderColor}
|
|
108
|
+
border={["left", "right"]}
|
|
109
|
+
customBorderChars={SplitBorderChars}
|
|
110
|
+
>
|
|
111
|
+
<box flexDirection="column" gap={1} width="100%">
|
|
112
|
+
<text fg="#E1E1E1" wrapMode="word" width="100%">
|
|
113
|
+
{currentToast.message}
|
|
114
|
+
</text>
|
|
115
|
+
</box>
|
|
116
|
+
</box>
|
|
117
|
+
);
|
|
118
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useNavigate } from "react-router";
|
|
3
|
+
import { Header } from "../components/header";
|
|
4
|
+
import { InputBar } from "../components/input-bar";
|
|
5
|
+
import { usePromptConfig } from "../providers/prompt-config";
|
|
6
|
+
import { TextAttributes } from "@opentui/core";
|
|
7
|
+
|
|
8
|
+
export function Home() {
|
|
9
|
+
const navigate = useNavigate();
|
|
10
|
+
const { mode, model } = usePromptConfig();
|
|
11
|
+
|
|
12
|
+
const handleSubmit = useCallback(
|
|
13
|
+
(text: string) => {
|
|
14
|
+
navigate("/sessions/new", { state: { message: text, mode, model } });
|
|
15
|
+
},
|
|
16
|
+
[navigate, mode, model],
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<box
|
|
21
|
+
alignItems="center"
|
|
22
|
+
justifyContent="center"
|
|
23
|
+
flexGrow={1}
|
|
24
|
+
gap={2}
|
|
25
|
+
position="relative"
|
|
26
|
+
width="100%"
|
|
27
|
+
height="100%"
|
|
28
|
+
>
|
|
29
|
+
<Header />
|
|
30
|
+
<box width="100%" maxWidth={78} paddingX={2} flexDirection="column" gap={1}>
|
|
31
|
+
<InputBar onSubmit={handleSubmit} />
|
|
32
|
+
<box flexDirection="row" gap={1} flexShrink={0} marginLeft="auto">
|
|
33
|
+
<text>tab</text>
|
|
34
|
+
<text attributes={TextAttributes.DIM}>agents</text>
|
|
35
|
+
</box>
|
|
36
|
+
</box>
|
|
37
|
+
</box>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Mode, modeSchema } from "@nightcode/shared";
|
|
4
|
+
import { useNavigate, useLocation } from "react-router";
|
|
5
|
+
import { SessionShell } from "../components/session-shell";
|
|
6
|
+
import { UserMessage } from "../components/messages";
|
|
7
|
+
import { useToast } from "../providers/toast";
|
|
8
|
+
import { apiClient } from "../lib/api-client";
|
|
9
|
+
import { getErrorMessage } from "../lib/http-errors";
|
|
10
|
+
|
|
11
|
+
const newSessionStateSchema = z.object({
|
|
12
|
+
message: z.string(),
|
|
13
|
+
mode: modeSchema,
|
|
14
|
+
model: z.string(),
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
export function NewSession() {
|
|
18
|
+
const navigate = useNavigate();
|
|
19
|
+
const location = useLocation();
|
|
20
|
+
const toast = useToast();
|
|
21
|
+
const hasStartedRef = useRef(false);
|
|
22
|
+
|
|
23
|
+
const state = useMemo(() => {
|
|
24
|
+
const parsed = newSessionStateSchema.safeParse(location.state);
|
|
25
|
+
return parsed.success ? parsed.data : null;
|
|
26
|
+
}, [location.state])
|
|
27
|
+
|
|
28
|
+
// Guard: if navigated here directly without state, go home
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (!state) {
|
|
31
|
+
navigate("/", { replace: true });
|
|
32
|
+
}
|
|
33
|
+
}, [state, navigate]);
|
|
34
|
+
|
|
35
|
+
// Create the session on mount — this screen exists to do this
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!state || hasStartedRef.current) return;
|
|
38
|
+
|
|
39
|
+
hasStartedRef.current = true;
|
|
40
|
+
|
|
41
|
+
let ignore = false;
|
|
42
|
+
const createSession = async () => {
|
|
43
|
+
try {
|
|
44
|
+
const res = await apiClient.sessions.$post({
|
|
45
|
+
json: {
|
|
46
|
+
title: state.message.slice(0, 100),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
if (ignore) return;
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
throw new Error(await getErrorMessage(res));
|
|
53
|
+
}
|
|
54
|
+
const session = await res.json();
|
|
55
|
+
navigate(
|
|
56
|
+
`/sessions/${session.id}`,
|
|
57
|
+
{ replace: true, state: { session, initialPrompt: state } }
|
|
58
|
+
);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (ignore) return;
|
|
61
|
+
toast.show({
|
|
62
|
+
variant: "error",
|
|
63
|
+
message: error instanceof Error ? error.message : "Failed to create session",
|
|
64
|
+
});
|
|
65
|
+
navigate("/", { replace: true });
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
createSession();
|
|
70
|
+
return () => {
|
|
71
|
+
ignore = true;
|
|
72
|
+
};
|
|
73
|
+
}, [state, navigate, toast]);
|
|
74
|
+
|
|
75
|
+
if (!state) return null;
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<SessionShell onSubmit={() => {}} inputDisabled loading>
|
|
79
|
+
<UserMessage message={state.message} mode={state.mode} />
|
|
80
|
+
</SessionShell>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { useState, useEffect, useMemo, useRef } from "react";
|
|
2
|
+
import { useParams, useLocation, useNavigate } from "react-router";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { useKeyboard } from "@opentui/react";
|
|
5
|
+
import { type ModeType, type SupportedChatModelId, type SessionRecord } from "@nightcode/shared";
|
|
6
|
+
import { SessionShell } from "../components/session-shell";
|
|
7
|
+
import {
|
|
8
|
+
UserMessage,
|
|
9
|
+
BotMessage,
|
|
10
|
+
ErrorMessage
|
|
11
|
+
} from "../components/messages";
|
|
12
|
+
import { useToast } from "../providers/toast";
|
|
13
|
+
import { useChat } from "../hooks/use-chat";
|
|
14
|
+
import { usePromptConfig } from "../providers/prompt-config";
|
|
15
|
+
import type { Message } from "../hooks/use-chat";
|
|
16
|
+
import { apiClient } from "../lib/api-client";
|
|
17
|
+
import { getErrorMessage } from "../lib/http-errors";
|
|
18
|
+
import { useKeyboardLayer } from "../providers/keyboard-layer";
|
|
19
|
+
|
|
20
|
+
type SessionData = SessionRecord;
|
|
21
|
+
|
|
22
|
+
const sessionLocationSchema = z.object({
|
|
23
|
+
session: z.custom<SessionData>((val) => val != null && typeof val === "object" && "id" in val),
|
|
24
|
+
initialPrompt: z
|
|
25
|
+
.object({
|
|
26
|
+
message: z.string(),
|
|
27
|
+
mode: z.custom<ModeType>(),
|
|
28
|
+
model: z.custom<SupportedChatModelId>(),
|
|
29
|
+
})
|
|
30
|
+
.optional(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function ChatMessage(
|
|
34
|
+
{ msg }: {
|
|
35
|
+
msg: Message
|
|
36
|
+
}
|
|
37
|
+
) {
|
|
38
|
+
if (msg.role === "user") {
|
|
39
|
+
const text = msg.parts
|
|
40
|
+
.filter((p) => p.type === "text")
|
|
41
|
+
.map((p) => p.text)
|
|
42
|
+
.join("");
|
|
43
|
+
|
|
44
|
+
return <UserMessage message={text} mode={msg.metadata?.mode ?? "BUILD"} />;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<BotMessage
|
|
49
|
+
parts={msg.parts}
|
|
50
|
+
model={msg.metadata?.model ?? "unknown"}
|
|
51
|
+
mode={msg.metadata?.mode ?? "BUILD"}
|
|
52
|
+
durationMs={msg.metadata?.durationMs}
|
|
53
|
+
streaming={false}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function SessionChat({
|
|
59
|
+
session,
|
|
60
|
+
initialPrompt,
|
|
61
|
+
}: {
|
|
62
|
+
session: SessionData,
|
|
63
|
+
initialPrompt?: { message: string; mode: ModeType; model: SupportedChatModelId };
|
|
64
|
+
}) {
|
|
65
|
+
const [initialMessages] = useState(() => session.messages as unknown as Message[]);
|
|
66
|
+
const { mode, model } = usePromptConfig();
|
|
67
|
+
const { isTopLayer } = useKeyboardLayer();
|
|
68
|
+
const { messages, status, submit, abort, interrupt, error } = useChat(
|
|
69
|
+
session.id,
|
|
70
|
+
initialMessages
|
|
71
|
+
);
|
|
72
|
+
const hasSubmittedInitialPromptRef = useRef(false);
|
|
73
|
+
|
|
74
|
+
// Stop the pending reply when the user leaves this session.
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
return () => {
|
|
77
|
+
void abort();
|
|
78
|
+
};
|
|
79
|
+
}, [abort]);
|
|
80
|
+
|
|
81
|
+
// Let the user cancel a reply even before the first streamed chunk arrives.
|
|
82
|
+
useKeyboard((key) => {
|
|
83
|
+
if (key.name === "escape" && isTopLayer("base") && status === "streaming") {
|
|
84
|
+
key.preventDefault();
|
|
85
|
+
interrupt();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!initialPrompt || hasSubmittedInitialPromptRef.current) return;
|
|
91
|
+
hasSubmittedInitialPromptRef.current = true;
|
|
92
|
+
void submit({
|
|
93
|
+
userText: initialPrompt.message,
|
|
94
|
+
mode: initialPrompt.mode,
|
|
95
|
+
model: initialPrompt.model,
|
|
96
|
+
});
|
|
97
|
+
}, [initialPrompt, submit]);
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<SessionShell
|
|
101
|
+
onSubmit={(text) => submit({ userText: text, mode, model })}
|
|
102
|
+
loading={status === "submitted" || status === "streaming"}
|
|
103
|
+
interruptible={status === "submitted" || status === "streaming"}
|
|
104
|
+
>
|
|
105
|
+
{messages.map((msg) => (
|
|
106
|
+
<ChatMessage key={msg.id} msg={msg} />
|
|
107
|
+
))}
|
|
108
|
+
{error && <ErrorMessage message={error.message} />}
|
|
109
|
+
</SessionShell>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function Session() {
|
|
114
|
+
const { id } = useParams();
|
|
115
|
+
const location = useLocation();
|
|
116
|
+
const navigate = useNavigate();
|
|
117
|
+
const toast = useToast();
|
|
118
|
+
|
|
119
|
+
const prefetched = useMemo(() => {
|
|
120
|
+
const parsed = sessionLocationSchema.safeParse(location.state);
|
|
121
|
+
return parsed.success ? parsed.data : null;
|
|
122
|
+
}, [location.state]);
|
|
123
|
+
|
|
124
|
+
const [session, setSession] = useState<SessionData | null>(prefetched?.session ?? null);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
// Skip fetch if session was passed via location state
|
|
128
|
+
if (prefetched?.session) return;
|
|
129
|
+
|
|
130
|
+
setSession(null);
|
|
131
|
+
|
|
132
|
+
if (!id) return;
|
|
133
|
+
|
|
134
|
+
let ignore = false;
|
|
135
|
+
const fetchSession = async () => {
|
|
136
|
+
try {
|
|
137
|
+
const res = await apiClient.sessions[":id"].$get({
|
|
138
|
+
param: { id },
|
|
139
|
+
});
|
|
140
|
+
if (ignore) return;
|
|
141
|
+
if (!res.ok) throw new Error(await getErrorMessage(res));
|
|
142
|
+
const resolved = await res.json();
|
|
143
|
+
setSession(resolved);
|
|
144
|
+
} catch (err) {
|
|
145
|
+
if (ignore) return;
|
|
146
|
+
toast.show({
|
|
147
|
+
variant: "error",
|
|
148
|
+
message: err instanceof Error ? err.message : "Failed to load session",
|
|
149
|
+
});
|
|
150
|
+
navigate("/", { replace: true });
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
fetchSession();
|
|
155
|
+
return () => {
|
|
156
|
+
ignore = true;
|
|
157
|
+
};
|
|
158
|
+
}, [id, prefetched, toast, navigate]);
|
|
159
|
+
|
|
160
|
+
if (!session) {
|
|
161
|
+
return <SessionShell onSubmit={() => {}} inputDisabled loading />;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<SessionChat
|
|
166
|
+
key={session.id}
|
|
167
|
+
session={session}
|
|
168
|
+
initialPrompt={prefetched?.initialPrompt}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
};
|