mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 18:13:27 +00:00
🎛️ fix: Settle New Turns at the Bottom, Steady Status Dots, and Elapsed Time in the Panel (#15394)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions
* 🎛️ fix: Settle New Turns at the Bottom, Steady Status Dots, and Elapsed Time in the Panel * 🎛️ fix: Anchor Panel Timers, Order Turns by Trigger Time, Keep a Non-Color Status Cue
This commit is contained in:
parent
14d4f2789d
commit
044894e8bb
10 changed files with 146 additions and 68 deletions
|
|
@ -45,11 +45,18 @@ export const shouldShowElapsed = ({
|
|||
* so the tick never announces.
|
||||
*/
|
||||
const Elapsed = memo(function Elapsed({ index }: { index: number }) {
|
||||
const submissionStart = useRecoilValue(store.submissionStartFamily(index));
|
||||
return <ElapsedTimer start={submissionStart ?? undefined} />;
|
||||
});
|
||||
|
||||
/** The bare ticker behind `Elapsed`, for surfaces whose start time does not
|
||||
* come from the chat submission store (e.g. subagent turns started by their
|
||||
* trigger). Falls back to mount time when no start is known. */
|
||||
export const ElapsedTimer = memo(function ElapsedTimer({ start: startAt }: { start?: number }) {
|
||||
const localize = useLocalize();
|
||||
const { i18n } = useTranslation();
|
||||
const submissionStart = useRecoilValue(store.submissionStartFamily(index));
|
||||
const [mountTime] = useState(() => Date.now());
|
||||
const start = submissionStart ?? mountTime;
|
||||
const start = startAt ?? mountTime;
|
||||
const [seconds, setSeconds] = useState(() => elapsedSeconds(start));
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Bot, ChevronDown } from 'lucide-react';
|
|||
import { useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil';
|
||||
import type { ParentSubagentSummary } from 'librechat-data-provider';
|
||||
import { getMessageRowWidthClass } from '~/components/Chat/Messages/ui/MessageRow';
|
||||
import { subagentStatusIcon, subagentStatusLabelKey } from './status';
|
||||
import { subagentStatusDotClass, subagentStatusLabelKey } from './status';
|
||||
import { useParentSubagents } from './ParentSubagentsProvider';
|
||||
import { eventSubagentSelection } from './eventSelection';
|
||||
import { activeSubagentPanel } from '~/store/subagents';
|
||||
|
|
@ -185,7 +185,6 @@ function EventSubagentRows({
|
|||
className="divide-y divide-border-light border-t border-border-light"
|
||||
>
|
||||
{eventChildren.map((child) => {
|
||||
const StatusIcon = subagentStatusIcon(child.status);
|
||||
const agent = child.agentId == null ? undefined : agentsMap?.[child.agentId];
|
||||
const label = agent?.name || child.actorId || child.title;
|
||||
const canOpen = child.latestTaskId != null;
|
||||
|
|
@ -218,13 +217,18 @@ function EventSubagentRows({
|
|||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-text-secondary">
|
||||
<StatusIcon
|
||||
size={14}
|
||||
className={child.status === 'running' ? 'animate-spin' : undefined}
|
||||
{/* Fixed-width slot: the dot gives at-a-glance color, the text
|
||||
keeps a non-color cue, and neither can shift the row as
|
||||
statuses change length. */}
|
||||
<span className="flex w-24 shrink-0 items-center justify-end gap-1.5 text-xs text-text-secondary">
|
||||
<span
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full border border-border-heavy/40',
|
||||
subagentStatusDotClass(child.status),
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{localize(subagentStatusLabelKey(child.status))}
|
||||
<span className="truncate">{localize(subagentStatusLabelKey(child.status))}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ describe('SubagentActivity', () => {
|
|||
expect(screen.getByTestId('tool-approval')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('marks bounded tool details as shortened without claiming history is missing', () => {
|
||||
it('renders bounded tool details without any shortening caption', () => {
|
||||
render(
|
||||
<SubagentActivity
|
||||
activity={{
|
||||
|
|
@ -307,7 +307,9 @@ describe('SubagentActivity', () => {
|
|||
);
|
||||
|
||||
expect(screen.queryByText('bounded input')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_activity_details_truncated')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('com_ui_subagent_activity_details_truncated'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_subagent_thread_history_truncated')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -291,21 +291,11 @@ function SubagentPrompt({ prompt }: { prompt: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
export const hasTruncatedActivityDetails = (activity: ChildActivity): boolean =>
|
||||
activity.items.some(
|
||||
(item) =>
|
||||
(item.type === 'writing' && item.textTruncated === true) ||
|
||||
(item.type === 'reasoning' && item.textTruncated === true) ||
|
||||
(item.type === 'activity_label' && item.labelTruncated === true) ||
|
||||
(item.type === 'tool' && (item.inputTruncated === true || item.outputTruncated === true)),
|
||||
);
|
||||
|
||||
export function SubagentActivityContent({
|
||||
activity,
|
||||
activityId,
|
||||
state = 'ready',
|
||||
showPrompt = true,
|
||||
showDetailTruncationNotice = true,
|
||||
conversationId = null,
|
||||
onCancelControl,
|
||||
}: {
|
||||
|
|
@ -313,14 +303,12 @@ export function SubagentActivityContent({
|
|||
activityId?: string;
|
||||
state?: 'ready' | 'loading' | 'error';
|
||||
showPrompt?: boolean;
|
||||
showDetailTruncationNotice?: boolean;
|
||||
conversationId?: string | null;
|
||||
onCancelControl?: (controlId: string) => void;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const isSubmitting = activity.status === 'running' || activity.status === 'dispatched';
|
||||
const parts = useMemo(() => activity.items.map(toContentPart), [activity.items]);
|
||||
const activityDetailsTruncated = hasTruncatedActivityDetails(activity);
|
||||
|
||||
let body: React.ReactNode;
|
||||
if (state === 'loading') {
|
||||
|
|
@ -367,11 +355,6 @@ export function SubagentActivityContent({
|
|||
{localize('com_ui_subagent_control_history_truncated')}
|
||||
</div>
|
||||
)}
|
||||
{showDetailTruncationNotice && activityDetailsTruncated && (
|
||||
<div className="mb-3 text-xs italic text-text-secondary">
|
||||
{localize('com_ui_subagent_activity_details_truncated')}
|
||||
</div>
|
||||
)}
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -120,10 +120,13 @@ describe('SubagentConversation', () => {
|
|||
expect(container.querySelectorAll('.agent-turn')).toHaveLength(2);
|
||||
expect(container.querySelector('[data-subagent-conversation]')).toBeInTheDocument();
|
||||
expect(screen.queryByText('com_ui_prompt')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('com_ui_subagent_activity_details_truncated')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('com_ui_subagent_activity_details_truncated'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('com_ui_subagent_activity_details_unavailable'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('stream-elapsed')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', {
|
||||
|
|
|
|||
|
|
@ -7,13 +7,10 @@ import type { TMessageContentParts } from 'librechat-data-provider';
|
|||
import type { ReactNode } from 'react';
|
||||
import type { ChildConversationTurn } from './adapters';
|
||||
import type { TranslationKeys } from '~/hooks';
|
||||
import {
|
||||
hasTruncatedActivityDetails,
|
||||
SubagentActivityContent,
|
||||
SubagentStatus,
|
||||
} from './SubagentActivity';
|
||||
import { SubagentActivityContent, SubagentStatus } from './SubagentActivity';
|
||||
import ContentParts from '~/components/Chat/Messages/Content/ContentParts';
|
||||
import MessageRow from '~/components/Chat/Messages/ui/MessageRow';
|
||||
import { ElapsedTimer } from '~/components/Chat/Messages/Elapsed';
|
||||
import MessageIcon from '~/components/Chat/Messages/MessageIcon';
|
||||
import { isAbnormalTerminalStatus } from './status';
|
||||
import { useAgentsMapContext } from '~/Providers';
|
||||
|
|
@ -191,10 +188,9 @@ function ChildMessage({
|
|||
const agentsMap = useAgentsMapContext();
|
||||
const agent = agentId == null ? undefined : agentsMap?.[agentId];
|
||||
const label = agent?.name ?? turn.activity.title;
|
||||
const wholeActivityTruncated = turn.activity.activityTruncated === true;
|
||||
const detailsLimited = wholeActivityTruncated || hasTruncatedActivityDetails(turn.activity);
|
||||
const detailsLimited = turn.activity.activityTruncated === true;
|
||||
let limitedNotice: ReactNode;
|
||||
if (wholeActivityTruncated && onLoadDetails != null && detailState !== 'unavailable') {
|
||||
if (detailsLimited && onLoadDetails != null && detailState !== 'unavailable') {
|
||||
limitedNotice = (
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onLoadDetails}>
|
||||
{detailState === 'error'
|
||||
|
|
@ -202,15 +198,16 @@ function ChildMessage({
|
|||
: localize('com_ui_subagent_show_full_activity')}
|
||||
</Button>
|
||||
);
|
||||
} else if (wholeActivityTruncated) {
|
||||
limitedNotice = localize('com_ui_subagent_activity_details_unavailable');
|
||||
} else {
|
||||
/** Only item-level fields were shortened for display; the run's full
|
||||
* activity is otherwise present, so avoid the alarming "unavailable"
|
||||
* framing there. */
|
||||
limitedNotice = (
|
||||
<span className="italic">{localize('com_ui_subagent_activity_details_truncated')}</span>
|
||||
);
|
||||
limitedNotice = localize('com_ui_subagent_activity_details_unavailable');
|
||||
}
|
||||
let footer: ReactNode = null;
|
||||
if (isAbnormalTerminalStatus(turn.activity.status)) {
|
||||
footer = <SubagentStatus activity={turn.activity} />;
|
||||
} else if (turn.activity.status === 'running' || turn.activity.status === 'dispatched') {
|
||||
const triggeredAt = turn.trigger.createdAt ?? turn.trigger.externalEvent?.occurredAt;
|
||||
const startedAt = triggeredAt == null ? NaN : Date.parse(triggeredAt);
|
||||
footer = <ElapsedTimer start={Number.isFinite(startedAt) ? startedAt : undefined} />;
|
||||
}
|
||||
const iconData = {
|
||||
endpoint: EModelEndpoint.agents,
|
||||
|
|
@ -230,11 +227,7 @@ function ChildMessage({
|
|||
)
|
||||
}
|
||||
label={label}
|
||||
footer={
|
||||
isAbnormalTerminalStatus(turn.activity.status) ? (
|
||||
<SubagentStatus activity={turn.activity} />
|
||||
) : null
|
||||
}
|
||||
footer={footer}
|
||||
ariaLabel={label}
|
||||
headerPrefix=""
|
||||
isCreatedByUser={false}
|
||||
|
|
@ -245,7 +238,6 @@ function ChildMessage({
|
|||
activityId={`${turn.taskId}:assistant`}
|
||||
state={state}
|
||||
showPrompt={false}
|
||||
showDetailTruncationNotice={false}
|
||||
conversationId={conversationId}
|
||||
onCancelControl={onCancelControl}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1483,6 +1483,67 @@ describe('SubagentThreadPanel', () => {
|
|||
expect(screen.queryByText('com_ui_subagent_depth')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['newest known task', 'task-new', 'last'],
|
||||
['displaced older task', 'task-old-displaced', 'first'],
|
||||
] as const)(
|
||||
'places a turn missing from the durable window per its identity: %s',
|
||||
(_label, selectedTaskId, position) => {
|
||||
mockParentChildrenByThread = new Map([
|
||||
[
|
||||
'child-thread',
|
||||
{
|
||||
threadId: 'child-thread',
|
||||
parentMessageId: 'parent-message',
|
||||
parentToolCallId: 'tool-call',
|
||||
subagentType: 'researcher',
|
||||
subagentKind: 'agent',
|
||||
title: 'Research child',
|
||||
origin: 'tool',
|
||||
status: 'running',
|
||||
latestTaskId: 'task-new',
|
||||
tasks: [{ taskId: 'task-new', status: 'running' }],
|
||||
tasksTruncated: false,
|
||||
} as ParentSubagentSummary,
|
||||
],
|
||||
]);
|
||||
mockUseSubagentThreadQuery.mockReturnValue({
|
||||
data: {
|
||||
...completedView,
|
||||
turns: [
|
||||
{
|
||||
taskId: 'task-durable',
|
||||
trigger: { kind: 'parent_dispatch', summary: 'Durable window turn' },
|
||||
status: 'completed',
|
||||
activity: [{ type: 'writing', text: 'Durable result' }],
|
||||
activityTruncated: false,
|
||||
messages: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isReadinessPending: false,
|
||||
});
|
||||
const missingSelection: ActiveSubagentPanel = {
|
||||
...selection,
|
||||
prompt: 'Synthesized window turn',
|
||||
durable: { threadId: 'child-thread', taskId: selectedTaskId },
|
||||
};
|
||||
render(
|
||||
<RecoilRoot initializeState={({ set }) => set(activeSubagentPanel, missingSelection)}>
|
||||
<SubagentThreadPanel selection={missingSelection} />
|
||||
</RecoilRoot>,
|
||||
);
|
||||
|
||||
const turns = screen.getAllByTestId('conversation-turn');
|
||||
expect(turns).toHaveLength(2);
|
||||
const synthesizedIndex = position === 'last' ? 1 : 0;
|
||||
expect(turns[synthesizedIndex]).toHaveTextContent('Synthesized window turn');
|
||||
expect(turns[1 - synthesizedIndex]).toHaveTextContent('Durable window turn');
|
||||
},
|
||||
);
|
||||
|
||||
it('loads an exact older turn projection only after the local disclosure is opened', async () => {
|
||||
const truncatedView: SubagentThreadView = {
|
||||
...completedView,
|
||||
|
|
|
|||
|
|
@ -796,23 +796,38 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
return override == null ? selected : { ...selected, activity: override };
|
||||
});
|
||||
}
|
||||
// The API keeps the exact selected activity even when its bounded
|
||||
// chronological turn is the first item removed from the response.
|
||||
// Preserve that selection ahead of the retained newer continuation.
|
||||
const retained = [
|
||||
{
|
||||
taskId: taskId || `${selection.parentMessageId}:${selection.toolCallId}`,
|
||||
trigger: {
|
||||
kind:
|
||||
selection.event == null
|
||||
? ('parent_continuation' as const)
|
||||
: ('external_event' as const),
|
||||
summary: selection.prompt ?? activity.prompt ?? '',
|
||||
},
|
||||
activity,
|
||||
/** A turn absent from the durable window is either the thread's newest
|
||||
* delivery whose fetch has not landed yet (place it at the END, where
|
||||
* it will settle — a new event must not appear at the top and then
|
||||
* jump to the bottom) or an older selection displaced from the bounded
|
||||
* window (keep it ahead of the retained newer continuation). */
|
||||
const indexedChild = byThreadId.get(threadId);
|
||||
const selectedTaskCreatedAt = indexedChild?.tasks.find(
|
||||
(task) => task.taskId === taskId,
|
||||
)?.createdAt;
|
||||
const synthesizedTurn = {
|
||||
taskId: taskId || `${selection.parentMessageId}:${selection.toolCallId}`,
|
||||
trigger: {
|
||||
kind:
|
||||
selection.event == null
|
||||
? ('parent_continuation' as const)
|
||||
: ('external_event' as const),
|
||||
summary: selection.prompt ?? activity.prompt ?? '',
|
||||
...(selectedTaskCreatedAt == null ? {} : { createdAt: selectedTaskCreatedAt }),
|
||||
},
|
||||
...durableTurns,
|
||||
];
|
||||
activity,
|
||||
};
|
||||
/** Order by trigger time when both sides carry one; the separately
|
||||
* polled discovery index is only the fallback authority, since it can
|
||||
* briefly lag or lead the thread view. */
|
||||
const lastDurableCreatedAt = durableTurns[durableTurns.length - 1]?.trigger.createdAt;
|
||||
const appendSynthesized =
|
||||
selectedTaskCreatedAt != null && lastDurableCreatedAt != null
|
||||
? selectedTaskCreatedAt >= lastDurableCreatedAt
|
||||
: indexedChild?.latestTaskId === taskId;
|
||||
const retained = appendSynthesized
|
||||
? [...durableTurns, synthesizedTurn]
|
||||
: [synthesizedTurn, ...durableTurns];
|
||||
return retained.map((turn) => {
|
||||
const override = turnDetailOverrides.get(turn.taskId);
|
||||
return override == null ? turn : { ...turn, activity: override };
|
||||
|
|
@ -831,12 +846,14 @@ export default function SubagentThreadPanel({ selection }: { selection: ActiveSu
|
|||
];
|
||||
}, [
|
||||
activity,
|
||||
byThreadId,
|
||||
latestConversationTurns,
|
||||
movingWindowTurns,
|
||||
olderTurns,
|
||||
postRebaseTurns,
|
||||
rebaseTurns,
|
||||
retainedTurnsValid,
|
||||
threadId,
|
||||
selection,
|
||||
taskId,
|
||||
turnDetailOverrides,
|
||||
|
|
|
|||
|
|
@ -24,3 +24,13 @@ export const subagentStatusLabelKey = (status: SubagentThreadStatus) =>
|
|||
cancelled: 'com_ui_subagent_thread_status_cancelled',
|
||||
}) as const
|
||||
)[status];
|
||||
|
||||
/** Fixed-size status dot classes: a stable-width indicator that cannot make
|
||||
* rows shift as status text changes length. */
|
||||
export const subagentStatusDotClass = (status: SubagentThreadStatus): string => {
|
||||
if (status === 'completed') return 'bg-status-success';
|
||||
if (status === 'running') return 'animate-pulse bg-status-info';
|
||||
if (status === 'failed' || status === 'interrupted') return 'bg-status-error';
|
||||
if (status === 'cancelled') return 'bg-status-warning';
|
||||
return 'bg-text-tertiary';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2233,7 +2233,6 @@
|
|||
"com_ui_subagent_control_cancel_message": "Withdraw message",
|
||||
"com_ui_subagent_control_history": "Control history",
|
||||
"com_ui_subagent_control_history_truncated": "Earlier control activity is not shown.",
|
||||
"com_ui_subagent_activity_details_truncated": "Some activity details were shortened.",
|
||||
"com_ui_subagent_activity_details_unavailable": "Additional activity details are unavailable.",
|
||||
"com_ui_subagent_show_full_activity": "Show full activity",
|
||||
"com_ui_subagent_load_earlier_activity": "Load earlier activity",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue