98 lines
5.0 KiB
TypeScript
98 lines
5.0 KiB
TypeScript
import type { ReactElement } from "react";
|
|
import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from "react";
|
|
|
|
import type { ProviderProfile } from "../../types/domain";
|
|
import { DraftsList } from "./DraftsList";
|
|
|
|
interface CommandBarProps {
|
|
disabled: boolean;
|
|
interactionDisabled?: boolean;
|
|
submitMode: "turn" | "steer";
|
|
targetTraceId: string | null;
|
|
providerProfile: ProviderProfile;
|
|
onProviderProfile(profile: ProviderProfile): void;
|
|
onSubmit(value: string): Promise<void>;
|
|
onClear(): void;
|
|
pickedDraft: string | null;
|
|
onPickedDraftConsumed(): void;
|
|
disabledReason: string | null;
|
|
/**
|
|
* Notify the workbench that the user is actively typing. Wired by App.tsx
|
|
* to `store.recordActivity` so the Code Agent inactivity-timeout (driven
|
|
* by the activityRef plumbed in workbench.ts) treats a user who is reading
|
|
* or composing a follow-up as "active". This avoids HWLAB #795 style
|
|
* false-positive timeouts where the user is at the keyboard but the
|
|
* backend has not yet emitted the next trace event.
|
|
*/
|
|
onTyping(): void;
|
|
onPickDraft(draft: string): void;
|
|
}
|
|
|
|
export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, onTyping, interactionDisabled = false, ...props }: CommandBarProps): ReactElement {
|
|
const [value, setValue] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [draftsOpen, setDraftsOpen] = useState(false);
|
|
const inputRows = useMemo(() => commandInputRows(value), [value]);
|
|
|
|
useEffect(() => {
|
|
if (pickedDraft) {
|
|
setValue(pickedDraft);
|
|
onPickedDraftConsumed();
|
|
}
|
|
}, [pickedDraft, onPickedDraftConsumed]);
|
|
|
|
async function submit(event: FormEvent<HTMLFormElement>): Promise<void> {
|
|
event.preventDefault();
|
|
const next = value.trim();
|
|
if (!next || interactionDisabled || props.disabled || submitting) return;
|
|
setValue("");
|
|
setSubmitting(true);
|
|
const submitted = props.onSubmit(next);
|
|
window.setTimeout(() => setSubmitting(false), 0);
|
|
void submitted.catch(() => setSubmitting(false));
|
|
}
|
|
|
|
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>): void {
|
|
if (event.key !== "Enter" || event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
event.preventDefault();
|
|
event.currentTarget.form?.requestSubmit();
|
|
}
|
|
|
|
return (
|
|
<form className="command-bar" id="command-form" aria-label="Agent 输入" onSubmit={submit} title={interactionDisabled ? "正在检查登录状态" : disabledReason ?? steerTitle(props.submitMode, props.targetTraceId)}>
|
|
<label className="agent-timeout-control" htmlFor="code-agent-provider-profile">
|
|
<span>模型通道</span>
|
|
<select id="code-agent-provider-profile" aria-label="Code Agent 模型通道" value={props.providerProfile} disabled={interactionDisabled} onChange={(event) => props.onProviderProfile(event.currentTarget.value as ProviderProfile)}>
|
|
<option value="deepseek">DeepSeek</option>
|
|
<option value="dsflash-go">DeepSeek V4 Flash</option>
|
|
<option value="codex-api">Codex API</option>
|
|
<option value="minimax-m3">MiniMax-M3</option>
|
|
</select>
|
|
</label>
|
|
<div className="input-shell">
|
|
<span className="prompt-mark">hwlab</span>
|
|
<textarea id="command-input" className={inputRows.scrollable ? "is-scrollable" : undefined} value={value} placeholder="输入任务,Enter 发送,Shift+Enter 换行" rows={inputRows.rows} disabled={interactionDisabled} onChange={(event) => { setValue(event.currentTarget.value); onTyping(); }} onKeyDown={keyDown} />
|
|
</div>
|
|
<button className="command-button secondary command-drafts-toggle" id="command-drafts-toggle" type="button" aria-expanded={draftsOpen} aria-controls="command-drafts-panel" disabled={interactionDisabled} onClick={() => setDraftsOpen((open) => !open)}>最近输入</button>
|
|
<button className="command-button" id="command-send" type="submit" disabled={interactionDisabled || props.disabled || submitting}>{sendLabel(props.submitMode, submitting)}</button>
|
|
<button className="command-button secondary" id="command-clear" type="button" disabled={interactionDisabled} onClick={() => { setValue(""); props.onClear(); }}>清空</button>
|
|
{draftsOpen ? <DraftsList id="command-drafts-panel" onPick={(draft) => { props.onPickDraft(draft); setDraftsOpen(false); }} /> : null}
|
|
</form>
|
|
);
|
|
}
|
|
|
|
export function commandInputRows(value: string): { rows: number; scrollable: boolean } {
|
|
const physicalLines = Math.max(1, value.split("\n").length);
|
|
return { rows: Math.min(5, physicalLines), scrollable: physicalLines > 5 };
|
|
}
|
|
|
|
function sendLabel(submitMode: "turn" | "steer", submitting: boolean): string {
|
|
if (submitting) return submitMode === "steer" ? "引导中" : "发送中";
|
|
return submitMode === "steer" ? "引导" : "发送";
|
|
}
|
|
|
|
function steerTitle(submitMode: "turn" | "steer", targetTraceId: string | null): string | undefined {
|
|
if (submitMode !== "steer" || !targetTraceId) return undefined;
|
|
return `引导运行中的 turn: ${targetTraceId}`;
|
|
}
|