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(),
|
||||
cancelChat: vi.fn(),
|
||||
setAnthropicApiKey: vi.fn(),
|
||||
getOAuthStatus: vi.fn(),
|
||||
};
|
||||
class ChatWebSocket {
|
||||
connect() {}
|
||||
@@ -65,6 +66,7 @@ describe("App", () => {
|
||||
mockedApi.getAnthropicApiKeyExists.mockResolvedValue(false);
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.getOAuthStatus.mockResolvedValue({ authenticated: false, expired: false, expires_at: 0, has_refresh_token: false });
|
||||
});
|
||||
|
||||
async function renderApp() {
|
||||
|
||||
+28
-1
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import type { OAuthStatus } from "./api/client";
|
||||
import { api } from "./api/client";
|
||||
import { Chat } from "./components/Chat";
|
||||
import { SelectionScreen } from "./components/selection/SelectionScreen";
|
||||
@@ -14,6 +15,27 @@ function App() {
|
||||
const [isOpening, setIsOpening] = React.useState(false);
|
||||
const [knownProjects, setKnownProjects] = React.useState<string[]>([]);
|
||||
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(() => {
|
||||
api
|
||||
@@ -182,10 +204,15 @@ function App() {
|
||||
onCloseSuggestions={closeSuggestions}
|
||||
completionError={completionError}
|
||||
currentPartial={currentPartial}
|
||||
oauthStatus={oauthStatus}
|
||||
/>
|
||||
) : (
|
||||
<div className="workspace" style={{ height: "100%" }}>
|
||||
<Chat projectPath={projectPath} onCloseProject={closeProject} />
|
||||
<Chat
|
||||
projectPath={projectPath}
|
||||
onCloseProject={closeProject}
|
||||
oauthStatus={oauthStatus}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -205,6 +205,13 @@ export interface CommandOutput {
|
||||
exit_code: number;
|
||||
}
|
||||
|
||||
export interface OAuthStatus {
|
||||
authenticated: boolean;
|
||||
expired: boolean;
|
||||
expires_at: number;
|
||||
has_refresh_token: boolean;
|
||||
}
|
||||
|
||||
declare const __STORKIT_PORT__: string;
|
||||
|
||||
const DEFAULT_API_BASE = "/api";
|
||||
@@ -402,6 +409,10 @@ export const api = {
|
||||
deleteStory(storyId: string) {
|
||||
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. */
|
||||
botCommand(command: string, args: string, baseUrl?: string) {
|
||||
return requestJson<{ response: string }>(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { AgentConfigInfo } from "../api/agents";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import type {
|
||||
AnthropicModelInfo,
|
||||
OAuthStatus,
|
||||
PipelineState,
|
||||
WizardStateData,
|
||||
} from "../api/client";
|
||||
@@ -164,9 +165,10 @@ const getContextWindowSize = (
|
||||
interface ChatProps {
|
||||
projectPath: string;
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [model, setModel] = useState("claude-code-pty");
|
||||
@@ -940,6 +942,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
||||
enableTools={enableTools}
|
||||
onToggleTools={setEnableTools}
|
||||
wsConnected={wsConnected}
|
||||
oauthStatus={oauthStatus}
|
||||
/>
|
||||
|
||||
{/* Two-column content area */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import type { OAuthStatus } from "../api/client";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const { useState, useEffect } = React;
|
||||
@@ -32,6 +33,7 @@ interface ChatHeaderProps {
|
||||
enableTools: boolean;
|
||||
onToggleTools: (enabled: boolean) => void;
|
||||
wsConnected: boolean;
|
||||
oauthStatus?: OAuthStatus | null;
|
||||
}
|
||||
|
||||
const getContextEmoji = (percentage: number): string => {
|
||||
@@ -55,6 +57,7 @@ export function ChatHeader({
|
||||
enableTools,
|
||||
onToggleTools,
|
||||
wsConnected,
|
||||
oauthStatus = null,
|
||||
}: ChatHeaderProps) {
|
||||
const hasModelOptions = availableModels.length > 0 || claudeModels.length > 0;
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
@@ -340,6 +343,59 @@ export function ChatHeader({
|
||||
</div>
|
||||
|
||||
<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
|
||||
style={{
|
||||
fontSize: "0.75em",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { KeyboardEvent } from "react";
|
||||
import type { OAuthStatus } from "../../api/client";
|
||||
import { ProjectPathInput } from "./ProjectPathInput.tsx";
|
||||
import { RecentProjectsList } from "./RecentProjectsList.tsx";
|
||||
|
||||
@@ -24,6 +25,7 @@ export interface SelectionScreenProps {
|
||||
onCloseSuggestions: () => void;
|
||||
completionError: string | null;
|
||||
currentPartial: string;
|
||||
oauthStatus?: OAuthStatus | null;
|
||||
}
|
||||
|
||||
export function SelectionScreen({
|
||||
@@ -43,6 +45,7 @@ export function SelectionScreen({
|
||||
onCloseSuggestions,
|
||||
completionError,
|
||||
currentPartial,
|
||||
oauthStatus = null,
|
||||
}: SelectionScreenProps) {
|
||||
const resolvedHomeDir = homeDir
|
||||
? homeDir.endsWith("/")
|
||||
@@ -57,6 +60,37 @@ export function SelectionScreen({
|
||||
<h1>Storkit</h1>
|
||||
<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 && (
|
||||
<RecentProjectsList
|
||||
projects={knownProjects}
|
||||
|
||||
@@ -30,6 +30,20 @@ export default defineConfig(() => {
|
||||
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: {
|
||||
ignored: [
|
||||
|
||||
Reference in New Issue
Block a user