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
1 change: 1 addition & 0 deletions bricks/ai-portal/src/chat-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ function LegacyChatPanelComponent(
const streamContextValue = useMemo(
() => ({
lastDetail: null,
planMap: null,
toggleAutoScroll,
setUserClosedAside: () => {},
}),
Expand Down
17 changes: 6 additions & 11 deletions bricks/ai-portal/src/chat-stream/Aside/FlowApp/FlowApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,20 +164,15 @@ function LegacyActivityDetail(
return tasks.find((t) => t.id === activity.taskId)!;
}, [tasks, activity.taskId]);

const fixedTasks = useMemo(() => {
return [
{
...activityTask,
parent: undefined,
},
];
}, [activityTask]);

const { messages } = useConversationStream(
true,
activityTask.state,
fixedTasks,
errors
tasks,
errors,
{
rootTaskId: activity.taskId,
expandAskUser: true,
}
);

useImperativeHandle(ref, () => ({
Expand Down
36 changes: 22 additions & 14 deletions bricks/ai-portal/src/chat-stream/ChatStream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,18 +114,19 @@ export function ChatStreamComponent(
!NON_WORKING_STATES.includes(conversationState!);
const canChat = conversationDone || conversationState === "input-required";
const { flowMap, activityMap } = useFlowAndActivityMap(serviceFlows);
const { messages, jobMap, lastDetail, activeAskUser } = useConversationStream(
conversationAvailable,
conversation?.state,
tasks,
errors,
{
flowMap,
activityMap,
showHumanActions,
skipActivitySubTasks: true,
}
);
const { messages, jobMap, planMap, lastDetail, activeAskUser } =
useConversationStream(
conversationAvailable,
conversation?.state,
tasks,
errors,
{
flowMap,
activityMap,
showHumanActions,
skipActivitySubTasks: true,
}
);

useEffect(() => {
onDetailChange({
Expand Down Expand Up @@ -402,10 +403,11 @@ export function ChatStreamComponent(
const streamContextValue = useMemo(
() => ({
lastDetail,
planMap,
setUserClosedAside,
toggleAutoScroll,
}),
[lastDetail, toggleAutoScroll]
[lastDetail, planMap, toggleAutoScroll]
);

return (
Expand Down Expand Up @@ -442,7 +444,13 @@ export function ChatStreamComponent(
)}
</div>
))}
{activeAskUser && <AskUser task={activeAskUser.task} />}
{activeAskUser && (
<AskUser
task={activeAskUser.task}
parentJob={activeAskUser.parentJob}
parentTask={activeAskUser.parentTask}
/>
)}
{earlyFinished && (
<div className={styles.message}>
<AssistantMessage earlyFinished />
Expand Down
2 changes: 2 additions & 0 deletions bricks/ai-portal/src/chat-stream/StreamContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ import type { ActiveDetail } from "../shared/interfaces";

export interface StreamContextValue {
lastDetail: ActiveDetail | null;
planMap: Map<string, any> | null;
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The planMap type should be Map<string, PlanStep> | null instead of Map<string, any> | null for better type safety. The PlanStep type is available from ../shared/interfaces.

Copilot uses AI. Check for mistakes.
setUserClosedAside: Dispatch<React.SetStateAction<boolean>>;
toggleAutoScroll: (enabled: boolean) => void;
}

export const StreamContext = createContext<StreamContextValue>({
lastDetail: null,
planMap: null,
setUserClosedAside: () => {},
toggleAutoScroll: () => {},
});
25 changes: 17 additions & 8 deletions bricks/ai-portal/src/chat-stream/useConversationStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface UseConversationStreamOptions {
showHumanActions?: boolean;
skipActivitySubTasks?: boolean;
rootTaskId?: string;
expandAskUser?: boolean;
}

export function useConversationStream(
Expand All @@ -33,24 +34,31 @@ export function useConversationStream(
rootTaskId,
flowMap,
activityMap,
expandAskUser,
} = options || {};

return useMemo(() => {
if (!conversationAvailable) {
return {
messages: [],
lastDetail: null,
planMap: null,
activeAskUser: null,
};
}

const { chunks, jobMap, activeAskUser } = getFlatChunks(tasks, errors, {
flowMap,
activityMap,
skipActivitySubTasks,
enablePlan: true,
rootTaskId,
});
const { chunks, jobMap, planMap, activeAskUser } = getFlatChunks(
tasks,
errors,
{
flowMap,
activityMap,
skipActivitySubTasks,
enablePlan: true,
rootTaskId,
expandAskUser,
}
);
const messages: ChatMessage[] = [];

let prevAssistantMessage: MessageFromAssistant = {
Expand Down Expand Up @@ -147,7 +155,7 @@ export function useConversationStream(
messages.push(prevAssistantMessage);
}

return { messages, jobMap, lastDetail, activeAskUser };
return { messages, jobMap, planMap, lastDetail, activeAskUser };
}, [
conversationAvailable,
state,
Expand All @@ -158,5 +166,6 @@ export function useConversationStream(
showHumanActions,
skipActivitySubTasks,
rootTaskId,
expandAskUser,
]);
}
1 change: 1 addition & 0 deletions bricks/ai-portal/src/cruise-canvas/reducers/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ function mergeJobs(
"cmd",
"mentionedAiEmployeeId",
"hil",
"summary",
]);
if (aiEmployeeId !== undefined) {
restMessagesPatch.aiEmployeeId = aiEmployeeId;
Expand Down
2 changes: 0 additions & 2 deletions bricks/ai-portal/src/cruise-canvas/useConversationGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ export function useConversationGraph(
const { chunks, jobMap } = getFlatChunks(tasks, errors, {
flowMap,
activityMap,
skipActivitySubTasks: false,
enablePlan: true,
});

const nodes: GraphNode[] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
border: 1px solid rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
cursor: pointer;

&:hover {
background: rgba(255, 255, 255, 0.8);
}
}
}

Expand Down Expand Up @@ -180,4 +184,8 @@
border-radius: 13px;
color: rgba(0, 0, 0, 0.65);
cursor: pointer;

&:hover {
background: rgba(0, 0, 0, 0.06);
}
}
8 changes: 8 additions & 0 deletions bricks/ai-portal/src/shared/ActivityPlan/ActivityPlan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React, {
Fragment,
useContext,
useMemo,
useRef,
useState,
} from "react";
import { initializeI18n } from "@next-core/i18n";
Expand Down Expand Up @@ -40,6 +41,7 @@ export function ActivityPlan({ task }: ActivityPlanProps) {
const { flowMap, setActiveDetail } = useContext(TaskContext);
const { toggleAutoScroll } = useContext(StreamContext);
const flow = flowMap?.get(task.id);
const toggleRef = useRef<ReturnType<typeof setTimeout> | null>(null);

return (
<ActivityPlanContext.Provider value={{ collapsed }}>
Expand Down Expand Up @@ -71,6 +73,12 @@ export function ActivityPlan({ task }: ActivityPlanProps) {
onClick={() => {
setCollapsed((prev) => !prev);
toggleAutoScroll(false);
if (toggleRef.current) {
clearTimeout(toggleRef.current);
}
toggleRef.current = setTimeout(() => {
toggleAutoScroll(true);
}, 100);
}}
Comment on lines 73 to 82
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout stored in toggleRef.current should be cleaned up when the component unmounts to prevent calling toggleAutoScroll(true) on an unmounted component. Add a cleanup effect:

useEffect(() => {
  return () => {
    if (toggleRef.current) {
      clearTimeout(toggleRef.current);
    }
  };
}, []);

Copilot uses AI. Check for mistakes.
>
{t(K.SHOW_PROCESS)}
Expand Down
6 changes: 1 addition & 5 deletions bricks/ai-portal/src/shared/ActivityPlan/PlanStateIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,7 @@ export function PlanStateIcon({ state, filled }: PlanStateIconProps) {
}
)}
>
<WrappedIcon
lib="lucide"
icon="check"
strokeWidth={filled ? 5 : 3}
/>
<WrappedIcon lib="lucide" icon="check" strokeWidth={5} />
</span>
);
}
Expand Down
9 changes: 9 additions & 0 deletions bricks/ai-portal/src/shared/AskUser/AskUser.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@
.input {
margin-top: 20px;
}

.tips {
font-weight: 500;
color: #d48806;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
34 changes: 29 additions & 5 deletions bricks/ai-portal/src/shared/AskUser/AskUser.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
import React, { useContext } from "react";
import type { Task } from "../interfaces";
import { initializeI18n } from "@next-core/i18n";
import type { Job, Task } from "../interfaces";
import styles from "./AskUser.module.css";
import { useConversationStream } from "../../chat-stream/useConversationStream";
import { TaskContext } from "../TaskContext";
import { UserMessage } from "../../chat-stream/UserMessage/UserMessage";
import { AssistantMessage } from "../../chat-stream/AssistantMessage/AssistantMessage";
import { DONE_STATES } from "../constants";
import { ChatBox } from "../ChatBox/ChatBox";
import { K, locales, NS, t } from "./i18n";
import { WrappedIcon } from "../bricks";
import { StreamContext } from "../../chat-stream/StreamContext";

initializeI18n(NS, locales);

export interface AskUserProps {
task: Task | undefined;
parentJob: Job | undefined;
parentTask: Task;
}

export function AskUser({ task }: AskUserProps) {
const { conversationState, tasks, errors, finished, earlyFinished, replay } =
useContext(TaskContext);
export function AskUser({ task, parentJob, parentTask }: AskUserProps) {
const {
conversationState,
tasks,
errors,
finished,
earlyFinished,
replay,
activityMap,
} = useContext(TaskContext);
const { planMap } = useContext(StreamContext);
const canChat =
DONE_STATES.includes(conversationState) ||
conversationState === "input-required";
Expand All @@ -31,12 +47,20 @@ export function AskUser({ task }: AskUserProps) {
);

const done = DONE_STATES.includes(task?.state);
if (!done) {
if (done) {
return null;
}

const stepName =
activityMap?.get(parentTask.id)?.activity.name ??
(parentJob ? planMap?.get(parentJob.id)?.name : undefined);

return (
<div className={styles.container}>
<div className={styles.tips}>
<WrappedIcon lib="antd" icon="info-circle" theme="filled" />
{t(K.ASK_USER_TIPS, { name: stepName })}
</div>
{messages.map((msg, index, list) => (
<div className={styles.message} key={index}>
{msg.role === "user" ? (
Expand Down
24 changes: 24 additions & 0 deletions bricks/ai-portal/src/shared/AskUser/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { i18n } from "@next-core/i18n";

export enum K {
ASK_USER_TIPS = "ASK_USER_TIPS",
}

const en: Locale = {
[K.ASK_USER_TIPS]:
"Elevo will continue after the activity 【{{ name }}】 provides additional information.",
};

const zh: Locale = {
[K.ASK_USER_TIPS]: "Elevo将会在活动【{{ name }}】补充信息后继续执行。",
};

export const NS = "bricks/ai-portal/AskUser";

export const locales = { en, zh };

export const t = i18n.getFixedT(null, NS);

type Locale = { [k in K]: string } & {
[k in K as `${k}_plural`]?: string;
};
20 changes: 19 additions & 1 deletion bricks/ai-portal/src/shared/ChatBox/ChatBox.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import React, { useCallback, useContext, useEffect, useState } from "react";
import React, {
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { initializeI18n } from "@next-core/i18n";
import { showDialog, WrappedChatInput, WrappedIcon } from "../bricks";
import { K, locales, NS, t } from "./i18n";
import { TaskContext } from "../TaskContext";
import type { ConversationState } from "../interfaces";
import styles from "./ChatBox.module.css";
import { ICON_STOP } from "../constants";
import { ChatInput } from "../../chat-input";

initializeI18n(NS, locales);

Expand Down Expand Up @@ -51,6 +58,16 @@ export function ChatBox({
setTerminating(true);
}, [onTerminate]);

const inputRef = useRef<ChatInput>(null);

useEffect(() => {
if (canChat) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
Comment on lines +64 to +68
Copy link

Copilot AI Dec 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The timeout created in this effect should be cleaned up to prevent it from firing after the component unmounts or when canChat changes. Add a cleanup function:

useEffect(() => {
  if (canChat) {
    const timeoutId = setTimeout(() => {
      inputRef.current?.focus();
    }, 100);
    return () => clearTimeout(timeoutId);
  }
}, [canChat]);
Suggested change
if (canChat) {
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
let timeoutId: ReturnType<typeof setTimeout> | undefined;
if (canChat) {
timeoutId = setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
return () => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
};

Copilot uses AI. Check for mistakes.
}, [canChat]);

if (
showInput === "never" ||
(!canChat && supports?.intercept && showInput !== "always")
Expand All @@ -69,6 +86,7 @@ export function ChatBox({

return (
<WrappedChatInput
ref={inputRef}
placeholder={t(K.SEND_MESSAGE)}
autoFocus
autoFade
Expand Down
Loading
Loading