Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
271 changes: 222 additions & 49 deletions apps/web/src/components/ChatView.tsx

Large diffs are not rendered by default.

52 changes: 38 additions & 14 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel";
import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner";
import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight";
import { searchSlashCommandItems } from "./composerSlashCommandSearch";
import { MOBILE_COMPOSER_VIEW_TRANSITION_NAME } from "./draftHeroTransition";
import {
getComposerPromptInjectionState,
getComposerProviderState,
Expand Down Expand Up @@ -441,6 +442,8 @@ export interface ChatComposerProps {
activeThread: Thread | undefined;
isServerThread: boolean;
isLocalDraftThread: boolean;
forceExpandedOnMobile: boolean;
projectSelectionRequired: boolean;

// Session phase
phase: SessionPhase;
Expand Down Expand Up @@ -552,6 +555,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
activeThread,
isServerThread: _isServerThread,
isLocalDraftThread: _isLocalDraftThread,
forceExpandedOnMobile,
projectSelectionRequired,
phase,
isConnecting,
isSendBusy,
Expand Down Expand Up @@ -882,7 +887,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false);
const [isComposerFocused, setIsComposerFocused] = useState(false);
const isMobileViewport = useMediaQuery("max-sm");
const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused;
const isComposerCollapsedMobile =
isMobileViewport && !forceExpandedOnMobile && !isComposerFocused;

// ------------------------------------------------------------------
// Refs
Expand Down Expand Up @@ -1135,7 +1141,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
[activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers],
);
const collapsedComposerPrimaryActionDisabled =
phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent;
phase === "running" ||
isSendBusy ||
isConnecting ||
projectSelectionRequired ||
!composerSendState.hasSendableContent;
const collapsedComposerPrimaryActionLabel = "Send message";
const showMobilePendingAnswerActions =
isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
Expand Down Expand Up @@ -1924,6 +1934,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isConnecting ||
isComposerApprovalState ||
pendingUserInputs.length > 0 ||
projectSelectionRequired ||
(environmentUnavailable !== null && activePendingProgress === null)
) {
return false;
Expand Down Expand Up @@ -2024,6 +2035,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isConnecting,
isComposerApprovalState,
pendingUserInputs.length,
projectSelectionRequired,
environmentUnavailable,
activePendingProgress,
applyPromptReplacement,
Expand All @@ -2046,6 +2058,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onSubmit={submitComposer}
className="mx-auto w-full min-w-0 max-w-3xl"
data-chat-composer-form="true"
style={
isMobileViewport ? { viewTransitionName: MOBILE_COMPOSER_VIEW_TRANSITION_NAME } : undefined
}
>
<div
className={cn(
Expand All @@ -2061,9 +2076,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
ref={composerSurfaceRef}
data-chat-composer-mobile-collapsed={isComposerCollapsedMobile ? "true" : "false"}
className={cn(
"chat-composer-glass rounded-[20px] border transition-colors duration-200 has-focus-visible:border-ring/45",
"chat-composer-glass rounded-[20px] border transition-[background-color] duration-200 has-focus-visible:border-ring/45",
isDragOverComposer ? "border-primary/70 bg-accent/45" : "border-border",
environmentUnavailable ? "opacity-75" : null,
environmentUnavailable || projectSelectionRequired ? "opacity-75" : null,
composerProviderState.composerSurfaceClassName,
)}
onFocusCapture={(event) => {
Expand Down Expand Up @@ -2175,7 +2190,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
promptHasText={false}
isSendBusy={isSendBusy}
isConnecting={isConnecting}
isEnvironmentUnavailable={environmentUnavailable !== null}
isEnvironmentUnavailable={
environmentUnavailable !== null || projectSelectionRequired
}
isPreparingWorktree={false}
hasSendableContent={false}
preserveComposerFocusOnPointerDown
Expand Down Expand Up @@ -2409,17 +2426,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
? "Type your own answer, or leave this blank to use the selected option"
: showPlanFollowUpPrompt && activeProposedPlan
? "Add feedback to refine the plan, or leave this blank to implement it"
: environmentUnavailable
? `${environmentUnavailable.label}: ${connectionStatusText(
environmentUnavailable.connection,
)}`
: phase === "disconnected"
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, $use skills, or / for commands"
: projectSelectionRequired
? "Choose a project above to start a thread"
: environmentUnavailable
? `${environmentUnavailable.label}: ${connectionStatusText(
environmentUnavailable.connection,
)}`
: phase === "disconnected"
? "Ask for follow-up changes or attach images"
: "Ask anything, @tag files/folders, $use skills, or / for commands"
}
disabled={
isConnecting ||
isComposerApprovalState ||
projectSelectionRequired ||
(environmentUnavailable !== null && activePendingProgress === null)
}
/>
Expand All @@ -2436,7 +2456,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
promptHasText={false}
isSendBusy={isSendBusy}
isConnecting={isConnecting}
isEnvironmentUnavailable={environmentUnavailable !== null}
isEnvironmentUnavailable={
environmentUnavailable !== null || projectSelectionRequired
}
isPreparingWorktree={false}
hasSendableContent={false}
preserveComposerFocusOnPointerDown
Expand Down Expand Up @@ -2547,7 +2569,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
promptHasText={prompt.trim().length > 0}
isSendBusy={isSendBusy}
isConnecting={isConnecting}
isEnvironmentUnavailable={environmentUnavailable !== null}
isEnvironmentUnavailable={
environmentUnavailable !== null || projectSelectionRequired
}
isPreparingWorktree={isPreparingWorktree}
hasSendableContent={composerSendState.hasSendableContent}
preserveComposerFocusOnPointerDown={isMobileViewport}
Expand Down
108 changes: 108 additions & 0 deletions apps/web/src/components/chat/DraftHeroHeadline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { ScopedProjectRef } from "@t3tools/contracts";
import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
import { FolderPlusIcon } from "lucide-react";
import { useMemo } from "react";

import { useOpenAddProjectCommandPalette } from "~/commandPaletteContext";
import { useNewThreadHandler } from "~/hooks/useHandleNewThread";
import { useProjects, useThreadShells } from "~/state/entities";
import { sortProjectsForSidebar } from "../Sidebar.logic";
import {
Menu,
MenuItem,
MenuPopup,
MenuRadioGroup,
MenuRadioItem,
MenuSeparator,
MenuTrigger,
} from "../ui/menu";

interface DraftHeroHeadlineProps {
readonly activeProjectRef: ScopedProjectRef | null;
readonly activeProjectTitle: string | null;
}

export function DraftHeroHeadline({
activeProjectRef,
activeProjectTitle,
}: DraftHeroHeadlineProps) {
const projects = useProjects();
const threads = useThreadShells();
const handleNewThread = useNewThreadHandler();
const openAddProject = useOpenAddProjectCommandPalette();

const orderedProjects = useMemo(
() => sortProjectsForSidebar(projects, threads, "updated_at"),
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
[projects, threads],
);
const projectByKey = useMemo(
() =>
new Map(
orderedProjects.map(
(project) =>
[
scopedProjectKey(scopeProjectRef(project.environmentId, project.id)),
project,
] as const,
),
),
[orderedProjects],
);
const activeProjectKey = activeProjectRef === null ? "" : scopedProjectKey(activeProjectRef);
const hasResolvedProject = activeProjectTitle !== null;
const canChooseProject = orderedProjects.length > 0;
const shouldShowProjectMenu = canChooseProject;

const projectSelector = shouldShowProjectMenu ? (
<Menu>
<MenuTrigger
aria-label={hasResolvedProject ? "Change project" : "Choose a project"}
className="pointer-events-auto inline cursor-pointer border-current border-b border-dotted text-foreground underline-offset-8 transition-opacity hover:opacity-75 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
>
{activeProjectTitle ?? "Choose a project"}
</MenuTrigger>
<MenuPopup align="center" className="max-h-80 w-64 overflow-y-auto">
<MenuRadioGroup
value={activeProjectKey}
onValueChange={(value) => {
const project = projectByKey.get(value as string);
if (!project || value === activeProjectKey) {
return;
}
void handleNewThread(scopeProjectRef(project.environmentId, project.id), {
replace: true,
});
}}
>
{orderedProjects.map((project) => {
const key = scopedProjectKey(scopeProjectRef(project.environmentId, project.id));
return (
<MenuRadioItem key={key} value={key} closeOnClick>
<span className="min-w-0 truncate">{project.title}</span>
</MenuRadioItem>
);
})}
</MenuRadioGroup>
<MenuSeparator />
<MenuItem onClick={openAddProject}>
<FolderPlusIcon />
New project
</MenuItem>
</MenuPopup>
</Menu>
) : (
<span className="text-muted-foreground/60">{activeProjectTitle ?? "Add a project"}</span>
);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated

return (
<h1 className="mx-auto w-full max-w-5xl text-center font-normal text-3xl text-foreground tracking-tight sm:text-5xl">
{hasResolvedProject ? (
<>What should we build in {projectSelector}?</>
) : canChooseProject ? (
<>{projectSelector} to start</>
) : (
<>Add a project to start</>
)}
</h1>
);
}
5 changes: 5 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ interface MessagesTimelineProps {
contentInsetEndAdjustment: number;
onIsAtEndChange: (isAtEnd: boolean) => void;
onManualNavigation: () => void;
hideEmptyPlaceholder?: boolean;
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -212,6 +213,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
contentInsetEndAdjustment,
onIsAtEndChange,
onManualNavigation,
hideEmptyPlaceholder = false,
}: MessagesTimelineProps) {
const [expandedTurnIds, setExpandedTurnIds] = useState<ReadonlySet<TurnId>>(new Set());
const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState<ReadonlySet<string>>(new Set());
Expand Down Expand Up @@ -458,6 +460,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
);

if (rows.length === 0 && !isWorking) {
if (hideEmptyPlaceholder) {
return null;
}
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground/30">
Expand Down
Loading
Loading