diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 1ed7b037..22a1aea8 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -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() { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 316029a8..49533e9a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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([]); const [homeDir, setHomeDir] = React.useState(null); + const [oauthStatus, setOauthStatus] = React.useState( + 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} /> ) : (
- +
)} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 335b8016..3f690345 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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("/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 }>( diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index 133f1660..1ca6a423 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -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 */} diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 5bed248c..83b5b25c 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -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({
+ {oauthStatus !== null && + (!oauthStatus.authenticated || oauthStatus.expired) && ( + + )} + {oauthStatus?.authenticated && !oauthStatus.expired && ( + + ✓ Claude + + )} +
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({

Storkit

Paste or complete a project path to start.

+ {oauthStatus !== null && ( +
+ {!oauthStatus.authenticated || oauthStatus.expired ? ( + + ) : ( + + ✓ Authenticated with Claude + + )} +
+ )} + {knownProjects.length > 0 && ( { 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: [