qwen-code/packages/desktop/src/renderer/App.tsx
2026-04-25 10:15:33 +08:00

1385 lines
37 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import {
type Dispatch,
type FormEvent,
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react';
import {
authenticateDesktop,
commitDesktopProjectChanges,
createDesktopSession,
getDesktopProjectGitDiff,
getDesktopProjectGitStatus,
getDesktopSessionModeState,
getDesktopSessionModelState,
getDesktopUserSettings,
listDesktopProjects,
listDesktopSessions,
loadDesktopStatus,
openDesktopProject,
revertDesktopProjectChanges,
setDesktopSessionMode,
setDesktopSessionModel,
stageDesktopProjectChanges,
updateDesktopUserSettings,
type DesktopConnectionStatus,
type DesktopGitDiff,
type DesktopProject,
type DesktopSessionSummary,
} from './api/client.js';
import {
connectSessionSocket,
type SessionSocketClient,
} from './api/websocket.js';
import {
chatReducer,
createInitialChatState,
type ChatAction,
type ChatState,
type ChatTimelineItem,
} from './stores/chatStore.js';
import {
createInitialModelState,
type ModelAction,
modelReducer,
type ModelState,
} from './stores/modelStore.js';
import {
buildSettingsUpdateRequest,
createInitialSettingsState,
settingsReducer,
type SettingsAction,
type SettingsState,
} from './stores/settingsStore.js';
import type {
DesktopApprovalMode,
DesktopServerMessage,
} from '../shared/desktopProtocol.js';
type LoadState =
| { state: 'loading' }
| { state: 'ready'; status: DesktopConnectionStatus }
| { state: 'error'; message: string };
export function App() {
const [loadState, setLoadState] = useState<LoadState>({ state: 'loading' });
const [projects, setProjects] = useState<DesktopProject[]>([]);
const [activeProjectId, setActiveProjectId] = useState<string | null>(null);
const [sessions, setSessions] = useState<DesktopSessionSummary[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [sessionError, setSessionError] = useState<string | null>(null);
const [gitDiff, setGitDiff] = useState<DesktopGitDiff | null>(null);
const [reviewError, setReviewError] = useState<string | null>(null);
const [commitMessage, setCommitMessage] = useState('');
const [messageText, setMessageText] = useState('');
const [chatState, dispatchChat] = useReducer(
chatReducer,
undefined,
createInitialChatState,
);
const [settingsState, dispatchSettings] = useReducer(
settingsReducer,
undefined,
createInitialSettingsState,
);
const [modelState, dispatchModel] = useReducer(
modelReducer,
undefined,
createInitialModelState,
);
const socketRef = useRef<SessionSocketClient | null>(null);
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId) ?? null,
[activeProjectId, projects],
);
const activeProjectPath = activeProject?.path ?? '';
useEffect(() => {
let disposed = false;
const load = async () => {
try {
const status = await loadDesktopStatus();
if (!disposed) {
setLoadState({ state: 'ready', status });
}
} catch (error) {
if (!disposed) {
setLoadState({
state: 'error',
message:
error instanceof Error
? error.message
: 'Unable to reach desktop service.',
});
}
}
};
void load();
return () => {
disposed = true;
};
}, []);
useEffect(() => {
if (loadState.state !== 'ready') {
return;
}
let disposed = false;
dispatchSettings({ type: 'load_start' });
void getDesktopUserSettings(loadState.status.serverInfo)
.then((settings) => {
if (!disposed) {
dispatchSettings({ type: 'load_success', settings });
}
})
.catch((error: unknown) => {
if (!disposed) {
dispatchSettings({
type: 'load_error',
message: getErrorMessage(error),
});
}
});
return () => {
disposed = true;
};
}, [loadState]);
useEffect(() => {
if (loadState.state !== 'ready') {
return;
}
let disposed = false;
void listDesktopProjects(loadState.status.serverInfo)
.then((result) => {
if (disposed) {
return;
}
setProjects(result.projects);
setActiveProjectId(
(current) => current ?? result.projects[0]?.id ?? null,
);
})
.catch((error: unknown) => {
if (!disposed) {
setSessionError(getErrorMessage(error));
}
});
return () => {
disposed = true;
};
}, [loadState]);
useEffect(() => {
if (loadState.state !== 'ready') {
return;
}
let disposed = false;
void listDesktopSessions(
loadState.status.serverInfo,
activeProjectPath || undefined,
)
.then((result) => {
if (!disposed) {
setSessions(result.sessions);
setSessionError(null);
}
})
.catch((error: unknown) => {
if (!disposed) {
setSessionError(getErrorMessage(error));
}
});
return () => {
disposed = true;
};
}, [activeProjectPath, loadState]);
useEffect(() => {
socketRef.current?.close();
socketRef.current = null;
dispatchModel({ type: 'reset' });
if (loadState.state !== 'ready' || !activeSessionId) {
return;
}
dispatchChat({ type: 'connect' });
const socket = connectSessionSocket(
loadState.status.serverInfo,
activeSessionId,
{
onMessage: (message) =>
handleSessionSocketMessage(message, dispatchChat, dispatchModel),
onClose: () => dispatchChat({ type: 'disconnect' }),
onError: () => setSessionError('Session socket connection failed.'),
},
);
socketRef.current = socket;
return () => {
socket.close();
if (socketRef.current === socket) {
socketRef.current = null;
}
};
}, [activeSessionId, loadState]);
useEffect(() => {
if (loadState.state !== 'ready' || !activeSessionId) {
return;
}
let disposed = false;
void Promise.allSettled([
getDesktopSessionModelState(loadState.status.serverInfo, activeSessionId),
getDesktopSessionModeState(loadState.status.serverInfo, activeSessionId),
]).then(([models, modes]) => {
if (disposed) {
return;
}
dispatchModel({
type: 'session_runtime_loaded',
models: models.status === 'fulfilled' ? models.value : undefined,
modes: modes.status === 'fulfilled' ? modes.value : undefined,
});
});
return () => {
disposed = true;
};
}, [activeSessionId, loadState]);
const chooseWorkspace = useCallback(async () => {
if (loadState.state !== 'ready') {
return;
}
try {
const selectedPath = await window.qwenDesktop.selectDirectory();
if (selectedPath) {
const project = await openDesktopProject(
loadState.status.serverInfo,
selectedPath,
);
setProjects((current) => [
project,
...current.filter((entry) => entry.id !== project.id),
]);
setActiveProjectId(project.id);
setActiveSessionId(null);
}
} catch (error) {
setSessionError(getErrorMessage(error));
}
}, [loadState]);
const createSession = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
try {
const session = await createDesktopSession(
loadState.status.serverInfo,
activeProject.path,
);
setSessions((current) => [session, ...current]);
setActiveSessionId(session.sessionId);
dispatchModel({
type: 'session_runtime_loaded',
models: session.models,
modes: session.modes,
});
setSessionError(null);
} catch (error) {
setSessionError(getErrorMessage(error));
}
}, [activeProject, loadState]);
const selectProject = useCallback((projectId: string) => {
setActiveProjectId(projectId);
setActiveSessionId(null);
}, []);
const refreshProjectGitStatus = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
try {
const gitStatus = await getDesktopProjectGitStatus(
loadState.status.serverInfo,
activeProject.id,
);
setProjects((current) =>
current.map((project) =>
project.id === activeProject.id
? {
...project,
gitBranch: gitStatus.branch,
gitStatus,
}
: project,
),
);
} catch (error) {
setSessionError(getErrorMessage(error));
}
}, [activeProject, loadState]);
const loadProjectReview = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
setGitDiff(null);
return;
}
try {
const diff = await getDesktopProjectGitDiff(
loadState.status.serverInfo,
activeProject.id,
);
setGitDiff(diff);
setReviewError(null);
} catch (error) {
setGitDiff(null);
setReviewError(getErrorMessage(error));
}
}, [activeProject, loadState]);
useEffect(() => {
void loadProjectReview();
}, [loadProjectReview]);
const applyReviewMutation = useCallback(
(status: DesktopProject['gitStatus'], diff: DesktopGitDiff) => {
if (!activeProject) {
return;
}
setProjects((current) =>
current.map((project) =>
project.id === activeProject.id
? {
...project,
gitBranch: status.branch,
gitStatus: status,
}
: project,
),
);
setGitDiff(diff);
setReviewError(null);
},
[activeProject],
);
const stageAllChanges = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
try {
const result = await stageDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
);
applyReviewMutation(result.status, result.diff);
} catch (error) {
setReviewError(getErrorMessage(error));
}
}, [activeProject, applyReviewMutation, loadState]);
const revertAllChanges = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
try {
const result = await revertDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
);
applyReviewMutation(result.status, result.diff);
} catch (error) {
setReviewError(getErrorMessage(error));
}
}, [activeProject, applyReviewMutation, loadState]);
const commitChanges = useCallback(async () => {
if (
loadState.state !== 'ready' ||
!activeProject ||
commitMessage.trim().length === 0
) {
return;
}
try {
const result = await commitDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
commitMessage,
);
applyReviewMutation(result.status, result.diff);
setCommitMessage('');
} catch (error) {
setReviewError(getErrorMessage(error));
}
}, [activeProject, applyReviewMutation, commitMessage, loadState]);
const saveSettings = useCallback(async () => {
if (loadState.state !== 'ready') {
return;
}
dispatchSettings({ type: 'save_start' });
try {
const settings = await updateDesktopUserSettings(
loadState.status.serverInfo,
buildSettingsUpdateRequest(settingsState.form),
);
dispatchSettings({ type: 'save_success', settings });
} catch (error) {
dispatchSettings({ type: 'save_error', message: getErrorMessage(error) });
}
}, [loadState, settingsState.form]);
const authenticate = useCallback(
async (methodId: string) => {
if (loadState.state !== 'ready') {
return;
}
try {
await authenticateDesktop(loadState.status.serverInfo, methodId);
setSessionError(null);
} catch (error) {
setSessionError(getErrorMessage(error));
}
},
[loadState],
);
const changeModel = useCallback(
async (modelId: string) => {
if (loadState.state !== 'ready' || !activeSessionId) {
return;
}
dispatchModel({ type: 'model_save_start' });
try {
const models = await setDesktopSessionModel(
loadState.status.serverInfo,
activeSessionId,
modelId,
);
dispatchModel({ type: 'model_saved', models });
} catch (error) {
dispatchModel({ type: 'error', message: getErrorMessage(error) });
}
},
[activeSessionId, loadState],
);
const changeMode = useCallback(
async (mode: DesktopApprovalMode) => {
if (loadState.state !== 'ready' || !activeSessionId) {
return;
}
dispatchModel({ type: 'mode_save_start' });
try {
const modes = await setDesktopSessionMode(
loadState.status.serverInfo,
activeSessionId,
mode,
);
dispatchModel({ type: 'mode_saved', modes });
} catch (error) {
dispatchModel({ type: 'error', message: getErrorMessage(error) });
}
},
[activeSessionId, loadState],
);
const sendMessage = useCallback(
(event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const content = messageText.trim();
if (!content || !socketRef.current) {
return;
}
dispatchChat({ type: 'append_user_message', content });
socketRef.current.sendUserMessage(content);
setMessageText('');
},
[messageText],
);
const stopGeneration = useCallback(() => {
socketRef.current?.stopGeneration();
}, []);
const respondToPermission = useCallback(
(requestId: string, optionId: string) => {
socketRef.current?.respondToPermission(requestId, optionId);
dispatchChat({ type: 'clear_permission_request', requestId });
},
[],
);
const respondToAskUserQuestion = useCallback(
(requestId: string, optionId: string) => {
socketRef.current?.respondToAskUserQuestion(requestId, optionId, {});
dispatchChat({ type: 'clear_ask_user_question', requestId });
},
[],
);
const statusLabel = useMemo(() => {
if (loadState.state === 'ready') {
return 'Connected';
}
if (loadState.state === 'error') {
return 'Offline';
}
return 'Starting';
}, [loadState]);
return (
<main className="desktop-shell">
<aside className="sidebar" aria-label="Sessions">
<div className="brand-lockup">
<div className="brand-mark" aria-hidden="true">
Q
</div>
<div>
<h1>Qwen Code</h1>
<p>Desktop</p>
</div>
</div>
<section className="sidebar-section">
<h2>Projects</h2>
<div className="workspace-path">
{activeProject?.path || 'No folder selected'}
</div>
<button className="secondary-button" onClick={chooseWorkspace}>
Open Project
</button>
<ProjectList
activeProjectId={activeProjectId}
projects={projects}
onSelect={selectProject}
/>
</section>
<section className="sidebar-section sidebar-section-fill">
<h2>Threads</h2>
<button
className="primary-button"
disabled={loadState.state !== 'ready' || !activeProject}
onClick={createSession}
>
New Thread
</button>
<SessionList
activeSessionId={activeSessionId}
sessions={sessions}
onSelect={setActiveSessionId}
/>
</section>
</aside>
<section className="workbench" aria-label="Workbench">
<header className="topbar">
<div>
<p className="eyebrow">Local workspace</p>
<h2>{activeProject?.name || 'Qwen Code Desktop'}</h2>
<div className="topbar-meta">
<span>{statusLabel}</span>
<span>{activeProject?.gitBranch || 'No Git branch'}</span>
<span>
{activeProject
? formatGitStatus(activeProject.gitStatus)
: 'No project'}
</span>
</div>
</div>
<div className="topbar-actions">
<button
className="secondary-button"
disabled={!activeProject}
type="button"
onClick={refreshProjectGitStatus}
>
Refresh Git
</button>
<StatusPill state={loadState.state} />
</div>
</header>
<div className="workspace-grid">
<section className="panel panel-main">
<div className="panel-header">
<h3>Conversation</h3>
<span>
{chatState.streaming ? 'Streaming' : chatState.connection}
</span>
</div>
<ChatTimeline state={chatState} activeSessionId={activeSessionId} />
<PermissionPrompts
state={chatState}
onAskUserQuestionResponse={respondToAskUserQuestion}
onPermissionResponse={respondToPermission}
/>
<form className="composer" onSubmit={sendMessage}>
<textarea
aria-label="Message"
disabled={!activeSessionId}
onChange={(event) => setMessageText(event.target.value)}
placeholder={activeSessionId ? 'Message Qwen Code' : ''}
rows={3}
value={messageText}
/>
<div className="composer-actions">
<button
className="secondary-button"
disabled={!chatState.streaming}
type="button"
onClick={stopGeneration}
>
Stop
</button>
<button
className="primary-button"
disabled={!activeSessionId || messageText.trim().length === 0}
type="submit"
>
Send
</button>
</div>
</form>
</section>
<section className="panel panel-side">
<div className="panel-header">
<h3>Review</h3>
</div>
<ReviewSummary
commitMessage={commitMessage}
gitDiff={gitDiff}
project={activeProject}
reviewError={reviewError}
onCommit={commitChanges}
onCommitMessageChange={setCommitMessage}
onRevertAll={revertAllChanges}
onStageAll={stageAllChanges}
/>
<RuntimeDetails loadState={loadState} />
<SessionDetails
activeSessionId={activeSessionId}
chatState={chatState}
modelState={modelState}
sessionError={sessionError}
onModeChange={changeMode}
onModelChange={changeModel}
/>
<SettingsPanel
state={settingsState}
onAuthenticate={authenticate}
onDispatch={dispatchSettings}
onSave={saveSettings}
/>
</section>
</div>
</section>
</main>
);
}
function SessionList({
activeSessionId,
sessions,
onSelect,
}: {
activeSessionId: string | null;
sessions: DesktopSessionSummary[];
onSelect: (sessionId: string) => void;
}) {
if (sessions.length === 0) {
return <div className="empty-row">No sessions</div>;
}
return (
<div className="session-list">
{sessions.map((session) => (
<button
className={
session.sessionId === activeSessionId
? 'session-row session-row-active'
: 'session-row'
}
key={session.sessionId}
onClick={() => onSelect(session.sessionId)}
>
<span>{session.title || session.sessionId}</span>
<small>{session.cwd || session.sessionId}</small>
</button>
))}
</div>
);
}
function ProjectList({
activeProjectId,
projects,
onSelect,
}: {
activeProjectId: string | null;
projects: DesktopProject[];
onSelect: (projectId: string) => void;
}) {
if (projects.length === 0) {
return <div className="empty-row">No recent projects</div>;
}
return (
<div className="project-list">
{projects.map((project) => (
<button
className={
project.id === activeProjectId
? 'project-row project-row-active'
: 'project-row'
}
key={project.id}
onClick={() => onSelect(project.id)}
type="button"
>
<span>{project.name}</span>
<small>{project.gitBranch || 'No Git branch'}</small>
</button>
))}
</div>
);
}
function ChatTimeline({
activeSessionId,
state,
}: {
activeSessionId: string | null;
state: ChatState;
}) {
if (!activeSessionId) {
return <div className="conversation-empty">No session selected</div>;
}
if (state.items.length === 0) {
return <div className="conversation-empty">Session ready</div>;
}
return (
<div className="chat-timeline">
{state.items.map((item) => (
<TimelineItem item={item} key={item.id} />
))}
</div>
);
}
function TimelineItem({ item }: { item: ChatTimelineItem }) {
if (item.type === 'message') {
return (
<article className={`chat-message chat-message-${item.role}`}>
<div className="message-role">{item.role}</div>
<p>{item.text}</p>
</article>
);
}
if (item.type === 'tool') {
return (
<article className="chat-tool">
<div className="message-role">{item.toolCall.kind || 'tool'}</div>
<strong>{item.toolCall.title || item.toolCall.toolCallId}</strong>
{item.toolCall.status ? <span>{item.toolCall.status}</span> : null}
</article>
);
}
if (item.type === 'plan') {
return (
<article className="chat-plan">
<div className="message-role">plan</div>
<ol>
{item.entries.map((entry) => (
<li key={`${entry.content}-${entry.status}`}>
<span>{entry.status}</span>
{entry.content}
</li>
))}
</ol>
</article>
);
}
return <div className="chat-event">{item.label}</div>;
}
function PermissionPrompts({
onAskUserQuestionResponse,
onPermissionResponse,
state,
}: {
onAskUserQuestionResponse: (requestId: string, optionId: string) => void;
onPermissionResponse: (requestId: string, optionId: string) => void;
state: ChatState;
}) {
const permission = state.pendingPermission;
const question = state.pendingAskUserQuestion;
if (!permission && !question) {
return null;
}
return (
<div className="permission-strip">
{permission ? (
<section className="permission-panel">
<div>
<span className="message-role">
{permission.request.toolCall.kind || 'permission'}
</span>
<strong>
{permission.request.toolCall.title ||
permission.request.toolCall.toolCallId}
</strong>
</div>
<div className="permission-actions">
{permission.request.options.map((option) => (
<button
className={
option.kind.startsWith('reject')
? 'secondary-button'
: 'primary-button'
}
key={option.optionId}
onClick={() =>
onPermissionResponse(permission.requestId, option.optionId)
}
type="button"
>
{option.name}
</button>
))}
</div>
</section>
) : null}
{question ? (
<section className="permission-panel">
{question.request.questions.map((item) => (
<div key={`${item.header}-${item.question}`}>
<span className="message-role">{item.header}</span>
<strong>{item.question}</strong>
{item.options.length > 0 ? (
<ul className="question-options">
{item.options.map((option) => (
<li key={`${option.label}-${option.description}`}>
{option.label}
</li>
))}
</ul>
) : null}
</div>
))}
<div className="permission-actions">
<button
className="secondary-button"
onClick={() =>
onAskUserQuestionResponse(question.requestId, 'cancel')
}
type="button"
>
Cancel
</button>
<button
className="primary-button"
onClick={() =>
onAskUserQuestionResponse(question.requestId, 'proceed_once')
}
type="button"
>
Submit
</button>
</div>
</section>
) : null}
</div>
);
}
function ReviewSummary({
commitMessage,
gitDiff,
onCommit,
onCommitMessageChange,
onRevertAll,
onStageAll,
project,
reviewError,
}: {
commitMessage: string;
gitDiff: DesktopGitDiff | null;
onCommit: () => void;
onCommitMessageChange: (message: string) => void;
onRevertAll: () => void;
onStageAll: () => void;
project: DesktopProject | null;
reviewError: string | null;
}) {
if (!project) {
return (
<div className="review-summary">
<div className="empty-row">Open a project to inspect Git status.</div>
</div>
);
}
const status = project.gitStatus;
const changedFiles = gitDiff?.files ?? [];
return (
<div className="review-summary">
<div className="review-tabs" aria-label="Review sections">
<span>Changes</span>
<span>Files</span>
<span>Artifacts</span>
<span>Summary</span>
</div>
<dl className="runtime-details runtime-details-compact">
<div>
<dt>Branch</dt>
<dd>{status.branch || 'Not available'}</dd>
</div>
<div>
<dt>Modified</dt>
<dd>{status.modified}</dd>
</div>
<div>
<dt>Staged</dt>
<dd>{status.staged}</dd>
</div>
<div>
<dt>Untracked</dt>
<dd>{status.untracked}</dd>
</div>
<div>
<dt>Files</dt>
<dd>{changedFiles.length}</dd>
</div>
{status.error ? (
<div>
<dt>Git</dt>
<dd className="error-text">{status.error}</dd>
</div>
) : null}
</dl>
<div className="review-actions">
<button
className="secondary-button"
disabled={changedFiles.length === 0}
type="button"
onClick={onRevertAll}
>
Revert All
</button>
<button
className="secondary-button"
disabled={changedFiles.length === 0}
type="button"
onClick={onStageAll}
>
Stage All
</button>
</div>
<div className="changed-files">
{changedFiles.length === 0 ? (
<div className="empty-row">No changes</div>
) : (
changedFiles.map((file) => (
<details key={file.path} open={changedFiles.length === 1}>
<summary>
<span>{file.path}</span>
<small>{file.status}</small>
</summary>
<pre>{file.diff || 'No textual diff available.'}</pre>
</details>
))
)}
</div>
<div className="commit-box">
<input
aria-label="Commit message"
placeholder="Commit message"
value={commitMessage}
onChange={(event) => onCommitMessageChange(event.target.value)}
/>
<button
className="primary-button"
disabled={commitMessage.trim().length === 0}
type="button"
onClick={onCommit}
>
Commit
</button>
</div>
{reviewError ? <p className="error-text">{reviewError}</p> : null}
</div>
);
}
function StatusPill({ state }: { state: LoadState['state'] }) {
return <span className={`status-pill status-pill-${state}`}>{state}</span>;
}
function RuntimeDetails({ loadState }: { loadState: LoadState }) {
if (loadState.state === 'loading') {
return <div className="runtime-row muted">Checking service</div>;
}
if (loadState.state === 'error') {
return <div className="runtime-row error-text">{loadState.message}</div>;
}
return (
<dl className="runtime-details">
<div>
<dt>Server</dt>
<dd>{loadState.status.serverUrl}</dd>
</div>
<div>
<dt>Desktop</dt>
<dd>{loadState.status.runtime.desktop.version}</dd>
</div>
<div>
<dt>Platform</dt>
<dd>
{loadState.status.runtime.platform.type}-
{loadState.status.runtime.platform.arch}
</dd>
</div>
<div>
<dt>Node</dt>
<dd>{loadState.status.runtime.desktop.nodeVersion}</dd>
</div>
<div>
<dt>ACP</dt>
<dd>
{loadState.status.runtime.cli.acpReady ? 'Ready' : 'Not started'}
</dd>
</div>
<div>
<dt>Health</dt>
<dd>{loadState.status.health.uptimeMs} ms</dd>
</div>
</dl>
);
}
function SessionDetails({
activeSessionId,
chatState,
modelState,
onModeChange,
onModelChange,
sessionError,
}: {
activeSessionId: string | null;
chatState: ChatState;
modelState: ModelState;
onModeChange: (mode: DesktopApprovalMode) => void;
onModelChange: (modelId: string) => void;
sessionError: string | null;
}) {
const currentMode =
modelState.modes?.currentModeId || chatState.mode || 'default';
const currentModel =
modelState.models?.currentModelId || chatState.currentModelId || '';
return (
<div className="session-details">
<div className="panel-header panel-header-inline">
<h3>Session</h3>
</div>
<dl className="runtime-details">
<div>
<dt>Active</dt>
<dd>{activeSessionId || 'None'}</dd>
</div>
<div>
<dt>Mode</dt>
<dd>
{modelState.modes ? (
<select
disabled={!activeSessionId || modelState.savingMode}
value={currentMode}
onChange={(event) =>
onModeChange(event.target.value as DesktopApprovalMode)
}
>
{modelState.modes.availableModes.map((mode) => (
<option key={mode.id} value={mode.id}>
{mode.name}
</option>
))}
</select>
) : (
currentMode || 'Unknown'
)}
</dd>
</div>
<div>
<dt>Model</dt>
<dd>
{modelState.models ? (
<select
disabled={!activeSessionId || modelState.savingModel}
value={currentModel}
onChange={(event) => onModelChange(event.target.value)}
>
{modelState.models.availableModels.map((model) => (
<option key={model.modelId} value={model.modelId}>
{model.name}
</option>
))}
</select>
) : (
currentModel || 'Unknown'
)}
</dd>
</div>
<div>
<dt>Commands</dt>
<dd>{chatState.availableCommands.length}</dd>
</div>
<div>
<dt>Skills</dt>
<dd>{chatState.availableSkills.length}</dd>
</div>
<div>
<dt>Tokens</dt>
<dd>{chatState.latestUsage?.usage?.totalTokens ?? 'Unknown'}</dd>
</div>
{sessionError || chatState.error ? (
<div>
<dt>Error</dt>
<dd className="error-text">{sessionError || chatState.error}</dd>
</div>
) : null}
{modelState.error ? (
<div>
<dt>Config</dt>
<dd className="error-text">{modelState.error}</dd>
</div>
) : null}
</dl>
</div>
);
}
function SettingsPanel({
onAuthenticate,
onDispatch,
onSave,
state,
}: {
onAuthenticate: (methodId: string) => void;
onDispatch: Dispatch<SettingsAction>;
onSave: () => void;
state: SettingsState;
}) {
const provider = state.form.provider;
return (
<div className="settings-panel">
<div className="panel-header panel-header-inline">
<h3>Settings</h3>
</div>
<div className="settings-form">
<label>
<span>Provider</span>
<select
value={provider}
onChange={(event) =>
onDispatch({
type: 'set_provider',
provider: event.target.value as 'api-key' | 'coding-plan',
})
}
>
<option value="api-key">API key</option>
<option value="coding-plan">Coding Plan</option>
</select>
</label>
{provider === 'coding-plan' ? (
<label>
<span>Region</span>
<select
value={state.form.codingPlanRegion}
onChange={(event) =>
onDispatch({
type: 'set_coding_plan_region',
region: event.target.value as 'china' | 'global',
})
}
>
<option value="china">China</option>
<option value="global">Global</option>
</select>
</label>
) : (
<>
<label>
<span>Model</span>
<input
value={state.form.activeModel}
onChange={(event) =>
onDispatch({
type: 'set_active_model',
model: event.target.value,
})
}
/>
</label>
<label>
<span>Base URL</span>
<input
value={state.form.baseUrl}
onChange={(event) =>
onDispatch({
type: 'set_base_url',
baseUrl: event.target.value,
})
}
/>
</label>
</>
)}
<label>
<span>API key</span>
<input
autoComplete="off"
placeholder={
provider === 'coding-plan'
? state.settings?.codingPlan.hasApiKey
? 'Configured'
: ''
: state.settings?.openai.hasApiKey
? 'Configured'
: ''
}
type="password"
value={state.form.apiKey}
onChange={(event) =>
onDispatch({ type: 'set_api_key', apiKey: event.target.value })
}
/>
</label>
<div className="settings-actions">
<button
className="secondary-button"
type="button"
onClick={() => onAuthenticate('qwen-oauth')}
>
OAuth
</button>
<button
className="primary-button"
disabled={state.loading || state.saving}
type="button"
onClick={onSave}
>
{state.saving ? 'Saving' : 'Save'}
</button>
</div>
{state.settings ? (
<p className="settings-summary">
{state.settings.selectedAuthType || 'No auth'} ·{' '}
{state.settings.model.name || 'No model'}
</p>
) : null}
{state.error ? <p className="error-text">{state.error}</p> : null}
</div>
</div>
);
}
function handleSessionSocketMessage(
message: DesktopServerMessage,
dispatchChat: Dispatch<ChatAction>,
dispatchModel: Dispatch<ModelAction>,
): void {
dispatchChat({ type: 'server_message', message });
if (message.type === 'mode_changed' && isApprovalMode(message.mode)) {
dispatchModel({ type: 'mode_changed', mode: message.mode });
}
if (message.type === 'model_changed') {
dispatchModel({ type: 'model_changed', modelId: message.modelId });
}
}
function isApprovalMode(value: string): value is DesktopApprovalMode {
return (
value === 'plan' ||
value === 'default' ||
value === 'auto-edit' ||
value === 'yolo'
);
}
function formatGitStatus(status: DesktopProject['gitStatus']): string {
if (!status.isRepository) {
return 'No Git repository';
}
if (status.clean) {
return 'Clean';
}
return `${status.modified} modified · ${status.staged} staged · ${status.untracked} untracked`;
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : 'Desktop operation failed.';
}