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
25 changes: 25 additions & 0 deletions apps/app/src/app/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,31 @@ select:disabled {
animation: soft-pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

/* Quick horizontal shake used to nudge the user toward an action (e.g. when
Enter is pressed while a follow-up message needs Steer/Queue selection). */
@keyframes ow-shake {
0%,
100% {
transform: translateX(0);
}
20% {
transform: translateX(-5px);
}
40% {
transform: translateX(5px);
}
60% {
transform: translateX(-3px);
}
80% {
transform: translateX(3px);
}
}

@utility animate-shake {
animation: ow-shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97);
}

/* Highlight animation for just-saved command */
@keyframes command-highlight {
0% {
Expand Down
7 changes: 7 additions & 0 deletions apps/app/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ export default {
"composer.placeholder": "Describe your task...",
"composer.remote_worker_paste_warning": "This is a remote worker. Sandboxes are remote too. To share files with it, upload them to the Shared folder in the sidebar.",
"composer.run_task": "Run task",
"composer.steer": "Steer",
"composer.steer_hint": "Send now and let the agent adjust mid-task",
"composer.queue": "Queue",
"composer.queue_hint": "Send once the agent finishes the current task",
"composer.queued_attachments_only": "{count} attachment(s)",
"composer.queued_count": "{count} queued",
"composer.escape_to_stop": "Hit Escape again to stop the agent",
"composer.skill_source": "Skill",
"composer.stop": "Stop",
"composer.tools_label": "Commands, skills, and MCPs",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/** @jsxImportSource react */
import { ListPlus, X } from "lucide-react";

import { t } from "@/i18n";

export type QueuedMessagesPanelProps = {
messages: string[];
onRemove: (index: number) => void;
};

/**
* Shows the follow-up messages the user has queued while the agent is busy.
* Rendered above the composer (mirrors the QuestionPanel header style). Each
* entry can be removed with an X. The whole panel hides when the queue is
* empty — callers should simply not render it in that case, but we also guard
* here for safety.
*/
export function QueuedMessagesPanel(props: QueuedMessagesPanelProps) {
if (props.messages.length === 0) return null;

return (
<div className="overflow-hidden border-b border-dls-border bg-transparent">
<div className="border-b border-dls-border px-4 py-3">
<div className="flex items-center gap-2.5">
<div className="flex size-5 shrink-0 items-center justify-center rounded-full border border-gray-7/40 bg-gray-3/40 text-gray-11">
<ListPlus size={12} />
</div>
<div className="text-sm font-medium leading-5 text-gray-12">
{t("composer.queued_count", { count: props.messages.length })}
</div>
</div>
</div>

<div className="max-h-48 space-y-2 overflow-auto px-4 py-3">
{props.messages.map((message, index) => (
<div
key={index}
className="flex items-start justify-between gap-3 rounded-xl border border-gray-6 bg-gray-1 px-3 py-2.5"
>
<div className="min-w-0 flex-1 whitespace-pre-wrap break-words text-sm leading-5 text-gray-11">
{message}
</div>
<button
type="button"
onClick={() => props.onRemove(index)}
className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-md text-gray-10 transition-colors hover:bg-gray-3 hover:text-gray-12"
title={t("common.remove")}
>
<X size={13} />
</button>
</div>
))}
</div>
</div>
);
}
147 changes: 129 additions & 18 deletions apps/app/src/react-app/domains/session/surface/composer/composer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/** @jsxImportSource react */
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import type { Agent } from "@opencode-ai/sdk/v2/client";
import { ArrowUp, ChevronRight, FileText, Paperclip, Plug, Settings, Square, Terminal, X, Zap } from "lucide-react";
import { ArrowUp, ChevronRight, FileText, ListPlus, Paperclip, Plug, Settings, Square, Terminal, X, Zap } from "lucide-react";
import fuzzysort from "fuzzysort";
import { OPENWORK_EXTENSION_CATALOG, type McpDirectoryInfo } from "../../../../../app/constants";
import type { CloudImportedPlugin, CloudImportedPluginFile } from "../../../../../app/cloud/import-state";
Expand Down Expand Up @@ -47,8 +47,11 @@ type ComposerProps = {
mentions: Record<string, "agent" | "file">;
onDraftChange: (value: string) => void;
onSend: () => void | Promise<void>;
onSteer: () => void | Promise<void>;
onQueue: () => void | Promise<void>;
onStop: () => void | Promise<void>;
busy: boolean;
queuedCount: number;
disabled: boolean;
modelUnavailable?: boolean;
statusLabel: string;
Expand Down Expand Up @@ -324,6 +327,50 @@ export function ReactSessionComposer(props: ComposerProps) {
draftRef.current = props.draft;
}, [props.draft]);

// Follow-up message UX (only relevant while the agent is busy):
// - Enter does NOT submit; instead it shakes the Steer/Queue buttons.
// - Escape arms a "Hit Escape again to stop the agent" prompt for 3s;
// a second Escape within that window stops the agent.
const [followupShake, setFollowupShake] = useState(false);
const [escapeArmed, setEscapeArmed] = useState(false);
const shakeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const escapeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const triggerFollowupShake = useCallback(() => {
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
setFollowupShake(true);
shakeTimerRef.current = setTimeout(() => setFollowupShake(false), 450);
}, []);

const disarmEscape = useCallback(() => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
setEscapeArmed(false);
}, []);

// Reset the escape-to-stop prompt whenever the agent stops being busy.
useEffect(() => {
if (!props.busy) disarmEscape();
}, [props.busy, disarmEscape]);

useEffect(() => () => {
if (shakeTimerRef.current) clearTimeout(shakeTimerRef.current);
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
}, []);

// Editor submit (Enter). While idle this sends normally; while busy a
// follow-up message must be explicitly Steered or Queued, so Enter only
// nudges the buttons.
const handleEditorSubmit = useCallback(() => {
if (props.busy) {
triggerFollowupShake();
return;
}
void props.onSend();
}, [props.busy, props.onSend, triggerFollowupShake]);

const slashMatch = props.draft.match(/^\/(\S*)$/);
const slashOpenNext = Boolean(slashMatch);
const slashQuery = slashMatch?.[1] ?? "";
Expand Down Expand Up @@ -758,6 +805,25 @@ export function ReactSessionComposer(props: ComposerProps) {
if (event.key === "Enter" && imeActive) {
return;
}
// Escape-to-stop while the agent is busy. Only when no menu is open so
// Escape can still close menus. First press arms a confirmation prompt
// for 3s; a second Escape within that window stops the agent.
const anyMenuOpen = agentMenuOpen || toolMenuOpen || Boolean(activeMenu);
if (event.key === "Escape" && props.busy && !anyMenuOpen) {
event.preventDefault();
if (escapeArmed) {
disarmEscape();
void props.onStop();
} else {
setEscapeArmed(true);
if (escapeTimerRef.current) clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = setTimeout(() => {
setEscapeArmed(false);
escapeTimerRef.current = null;
}, 3000);
}
return;
}
if (agentMenuOpen) {
const total = agents.length + 1;
if (event.key === "ArrowDown") {
Expand Down Expand Up @@ -1054,7 +1120,7 @@ export function ReactSessionComposer(props: ComposerProps) {
disabled={props.disabled}
placeholder={t("composer.placeholder")}
onChange={props.onDraftChange}
onSubmit={props.onSend}
onSubmit={handleEditorSubmit}
onExpandPastedText={handleExpandPastedText}
onPasteText={props.onPasteText}
onPaste={(event) => {
Expand Down Expand Up @@ -1369,27 +1435,72 @@ export function ReactSessionComposer(props: ComposerProps) {
</div>

{/*
Single action button that toggles between Stop and Run task.
When busy with no draft: Stop (cancels current run).
When busy with a draft: Run task (queues a follow-up).
When idle: Run task.
Action area.
- Idle: single "Run task" button (sends immediately).
- Busy: follow-up controls — "Steer" sends now (the agent
adjusts mid-task), "Queue" sends once the agent is idle,
and an outline "Stop" cancels the run. Steer/Queue are
disabled until there's something to send. Pressing Enter
while busy shakes Steer/Queue to prompt an explicit choice.
Escape arms a "Hit Escape again to stop the agent" prompt.
*/}
<div className="ml-auto flex shrink-0 items-end gap-1.5">
{props.busy && !canSend ? (
<button
type="button"
onClick={props.onStop}
className="inline-flex h-9 max-h-9 items-center gap-2 rounded-full bg-gray-12 px-4 text-[13px] font-medium text-gray-1 transition-colors hover:bg-gray-11"
title={t("composer.stop")}
>
<Square size={12} fill="currentColor" />
<span>{t("composer.stop")}</span>
</button>
{props.busy ? (
<>
{escapeArmed ? (
<span className="self-center pr-1 text-[12px] font-medium text-gray-10">
{t("composer.escape_to_stop")}
</span>
) : null}
<div className={`flex items-end gap-1.5 ${followupShake ? "animate-shake" : ""}`}>
<button
type="button"
onClick={canSend ? props.onSteer : undefined}
disabled={!canSend}
className={`inline-flex h-9 max-h-9 items-center gap-2 rounded-full px-4 text-[13px] font-medium transition-colors ${
canSend
? "bg-[var(--dls-accent)] text-[var(--dls-accent-fg)] hover:bg-[var(--dls-accent-hover)]"
: "bg-gray-4 text-gray-10"
}`}
title={t("composer.steer_hint")}
>
<Zap size={14} />
<span>{t("composer.steer")}</span>
</button>
<button
type="button"
onClick={canSend ? props.onQueue : undefined}
disabled={!canSend}
className={`relative inline-flex h-9 max-h-9 items-center gap-2 rounded-full px-4 text-[13px] font-medium transition-colors ${
canSend
? "bg-gray-12 text-gray-1 hover:bg-gray-11"
: "bg-gray-4 text-gray-10"
}`}
title={t("composer.queue_hint")}
>
<ListPlus size={14} />
<span>
{props.queuedCount > 0
? t("composer.queued_count", { count: props.queuedCount })
: t("composer.queue")}
</span>
</button>
</div>
<button
type="button"
onClick={props.onStop}
className="inline-flex h-9 max-h-9 items-center gap-2 rounded-full border border-dls-border bg-transparent px-4 text-[13px] font-medium text-gray-11 transition-colors hover:bg-gray-3"
title={t("composer.stop")}
>
<Square size={12} fill="currentColor" />
<span>{t("composer.stop")}</span>
</button>
</>
) : (
<button
type="button"
onClick={canSend ? props.onSend : props.busy ? props.onStop : undefined}
disabled={props.disabled || (!canSend && !props.busy)}
onClick={canSend ? props.onSend : undefined}
disabled={props.disabled || !canSend}
className={`inline-flex h-9 max-h-9 items-center gap-2 rounded-full px-4 text-[13px] font-medium transition-colors ${
!canSend || props.disabled
? "bg-gray-4 text-gray-10"
Expand Down
Loading
Loading