Skip to content
Open
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
13 changes: 11 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ import { AsyncResult } from "effect/reactivity";
import { isElectron } from "../env";
import { useDiffPanelStore } from "../diffPanelStore";
import { useActiveThreadRef } from "../hooks/useActiveThreadRef";
import { composerRequiresModifier } from "../composer-logic";
import { composerRequiresModifier, formatAssistantCitationForComposer } from "../composer-logic";
import {
derivePhase,
deriveActiveWorkStartedAt,
Expand Down Expand Up @@ -2806,7 +2806,11 @@ export default function ChatView(props: ChatViewProps) {
? "Available once the thread's worktree is set up."
: null;
const openSideChatStable = useStableCallback(() => void sideChat.open());
const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt));
const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open({ prompt }));
const openCitationInSideChatStable = useStableCallback(
(citation: AssistantCitation) =>
void sideChat.open({ draft: formatAssistantCitationForComposer(citation) }),
);
const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } =
useTimelineRewind({
environmentId,
Expand Down Expand Up @@ -3418,6 +3422,11 @@ export default function ChatView(props: ChatViewProps) {
citationRequest={citationRequest}
citationHistory={citationHistory}
onCiteAssistantText={citeAssistantTextStable}
onOpenCitationInSideChat={
sideChat.available && sideChatDisabledReason === null
? openCitationInSideChatStable
: undefined
}
{...(activeProject
? { onRunShellCommand: parentPanel?.runShellCommand ?? runShellCommandStable }
: {})}
Expand Down
53 changes: 39 additions & 14 deletions apps/web/src/components/chat/AssistantSelectionToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
type AssistantCitation,
type ScopedThreadRef,
} from "@t3tools/contracts";
import { QuoteIcon } from "lucide-react";
import { MessagesSquareIcon, QuoteIcon } from "lucide-react";
import { use, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
Expand All @@ -18,23 +18,26 @@ import {
type SelectionActionPoint,
} from "~/lib/selectionActions";
import { Button } from "../ui/button";
import { ButtonGroup } from "../ui/group";
import { BackgroundThreadContext } from "./backgroundThread";

export function AssistantSelectionToolbar({
viewport,
threadRef,
onCite,
onOpenInSideChat,
}: {
viewport: HTMLElement | null;
threadRef: ScopedThreadRef;
onCite: (citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor) => boolean;
onOpenInSideChat: ((citation: AssistantCitation) => void) | undefined;
}) {
const [selection, setSelection] = useState<{
citation: AssistantCitation;
position: SelectionActionPoint;
sourceAnchor: AssistantCitationSourceAnchor;
} | null>(null);
const toolbarRef = useRef<HTMLButtonElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
const actionsRef = useRef<ReturnType<typeof observeSelectionActions> | null>(null);
const backgroundThread = use(BackgroundThreadContext);

Expand Down Expand Up @@ -160,10 +163,11 @@ export function AssistantSelectionToolbar({
) {
return;
}
if (toolbar.disabled) return;
const firstAction = toolbar.querySelector<HTMLButtonElement>("button:not(:disabled)");
if (!firstAction) return;
event.preventDefault();
event.stopPropagation();
toolbar.focus({ preventScroll: true });
firstAction.focus({ preventScroll: true });
};
// Tab belongs to the foreground chat; a background timeline only follows its selection.
if (!backgroundThread) document.addEventListener("keydown", focusActions, true);
Expand All @@ -188,18 +192,18 @@ export function AssistantSelectionToolbar({
dismiss();
return true;
};
const openInSideChat = () => {
onOpenInSideChat?.(selection.citation);
window.getSelection()?.removeAllRanges();
dismiss();
};
return createPortal(
<Button
<ButtonGroup
ref={toolbarRef}
type="button"
size="xs"
variant="glass"
disabled={tooLong}
aria-label={tooLong ? "Selection is too long to cite" : "Cite selection in composer"}
aria-label="Selection actions"
className="fixed z-50 max-w-[calc(100vw-1rem)]"
style={{ left: selection.position.x, top: selection.position.y }}
onPointerDown={(event) => event.preventDefault()}
onClick={cite}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Escape" && !event.nativeEvent.isComposing) {
Expand All @@ -208,9 +212,30 @@ export function AssistantSelectionToolbar({
}
}}
>
<QuoteIcon aria-hidden="true" className="size-3.5" />
{tooLong ? "Shorten selection" : "Cite"}
</Button>,
<Button
type="button"
size="xs"
variant="glass"
disabled={tooLong}
aria-label={tooLong ? "Selection is too long to cite" : "Cite selection in composer"}
onClick={cite}
>
<QuoteIcon aria-hidden="true" className="size-3.5" />
{tooLong ? "Shorten selection" : "Cite"}
</Button>
{onOpenInSideChat && !tooLong ? (
<Button
type="button"
size="xs"
variant="glass"
aria-label="Ask about selection in a side chat"
onClick={openInSideChat}
>
<MessagesSquareIcon aria-hidden="true" className="size-3.5" />
Ask
</Button>
) : null}
</ButtonGroup>,
document.body,
);
}
3 changes: 3 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ interface MessagesTimelineProps {
citation: AssistantCitation,
sourceAnchor: AssistantCitationSourceAnchor,
) => boolean;
onOpenCitationInSideChat?: ((citation: AssistantCitation) => void) | undefined;
isWorking: boolean;
runlessWorkActive?: boolean;
activeTurnStartedAt?: string | null;
Expand Down Expand Up @@ -336,6 +337,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
citationRequest = null,
citationHistory = CITED_HISTORY_LOADING,
onCiteAssistantText,
onOpenCitationInSideChat,
isWorking,
runlessWorkActive = false,
activeTurnStartedAt = null,
Expand Down Expand Up @@ -1302,6 +1304,7 @@ const ConversationTimeline = memo(function ConversationTimeline({
viewport={timelineViewportElement}
threadRef={citationThreadRef}
onCite={onCiteAssistantText}
onOpenInSideChat={onOpenCitationInSideChat}
/>
) : null}
<LegendList<MessagesTimelineRow>
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/chat/SideChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
import { Trash2Icon, XIcon } from "lucide-react";
import { useEffect, useState } from "react";

import { useComposerDraftStore } from "../../composerDraftStore";
import { ComposerHandleContext } from "../../composerHandleContext";
import { useClientSettings } from "../../hooks/useSettings";
import type { SideChatParentPanel } from "../../lib/sideChatPane";
Expand Down Expand Up @@ -111,6 +112,7 @@ export default function SideChatPane(props: {
});
setDeleting(false);
if (result._tag === "Success") {
useComposerDraftStore.getState().clearDraftThread(threadRef);
useRightPanelStore.getState().closeSurface(parentRef, `side-chat:${threadId}`);
} else if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Expand All @@ -123,7 +125,7 @@ export default function SideChatPane(props: {
};

return (
<div data-side-chat-pane className="flex h-full min-h-0 flex-col">
<div data-side-chat-pane={threadId} className="flex h-full min-h-0 flex-col">
<div className="flex h-9 shrink-0 items-center gap-1 border-b border-border pr-1 pl-3">
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{shell?.title ?? "Side chat"}
Expand Down
27 changes: 22 additions & 5 deletions apps/web/src/components/chat/useSideChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,24 @@ import {
import { useRef } from "react";

import { newMessageId, newThreadId } from "~/lib/utils";
import { requestWorkspaceFocus } from "~/lib/workspaceFocus";
import { useComposerDraftStore } from "../../composerDraftStore";
import { useStableCallback } from "../../hooks/useStableCallback";
import { useRightPanelStore } from "../../rightPanelStore";
import { readThreadShell, waitForThreadShell } from "../../state/entities";
import { threadEnvironment } from "../../state/threads";
import { useAtomCommand } from "../../state/use-atom-command";
import { useOrchestrationCommand } from "../../state/use-orchestration-command";

/** `prompt` is sent as the first message; `draft` waits in the side chat's composer. */
interface SideChatSeed {
readonly prompt?: string;
readonly draft?: string;
}

/**
* Starts side chats from `threadRef`, or offers none when it is null. `open` forks the
* thread's latest stable point, sends `prompt` there, and shows the side chat's tab. It
* thread's latest stable point, applies the seed there, and shows the side chat's tab. It
* resolves false when the side chat did not start, or another one is still starting.
*/
export function useSideChat(input: {
Expand All @@ -39,7 +47,7 @@ export function useSideChat(input: {
const error = squashAtomCommandFailure(failure);
input.onError(error instanceof Error ? error.message : fallback);
};
const start = async (threadRef: ScopedThreadRef, prompt: string | undefined) => {
const start = async (threadRef: ScopedThreadRef, seed: SideChatSeed) => {
const targetThreadId = newThreadId();
const fork = await forkSideChat({
environmentId: threadRef.environmentId,
Expand Down Expand Up @@ -67,7 +75,10 @@ export function useSideChat(input: {
discard();
return false;
}
const text = prompt?.trim();
const text = seed.prompt?.trim();
if (seed.draft) {
useComposerDraftStore.getState().setPrompt(targetRef, seed.draft);
}
// Send before showing the tab: closing a tab whose side chat has no run deletes it.
if (text) {
const turn = await startThreadTurn({
Expand All @@ -88,13 +99,19 @@ export function useSideChat(input: {
}
}
useRightPanelStore.getState().openSideChat(threadRef, targetThreadId);
if (seed.draft) {
requestWorkspaceFocus({
area: "panel",
selector: `[data-side-chat-pane="${targetThreadId}"] [data-testid="composer-editor"]`,
});
}
return true;
};
const open = async (prompt?: string) => {
const open = async (seed: SideChatSeed = {}) => {
const { threadRef } = input;
if (!threadRef || pendingRef.current) return false;
pendingRef.current = true;
return start(threadRef, prompt).finally(() => {
return start(threadRef, seed).finally(() => {
pendingRef.current = false;
});
};
Expand Down
Loading
Loading