Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions apps/presentation/dashboard/src/data/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,7 @@ export async function createChatSession(
agentId?: string,
mode: "resume_latest" | "new" = "resume_latest",
signal?: AbortSignal,
agentModelId?: string,
) {
return requestJson<{
agent_id: string;
Expand All @@ -727,8 +728,23 @@ export async function createChatSession(
// silently re-point the steward channel away from its configured executor.
// A project Session carries only the host-issued reference, never a Goal.
body: JSON.stringify(context.kind === "project"
? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, mode }
: { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, mode, context_kind: context.kind }),
? { context_kind: "project", project_ref: context.projectRef, agent_id: agentId, agent_model_id: agentModelId, mode }
: { goal_id: context.kind === "goal" ? context.goalId : "", agent_id: agentId, agent_model_id: agentModelId, mode, context_kind: context.kind }),
});
}

export type AgentModelOption = { value: string; label: string };
export type AgentModelDiscovery = {
ok: true;
schema_version: "loopx_chat_agent_model_options_v1";
agent_id: string;
supported: boolean;
model: { id: string; label: string; selected: string; options: AgentModelOption[] } | null;
};

export function discoverAgentModelOptions(agentId: string, signal?: AbortSignal) {
return requestJson<AgentModelDiscovery>("/api/chat/agent-model-options", {
method: "POST", signal, body: JSON.stringify({agent_id: agentId}),
});
}

Expand All @@ -745,6 +761,7 @@ export type ChatSessionSummary = {
goal_id: string | null;
project_ref?: string | null;
agent_id: string;
agent_model_id?: string | null;
adapter_kind: string;
channel_id?: string;
status: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { useWorkspaceI18n } from "./i18n";
import type { ManagerChannelBinding, ManagerRuntimeSessionReadback } from "../../data/chat";
import type { WorkspaceAgentOption, WorkspaceGoal, WorkspaceGoalTab } from "./personal-workspace-model";
import { WorkspaceSelect } from "./workspace-select";
import { discoverAgentModelOptions, type AgentModelDiscovery } from "../../data/chat";
import { useEffect, useState } from "react";

export function ChannelHeader({
agents,
Expand All @@ -26,11 +28,13 @@ export function ChannelHeader({
onOpenNavigation,
onSelectGoalTab,
onSelectAgent,
onSelectAgentModel,
onReturnManagerHome,
refreshState,
readOnlySourceLabel,
resumeReviewed,
selectedAgentId,
selectedAgentModelId,
selectedGoal,
selectedGoalTab,
workspaceGrantLabel,
Expand Down Expand Up @@ -58,16 +62,49 @@ export function ChannelHeader({
onOpenNavigation?: () => void;
onSelectGoalTab: (tab: WorkspaceGoalTab) => void;
onSelectAgent: (agentId: string) => void;
onSelectAgentModel?: (modelId: string) => void;
onReturnManagerHome?: () => void;
refreshState?: "idle" | "loading" | "done" | "error";
readOnlySourceLabel?: string;
/** Resume opens the owner's reviewed preview instead of a local confirmation. */
resumeReviewed?: boolean;
selectedAgentId: string;
selectedAgentModelId?: string;
selectedGoal: WorkspaceGoal | null;
selectedGoalTab: WorkspaceGoalTab;
}) {
const { locale, t } = useWorkspaceI18n();
const [modelDiscovery, setModelDiscovery] = useState<AgentModelDiscovery | null>(null);
const [modelDiscoveryState, setModelDiscoveryState] = useState<"idle" | "loading" | "error">("idle");
useEffect(() => {
if (readOnlySourceLabel) return;
const controller = new AbortController();
setModelDiscovery(null);
setModelDiscoveryState("loading");
void discoverAgentModelOptions(selectedAgentId, controller.signal)
.then((result) => { if (!controller.signal.aborted) { setModelDiscovery(result); setModelDiscoveryState("idle"); } })
.catch(() => { if (!controller.signal.aborted) setModelDiscoveryState("error"); });
return () => controller.abort();
}, [readOnlySourceLabel, selectedAgentId]);
const model = modelDiscovery?.model;
const selectedModel = selectedAgentModelId === undefined ? model?.selected || model?.options[0]?.value || "" : selectedAgentModelId;
const modelControl = model && model.options.length ? (
<WorkspaceSelect
ariaLabel={locale === "zh-CN" ? "选择模型" : "Select model"}
className="personal-agent-select personal-model-select"
onChange={(value) => onSelectAgentModel?.(value)}
options={[
...(!selectedModel ? [{ disabled: true, label: locale === "zh-CN" ? "当前模型未知" : "Current model unknown", value: "" }] : []),
...(selectedModel && !model.options.some((option) => option.value === selectedModel)
? [{ disabled: true, label: `${locale === "zh-CN" ? "当前模型" : "Current model"}: ${selectedModel}`, value: selectedModel }] : []),
...model.options.map((option) => ({ label: option.label, value: option.value })),
]}
prefixLabel={locale === "zh-CN" ? "模型" : "Model"}
value={selectedModel}
/>
) : selectedAgentModelId === "" ? <small className="personal-model-discovery">{locale === "zh-CN" ? "当前模型未知" : "Current model unknown"}</small>
: modelDiscoveryState === "loading" ? <small className="personal-model-discovery">{locale === "zh-CN" ? "正在读取模型…" : "Reading models…"}</small>
: modelDiscoveryState === "error" ? <small className="personal-model-discovery">{locale === "zh-CN" ? "无法读取模型" : "Models unavailable"}</small> : null;
// The chip reports the selected executor, whose credential pays for it, and
// the resolved model, so an executor and a model that disagree are visible
// instead of arriving as one silent configuration.
Expand Down Expand Up @@ -138,9 +175,9 @@ export function ChannelHeader({
: null;

const runtimeControl = readOnlySourceLabel ? (
<span className="personal-read-only-source" title={t("header.readOnlySourceDescription", { source: readOnlySourceLabel })}><Eye size={15} />{readOnlySourceLabel}<small>{t("common.readOnly")}</small></span>
) : (
<WorkspaceSelect
<span className="personal-read-only-source" title={t("header.readOnlySourceDescription", { source: readOnlySourceLabel })}><Eye size={15} />{readOnlySourceLabel}<small>{t("common.readOnly")}</small></span>
) : <>
<WorkspaceSelect
ariaLabel={t("header.selectChatRuntime")}
className="personal-agent-select"
icon={<Bot size={16} />}
Expand All @@ -152,8 +189,9 @@ export function ChannelHeader({
}))}
prefixLabel={t("header.chatRuntime")}
value={selectedAgentId}
/>
);
/>
{modelControl}
</>;

const executionChipClass = managerExecutionUnavailable
? "personal-execution-chip is-unavailable" : "personal-execution-chip";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,7 @@ export type PersonalWorkspaceCallbacks = {
onStartLoopX?: (operation: "start" | "resume", agentId: string, goalId: string,
settings?: LoopXModeSettings) => void;
onSelectAgent?: (agentId: string) => void;
onSelectAgentModel?: (agentId: string, modelId: string) => void;
onSelectChannel?: (channel: WorkspaceChannel) => void;
onSelectGoal?: (goalId: string | null, view: WorkspaceGoalTab) => void;
onSelectTodo?: (todoId: string | null) => void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,7 @@ export function PersonalWorkspacePage({
readOnly = false,
typedActionsRevision = 0,
selectedAgentId: controlledAgentId,
selectedAgentModelId,
selectedGoalId: controlledGoalId,
statusSourceControl,
serviceNotice,
Expand Down Expand Up @@ -847,6 +848,7 @@ export function PersonalWorkspacePage({
// re-reads the store instead of waiting for the next mount.
typedActionsRevision?: number;
selectedAgentId?: string;
selectedAgentModelId?: string;
selectedGoalId?: string | null;
statusSourceControl?: StatusSourceControl;
serviceNotice?: ReactNode;
Expand Down Expand Up @@ -2151,13 +2153,15 @@ export function PersonalWorkspacePage({
else setSelectedGoalTab(tab);
}}
onSelectAgent={selectAgent}
onSelectAgentModel={(modelId) => callbacks.onSelectAgentModel?.(selectedAgentId, modelId)}
onReturnManagerHome={() => {
if (selectedWorkspaceRef) workspaceConversations?.onSelect(null);
setSelectedGoalTab("overview");
setManagerConversationReceiptVisible(false);
window.requestAnimationFrame(() => channelScrollRef.current?.scrollTo({ behavior: "smooth", top: 0 }));
}}
selectedAgentId={selectedAgentId}
selectedAgentModelId={selectedAgentModelId}
refreshState={refreshState}
readOnlySourceLabel={readOnly ? statusSourceControl?.activeSource.label : undefined}
selectedGoal={selectedGoal}
Expand Down
52 changes: 40 additions & 12 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,10 @@ function readPersonalAgentSelections() {
}
}

function agentModelSelectionKey(contextId: string, agentId: string) {
return `${contextId}:${agentId}`;
}

type PersonalManagerAnswer = {
lines: string[];
text: string;
Expand Down Expand Up @@ -1406,6 +1410,7 @@ function PersonalGoalHome({
const agentDefaultForContext = (targetContextId: string) =>
targetContextId === "manager" ? stewardExecutorAgentId ?? defaultAgentId : defaultAgentId;
const [selectedAgents, setSelectedAgents] = useState<Record<string, string>>(readPersonalAgentSelections);
const [selectedAgentModels, setSelectedAgentModels] = useState<Record<string, string>>({});
const selectedAgentId = selectedAgents[contextId] ?? agentDefaultForContext(contextId);
const selectedAgent = selectAvailableChatAgent(agentOptions, selectedAgentId, defaultAgentId);
const [agentMenuOpen, setAgentMenuOpen] = useState(false);
Expand Down Expand Up @@ -1584,7 +1589,11 @@ function PersonalGoalHome({
return () => { controller.abort(); clearTimeout(timer); };
}, [readOnly, conversationReturnSessionKey]);

function recordSessionAdmission(session: ChatSessionSummary) {
function recordSessionAdmission(targetContextId: string, session: ChatSessionSummary) {
const key = agentModelSelectionKey(targetContextId, session.agent_id);
const currentModel = session.agent_model_id ?? "";
setSelectedAgentModels((current) => current[key] === currentModel
? current : { ...current, [key]: currentModel });
const queues = chatSessionQueuesFollowUps(session);
const supportsSteering = chatSessionSupportsSteering(session);
setSteeringSessionIds((current) => {
Expand Down Expand Up @@ -1712,6 +1721,7 @@ function PersonalGoalHome({
if (cancelled) return;
const latest = currentChannelSession(history, selectedAgent.agentId);
latestDiscoveredSessionId = latest?.session_id ?? null;
if (latest) recordSessionAdmission(targetContextId, latest);
if (latest && !latest.resumable) {
newSessionRequired.current.add(sessionKey);
recordRuntimeBinding(targetContextId, {
Expand All @@ -1726,12 +1736,13 @@ function PersonalGoalHome({
// actually made for this context is sent.
const sessionEndpoint =
targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedAgent.agentId;
const created = await createChatSession(targetContext, sessionEndpoint, "resume_latest");
const created = await createChatSession(targetContext, sessionEndpoint, "resume_latest", undefined,
latest?.agent_model_id ?? selectedAgentModels[agentModelSelectionKey(targetContextId, sessionEndpoint ?? selectedAgent.agentId)]);
if (cancelled) return;
if (targetContext.kind === "manager" && created.session.manager_runtime) {
setManagerRuntime(created.session.manager_runtime);
}
recordSessionAdmission(created.session);
recordSessionAdmission(targetContextId, created.session);
sessionIds.current.set(sessionKey, created.session_id);
const activeSnapshot = history.snapshots.find(
(snapshot) => snapshot.session.session_id === created.session_id,
Expand Down Expand Up @@ -2216,8 +2227,8 @@ function PersonalGoalHome({
const key = `${goalId}:${agentId}`;
const existing = sessionIds.current.get(key);
if (existing) return existing;
const session = await createChatSession({ kind: "goal", goalId }, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", signal);
recordSessionAdmission(session.session);
const session = await createChatSession({ kind: "goal", goalId }, agentId, newSessionRequired.current.has(key) ? "new" : "resume_latest", signal, selectedAgentModels[key]);
recordSessionAdmission(goalId, session.session);
sessionIds.current.set(key, session.session_id);
newSessionRequired.current.delete(key);
recordRuntimeBinding(goalId, {agentId, resumable: true, sessionId: session.session_id, status: session.session.status});
Expand Down Expand Up @@ -2310,11 +2321,12 @@ function PersonalGoalHome({
const mode = newSessionRequired.current.has(sessionKey) ? "new" : "resume_latest";
const sessionEndpoint =
targetContext.kind === "manager" ? selectedAgents[targetContextId] : selectedRoute.agentId;
const session = await createChatSession(targetContext, sessionEndpoint, mode, preparationController.signal);
const session = await createChatSession(targetContext, sessionEndpoint, mode, preparationController.signal,
selectedAgentModels[agentModelSelectionKey(targetContextId, sessionEndpoint ?? selectedRoute.agentId)]);
if (targetContextId === "manager" && session.session.manager_runtime) {
setManagerRuntime(session.session.manager_runtime);
}
recordSessionAdmission(session.session);
recordSessionAdmission(targetContextId, session.session);
sessionId = session.session_id;
sessionIds.current.set(sessionKey, sessionId);
recordRuntimeBinding(targetContextId, {
Expand Down Expand Up @@ -2392,16 +2404,20 @@ function PersonalGoalHome({
// stored identity before offering a link; a failed read never hides the
// visible answer or retries the model Turn.
const completedMessageId = streamingMessageId;
if ((response.message || streamedText).length >= MIN_SEPARATE_ANSWER_LENGTH) {
void fetchChatSession(sessionId).then((stored) => {
void fetchChatSession(sessionId).then((stored) => {
const key = `${targetContextId}:${stored.session.agent_id}`;
if (sessionIds.current.get(key) === sessionId && !newSessionRequired.current.has(key)) {
recordSessionAdmission(targetContextId, stored.session);
}
if ((response.message || streamedText).length >= MIN_SEPARATE_ANSWER_LENGTH) {
const answer = stored.messages.find((item) =>
item.turn_id === streamed.turnId && ["agent", "assistant"].includes(item.role));
if (answer) updateConversationMessage(targetContextId, completedMessageId, {
sourceMessageId: answer.message_id, sourceSessionId: sessionId,
collaboration: answer.collaboration, returnDelivery: answer.return_delivery,
});
}).catch(() => { /* The original conversation remains readable. */ });
}
}
}).catch(() => { /* The original conversation remains readable. */ });
const todoProposals = response.proposals.filter(isTodoProposal);
// The channel already states where a team plan is confirmed: its answer
// names the Goal whose workspace holds the card, so a manager-channel
Expand Down Expand Up @@ -2585,7 +2601,7 @@ function PersonalGoalHome({
if (!sessionId) return;
try {
const restored = await resumeChatSession(sessionId);
recordSessionAdmission(restored.session);
recordSessionAdmission(targetContextId, restored.session);
sessionIds.current.set(sessionKey, sessionId);
newSessionRequired.current.delete(sessionKey);
recordRuntimeBinding(targetContextId, {
Expand Down Expand Up @@ -2633,6 +2649,16 @@ function PersonalGoalHome({
setAgentMenuOpen(false);
}

function chooseAgentModel(agentId: string, modelId: string) {
const selectionKey = agentModelSelectionKey(contextId, agentId);
if (selectedAgentModels[selectionKey] === modelId) return;
setSelectedAgentModels((current) => current[selectionKey] === modelId ? current : { ...current, [selectionKey]: modelId });
const key = `${contextId}:${agentId}`;
sessionIds.current.delete(key);
newSessionRequired.current.add(key);
recordRuntimeBinding(contextId, null);
}

function openGoalChat(goalId: string) {
onSelectGoal(goalId, "chat");
setMobilePanel("chat");
Expand Down Expand Up @@ -3094,6 +3120,7 @@ function PersonalGoalHome({
},
onRetryResumeRun: retryManagerSession,
onSelectAgent: chooseAgent,
onSelectAgentModel: chooseAgentModel,
onSelectGoal: (goalId, view) => { onSelectGoal(goalId ?? "", view); setMobilePanel("chat"); },
onSelectView,
onSelectTodo,
Expand All @@ -3118,6 +3145,7 @@ function PersonalGoalHome({
} : undefined}
readOnly={readOnly}
selectedAgentId={selectedAgent.agentId}
selectedAgentModelId={selectedAgentModels[agentModelSelectionKey(contextId, selectedAgent.agentId)]}
selectedGoalId={selectedGoal?.goalId ?? null}
serviceNotice={executionDiscoveryError
? <p className="personal-service-notice" role="status">{t(executionDiscoveryError === "partial" ? "runs.discoveryPartial" : "runs.discoveryOffline")}</p>
Expand Down
Loading
Loading