storkit: merge 446_story_oauth_login_button_in_web_ui
This commit is contained in:
@@ -19,6 +19,7 @@ vi.mock("./api/client", () => {
|
|||||||
setModelPreference: vi.fn(),
|
setModelPreference: vi.fn(),
|
||||||
cancelChat: vi.fn(),
|
cancelChat: vi.fn(),
|
||||||
setAnthropicApiKey: vi.fn(),
|
setAnthropicApiKey: vi.fn(),
|
||||||
|
getOAuthStatus: vi.fn(),
|
||||||
};
|
};
|
||||||
class ChatWebSocket {
|
class ChatWebSocket {
|
||||||
connect() {}
|
connect() {}
|
||||||
@@ -65,6 +66,7 @@ describe("App", () => {
|
|||||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||||
|
mockedApi.getOAuthStatus.mockResolvedValue({ authenticated: false, expired: false, expires_at: 0, has_refresh_token: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
async function renderApp() {
|
async function renderApp() {
|
||||||
|
|||||||
+28
-1
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import type { OAuthStatus } from "./api/client";
|
||||||
import { api } from "./api/client";
|
import { api } from "./api/client";
|
||||||
import { Chat } from "./components/Chat";
|
import { Chat } from "./components/Chat";
|
||||||
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
||||||
@@ -14,6 +15,27 @@ function App() {
|
|||||||
const [isOpening, setIsOpening] = React.useState(false);
|
const [isOpening, setIsOpening] = React.useState(false);
|
||||||
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
||||||
const [homeDir, setHomeDir] = React.useState<string | null>(null);
|
const [homeDir, setHomeDir] = React.useState<string | null>(null);
|
||||||
|
const [oauthStatus, setOauthStatus] = React.useState<OAuthStatus | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
function fetchOAuthStatus() {
|
||||||
|
api
|
||||||
|
.getOAuthStatus()
|
||||||
|
.then((s) => {
|
||||||
|
if (active) setOauthStatus(s);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
fetchOAuthStatus();
|
||||||
|
const intervalId = window.setInterval(fetchOAuthStatus, 5000);
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
api
|
api
|
||||||
@@ -182,10 +204,15 @@ function App() {
|
|||||||
onCloseSuggestions={closeSuggestions}
|
onCloseSuggestions={closeSuggestions}
|
||||||
completionError={completionError}
|
completionError={completionError}
|
||||||
currentPartial={currentPartial}
|
currentPartial={currentPartial}
|
||||||
|
oauthStatus={oauthStatus}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="workspace" style={{ height: "100%" }}>
|
<div className="workspace" style={{ height: "100%" }}>
|
||||||
<Chat projectPath={projectPath} onCloseProject={closeProject} />
|
<Chat
|
||||||
|
projectPath={projectPath}
|
||||||
|
onCloseProject={closeProject}
|
||||||
|
oauthStatus={oauthStatus}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,13 @@ export interface CommandOutput {
|
|||||||
exit_code: number;
|
exit_code: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OAuthStatus {
|
||||||
|
authenticated: boolean;
|
||||||
|
expired: boolean;
|
||||||
|
expires_at: number;
|
||||||
|
has_refresh_token: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
declare const __STORKIT_PORT__: string;
|
declare const __STORKIT_PORT__: string;
|
||||||
|
|
||||||
const DEFAULT_API_BASE = "/api";
|
const DEFAULT_API_BASE = "/api";
|
||||||
@@ -402,6 +409,10 @@ export const api = {
|
|||||||
deleteStory(storyId: string) {
|
deleteStory(storyId: string) {
|
||||||
return callMcpTool("delete_story", { story_id: storyId });
|
return callMcpTool("delete_story", { story_id: storyId });
|
||||||
},
|
},
|
||||||
|
/** Fetch OAuth status from the server. */
|
||||||
|
getOAuthStatus() {
|
||||||
|
return requestJson<OAuthStatus>("/oauth/status", {}, "");
|
||||||
|
},
|
||||||
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
||||||
botCommand(command: string, args: string, baseUrl?: string) {
|
botCommand(command: string, args: string, baseUrl?: string) {
|
||||||
return requestJson<{ response: string }>(
|
return requestJson<{ response: string }>(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { AgentConfigInfo } from "../api/agents";
|
|||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import type {
|
import type {
|
||||||
AnthropicModelInfo,
|
AnthropicModelInfo,
|
||||||
|
OAuthStatus,
|
||||||
PipelineState,
|
PipelineState,
|
||||||
WizardStateData,
|
WizardStateData,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
@@ -164,9 +165,10 @@ const getContextWindowSize = (
|
|||||||
interface ChatProps {
|
interface ChatProps {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
onCloseProject: () => void;
|
onCloseProject: () => void;
|
||||||
|
oauthStatus?: OAuthStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
export function Chat({ projectPath, onCloseProject, oauthStatus = null }: ChatProps) {
|
||||||
const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
|
const { messages, setMessages, clearMessages } = useChatHistory(projectPath);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [model, setModel] = useState("claude-code-pty");
|
const [model, setModel] = useState("claude-code-pty");
|
||||||
@@ -940,6 +942,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
enableTools={enableTools}
|
enableTools={enableTools}
|
||||||
onToggleTools={setEnableTools}
|
onToggleTools={setEnableTools}
|
||||||
wsConnected={wsConnected}
|
wsConnected={wsConnected}
|
||||||
|
oauthStatus={oauthStatus}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Two-column content area */}
|
{/* Two-column content area */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import type { OAuthStatus } from "../api/client";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
|
||||||
const { useState, useEffect } = React;
|
const { useState, useEffect } = React;
|
||||||
@@ -32,6 +33,7 @@ interface ChatHeaderProps {
|
|||||||
enableTools: boolean;
|
enableTools: boolean;
|
||||||
onToggleTools: (enabled: boolean) => void;
|
onToggleTools: (enabled: boolean) => void;
|
||||||
wsConnected: boolean;
|
wsConnected: boolean;
|
||||||
|
oauthStatus?: OAuthStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getContextEmoji = (percentage: number): string => {
|
const getContextEmoji = (percentage: number): string => {
|
||||||
@@ -55,6 +57,7 @@ export function ChatHeader({
|
|||||||
enableTools,
|
enableTools,
|
||||||
onToggleTools,
|
onToggleTools,
|
||||||
wsConnected,
|
wsConnected,
|
||||||
|
oauthStatus = null,
|
||||||
}: ChatHeaderProps) {
|
}: ChatHeaderProps) {
|
||||||
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
@@ -340,6 +343,59 @@ export function ChatHeader({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "16px" }}>
|
||||||
|
{oauthStatus !== null &&
|
||||||
|
(!oauthStatus.authenticated || oauthStatus.expired) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Authenticate with Claude via OAuth"
|
||||||
|
onClick={() => {
|
||||||
|
window.open("/oauth/authorize", "_blank", "noopener,noreferrer");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "99px",
|
||||||
|
border: "none",
|
||||||
|
fontSize: "0.85em",
|
||||||
|
backgroundColor: "#1a3a5c",
|
||||||
|
color: "#7eb8f7",
|
||||||
|
cursor: "pointer",
|
||||||
|
outline: "none",
|
||||||
|
transition: "all 0.2s",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
onMouseOver={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#234d7a";
|
||||||
|
e.currentTarget.style.color = "#a8d4ff";
|
||||||
|
}}
|
||||||
|
onMouseOut={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#1a3a5c";
|
||||||
|
e.currentTarget.style.color = "#7eb8f7";
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#234d7a";
|
||||||
|
e.currentTarget.style.color = "#a8d4ff";
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "#1a3a5c";
|
||||||
|
e.currentTarget.style.color = "#7eb8f7";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{oauthStatus.expired ? "Re-authenticate" : "Login with Claude"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{oauthStatus?.authenticated && !oauthStatus.expired && (
|
||||||
|
<span
|
||||||
|
title="Authenticated with Claude via OAuth"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8em",
|
||||||
|
color: "#4caf50",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✓ Claude
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.75em",
|
fontSize: "0.75em",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { KeyboardEvent } from "react";
|
import type { KeyboardEvent } from "react";
|
||||||
|
import type { OAuthStatus } from "../../api/client";
|
||||||
import { ProjectPathInput } from "./ProjectPathInput.tsx";
|
import { ProjectPathInput } from "./ProjectPathInput.tsx";
|
||||||
import { RecentProjectsList } from "./RecentProjectsList.tsx";
|
import { RecentProjectsList } from "./RecentProjectsList.tsx";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ export interface SelectionScreenProps {
|
|||||||
onCloseSuggestions: () => void;
|
onCloseSuggestions: () => void;
|
||||||
completionError: string | null;
|
completionError: string | null;
|
||||||
currentPartial: string;
|
currentPartial: string;
|
||||||
|
oauthStatus?: OAuthStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SelectionScreen({
|
export function SelectionScreen({
|
||||||
@@ -43,6 +45,7 @@ export function SelectionScreen({
|
|||||||
onCloseSuggestions,
|
onCloseSuggestions,
|
||||||
completionError,
|
completionError,
|
||||||
currentPartial,
|
currentPartial,
|
||||||
|
oauthStatus = null,
|
||||||
}: SelectionScreenProps) {
|
}: SelectionScreenProps) {
|
||||||
const resolvedHomeDir = homeDir
|
const resolvedHomeDir = homeDir
|
||||||
? homeDir.endsWith("/")
|
? homeDir.endsWith("/")
|
||||||
@@ -57,6 +60,37 @@ export function SelectionScreen({
|
|||||||
<h1>Storkit</h1>
|
<h1>Storkit</h1>
|
||||||
<p>Paste or complete a project path to start.</p>
|
<p>Paste or complete a project path to start.</p>
|
||||||
|
|
||||||
|
{oauthStatus !== null && (
|
||||||
|
<div style={{ marginBottom: "1rem" }}>
|
||||||
|
{!oauthStatus.authenticated || oauthStatus.expired ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
window.open("/oauth/authorize", "_blank", "noopener,noreferrer");
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
borderRadius: "6px",
|
||||||
|
border: "1px solid #1a3a5c",
|
||||||
|
backgroundColor: "#1a3a5c",
|
||||||
|
color: "#7eb8f7",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.9em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{oauthStatus.expired ? "Re-authenticate with Claude" : "Login with Claude"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
title="Authenticated with Claude via OAuth"
|
||||||
|
style={{ color: "#4caf50", fontSize: "0.9em" }}
|
||||||
|
>
|
||||||
|
✓ Authenticated with Claude
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{knownProjects.length > 0 && (
|
{knownProjects.length > 0 && (
|
||||||
<RecentProjectsList
|
<RecentProjectsList
|
||||||
projects={knownProjects}
|
projects={knownProjects}
|
||||||
|
|||||||
@@ -30,6 +30,20 @@ export default defineConfig(() => {
|
|||||||
proxy.on("error", (_err) => {});
|
proxy.on("error", (_err) => {});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/oauth": {
|
||||||
|
target: `http://127.0.0.1:${String(backendPort)}`,
|
||||||
|
timeout: 120000,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on("error", (_err) => {});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/callback": {
|
||||||
|
target: `http://127.0.0.1:${String(backendPort)}`,
|
||||||
|
timeout: 120000,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on("error", (_err) => {});
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
ignored: [
|
ignored: [
|
||||||
|
|||||||
Reference in New Issue
Block a user