storkit: merge 446_story_oauth_login_button_in_web_ui

This commit is contained in:
dave
2026-03-31 10:04:52 +00:00
parent 23562dfa61
commit 321c88e05e
7 changed files with 149 additions and 2 deletions
+2
View File
@@ -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
View File
@@ -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>
)} )}
+11
View File
@@ -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 }>(
+4 -1
View File
@@ -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 */}
+56
View File
@@ -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}
+14
View File
@@ -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: [