diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 9916d916f69..1394a8c901e 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -97,9 +97,9 @@ export const INSTANCE_FEATURE_CATALOG: Record} /> } /> } /> - } /> + } /> } /> } /> } /> @@ -122,15 +120,14 @@ function boardRoutes() { } /> } /> }> - } /> - } /> - } /> + } /> + } /> } /> - } /> - } /> + } /> + } /> } /> {/* Needs attention folded into Connections (PAP-13254); keep legacy links working. */} - } /> + } /> } /> } /> } /> @@ -263,10 +260,7 @@ function boardRoutes() { } /> } /> } /> - } /> - {/* `/audit` merged into the single Activity page (PAP-16302). Existing deep - links keep working, preset to the agent-actions scope. */} - } /> + } /> {/* Conference Room Chat surfaces (PAP-136/PAP-137): routes stay registered but redirect to the company home while the experimental flag is off. The board-level `artifacts` mount below is the new @@ -276,16 +270,12 @@ function boardRoutes() { } /> } /> - {/* Task Chat Redesign dev harness — dev builds only, and additionally - gated by enableTaskChatRedesign (redirects to /dashboard when the - flag is off). */} - {import.meta.env.DEV ? ( - }> - } /> - - ) : null} + {/* Task Chat Redesign dev harness — gated by enableTaskChatRedesign; + redirects to /dashboard when the flag is off. */} + }> + } /> + } /> - } /> } /> } /> } /> @@ -310,7 +300,7 @@ function boardRoutes() { function AppsConnectEntryRoute() { const location = useLocation(); const searchParams = new URLSearchParams(location.search); - return canEnterAppsConnect(searchParams) ? : ; + return searchParams.get("byo") === "1" ? : ; } function InboxRootRedirect() { @@ -355,7 +345,7 @@ function LegacySettingsRedirect() { const { companyPrefix } = useParams<{ companyPrefix?: string }>(); if (loading) { - return ; + return
Loading...
; } const targetCompany = @@ -404,7 +394,7 @@ function LegacyToolsRedirect() { function legacyToolsRedirectTarget(tab?: string) { if (!tab) return "/apps/advanced/profiles"; - if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps/connections"; + if (tab === "applications" || tab === "connections" || tab === "overview" || tab === "examples") return "/apps"; return `/apps/advanced/${tab}`; } @@ -463,7 +453,7 @@ function CompanyRootRedirect() { const location = useLocation(); if (loading) { - return ; + return
Loading...
; } const targetCompany = selectedCompany ?? companies[0] ?? null; @@ -494,7 +484,7 @@ function UnprefixedBoardRedirect() { const { companies, selectedCompany, loading } = useCompany(); if (loading) { - return ; + return
Loading...
; } const targetCompany = selectedCompany ?? companies[0] ?? null; @@ -550,9 +540,9 @@ export function App() { } /> } /> } /> + } /> } /> } /> - } /> }> } /> @@ -580,7 +570,6 @@ export function App() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index 27bfa3fb348..81d0fa5b8a3 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -1,46 +1,15 @@ -import { useCallback, useEffect, useMemo, useRef, type ComponentProps } from "react"; +import { useEffect, useMemo, useState, type ComponentProps } from "react"; import { IssueChatThread } from "@/components/IssueChatThread"; import { useLiveRunTranscripts, type RunTranscriptSource } from "@/components/transcript/useLiveRunTranscripts"; import { commentsToTaskChatItems } from "@/components/task-chat/task-chat-adapter"; import { - assembleThreadItems, - attachSettledTurns, - buildTurnSummary, - coalesceSettledTurns, deriveRunStatusLabel, - isNestableLiveChild, isTerminalRunStatus, - prependIssueBrief, - settledRunChildren, transcriptToTaskChatItems, - type SettledTurnMergeMeta, } from "@/components/task-chat/transcript-adapter"; -import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescriptionBubble"; -import type { - TaskChatInteractionItem, - TaskChatItem, - TaskChatMessageItem, - TaskChatTurnItem, -} from "@/components/task-chat/task-chat-model"; -import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard"; -import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions"; -import type { FeedbackVoteValue } from "@paperclipai/shared"; -import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/TaskChatThreadView"; +import type { TaskChatItem } from "@/components/task-chat/task-chat-model"; +import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView"; import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer"; -import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow"; -import { useSidebar } from "@/context/SidebarContext"; -import { cn } from "@/lib/utils"; -import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; -import { latestSameRunHandoffTimestamp, type IssueChatComment } from "@/lib/issue-chat-messages"; -import { isLiveIssueRun, isTerminalIssueStatus } from "@/lib/liveIssueIds"; -import { workModeInEffectAt } from "@/lib/issue-timeline-events"; -import { workModeMetaFor } from "@/lib/work-mode-meta"; - -function toMs(value: Date | string | null | undefined): number { - if (!value) return 0; - const ms = new Date(value).getTime(); - return Number.isNaN(ms) ? 0 : ms; -} export type TaskChatThreadProps = ComponentProps; @@ -56,30 +25,12 @@ export type TaskChatThreadProps = ComponentProps; * - the live run transcript (useLiveRunTranscripts, the same poll+websocket * source the current thread uses) → the in-flight turn streams * thinking → tool → diff → responding, capped by a live "running" status - * pill. - * - * Run activity is grouped into TaskChatTurnItem: the in-flight run renders as - * ONE expandable parent row — its status line (whimsy gerund or in-flight - * tool state + elapsed + tokens) is the turn's single visible line, with the - * chronological tool activity nested behind an expand (PAP-354). Mid-run - * agent text (interstitial updates between tool calls) is ephemeral - * (PAP-361, round 9): while one streams it occupies a dedicated one-line row - * PERMANENTLY RESERVED above the status line (so the layout above never - * jumps), and when it finishes the text slides out and the slot sits empty — - * nothing persists; the run log and the classic transcript remain the - * archive. When the run terminates, its settled turn anchors after the run's - * last comment (comment.runId linkage) and — when it directly follows that - * reply bubble — attaches to it: the "✓ Worked · …" summary renders appended - * to the bubble's always-visible timestamp line (round 9), still expandable - * to the tool history. Turns without a reply bubble keep the standalone - * folded row. flag-OFF remains byte-for-byte IssueChatThread. + * pill. Only the non-terminal run streams; once it finishes its output + * lands as a comment. flag-OFF remains byte-for-byte IssueChatThread. */ export function TaskChatThread(props: TaskChatThreadProps) { const { comments, - interactions, - timelineEvents, - issueId = null, agentMap, userLabelMap, currentUserId, @@ -95,56 +46,11 @@ export function TaskChatThread(props: TaskChatThreadProps) { linkedRuns, liveRuns, activeRun, - onAttachImage, - imageUploadHandler, - mentions, - enableReassign, - reassignOptions, - currentAssigneeValue, - issueStatus, - issueAssigneeAgentId = null, - onAcceptInteraction, - onRejectInteraction, - onSubmitInteractionAnswers, - onCancelInteraction, - onSubmitInteractionVerdicts, - externalReferences, - threadHeader, - workModeChanges, - issueBrief, - feedbackVotes, - feedbackDataSharingPreference = "prompt", - feedbackTermsUrl = null, - onVote, } = props; - const linkedRunMetaById = useMemo(() => { - const map = new Map[number]>(); - for (const run of linkedRuns ?? []) map.set(run.runId, run); - return map; - }, [linkedRuns]); - - // Each agent reply is tagged with the mode its request ran under: the - // issue's work mode at the reply's run start (comment.runId linkage), - // reconstructed from the activity feed's work-mode switch history — not the - // issue's current mode, which the user may have changed since. - const agentModeLabelFor = useCallback( - (comment: IssueChatComment) => { - const runMeta = comment.runId ? linkedRunMetaById.get(comment.runId) : undefined; - const atMs = toMs(runMeta?.startedAt ?? runMeta?.createdAt ?? comment.createdAt); - return workModeMetaFor(workModeInEffectAt(workModeChanges ?? [], atMs, issueWorkMode)).label; - }, - [linkedRunMetaById, workModeChanges, issueWorkMode], - ); const commentItems = useMemo( - () => commentsToTaskChatItems(comments, { - agentMap, - userLabelMap, - currentUserId, - issueAssigneeAgentId, - agentModeLabelFor, - }), - [comments, agentMap, userLabelMap, currentUserId, issueAssigneeAgentId, agentModeLabelFor], + () => commentsToTaskChatItems(comments, { agentMap, userLabelMap, currentUserId }), + [comments, agentMap, userLabelMap, currentUserId], ); // Every run we might need a transcript for (history + live), deduped by id. @@ -185,333 +91,55 @@ export function TaskChatThread(props: TaskChatThreadProps) { // The single in-flight run whose turn we stream live (non-terminal). const liveRun = useMemo(() => { - if (isTerminalIssueStatus(issueStatus)) return null; - if (activeRun && isLiveIssueRun(activeRun, issueStatus)) return activeRun; - return (liveRuns ?? []).find((r) => isLiveIssueRun(r, issueStatus)) ?? null; - }, [activeRun, issueStatus, liveRuns]); + if (activeRun && !isTerminalRunStatus(activeRun.status)) return activeRun; + return (liveRuns ?? []).find((r) => !isTerminalRunStatus(r.status)) ?? null; + }, [activeRun, liveRuns]); - // Runs observed non-terminal while mounted: their turns ANIMATE the fold when - // they settle. Runs already terminal at mount collapse instantly. - const liveSeenRef = useRef>(new Set()); + // Re-render on a 1s cadence while live so the status pill's elapsed advances + // even between transcript chunks. + const [tick, setTick] = useState(0); useEffect(() => { - if (liveRun) liveSeenRef.current.add(liveRun.id); + if (!liveRun) return; + const id = window.setInterval(() => setTick((t) => t + 1), 1000); + return () => window.clearInterval(id); }, [liveRun]); - // Each terminal run's turn anchors immediately after the run's last comment - // (its reply bubble), via the comment.runId linkage — the summary line lands - // below the bubble, where the live "Running…" pill sat. A run with no linked - // comment (stopped run, or reply not yet fetched) instead slots into the - // backbone chronologically at its start time (PAP-367). - const lastCommentIdByRun = useMemo(() => { - const map = new Map(); - for (const comment of comments) { - if (comment.deletedAt || !comment.runId || !comment.id) continue; - map.set(comment.runId, comment.id); - } - return map; - }, [comments]); - - const { data: planDocument } = useIssuePlanDocument(issueId); - - // Comments, interactions, and the plan-doc marker merged into one - // chronological backbone (same sort keys and same-run handoff shift as the - // legacy buildIssueChatMessages), so plan-mode confirmation/question cards - // land where they happened in the conversation. - const orderedEntries = useMemo(() => { - const entries: { ms: number; order: number; id: string; item: TaskChatItem }[] = []; - // commentsToTaskChatItems skips deleted comments — mirror its filter so the - // two lists stay index-aligned. - const visibleComments = comments.filter((comment) => !comment.deletedAt); - visibleComments.forEach((comment, index) => { - const item = commentItems[index]; - if (!item) return; - entries.push({ ms: toMs(comment.createdAt), order: 1, id: item.id, item }); - }); - for (const interaction of interactions ?? []) { - const createdAtMs = toMs(interaction.createdAt); - const handoffAtMs = - interaction.kind === "request_confirmation" && interaction.sourceRunId - ? latestSameRunHandoffTimestamp({ - interactionCreatedAtMs: createdAtMs, - sourceRunId: interaction.sourceRunId, - comments, - timelineEvents: timelineEvents ?? [], - linkedRuns: linkedRuns ?? [], - liveRuns: liveRuns ?? [], - }) - : null; - const id = `interaction:${interaction.id}`; - entries.push({ - ms: handoffAtMs ?? createdAtMs, - order: 2, - id, - item: { id, kind: "interaction", interaction }, - }); - } - if (planDocument) { - const revision = planDocument.latestRevisionNumber ?? 1; - const id = `plan-doc:${planDocument.latestRevisionId ?? planDocument.id}`; - entries.push({ - ms: toMs(planDocument.updatedAt), - order: 0, - id, - item: { - id, - kind: "marker", - variant: "turn_boundary", - label: revision > 1 ? "Plan updated" : "Plan created", - detail: `rev ${revision} — see the Plan tab`, - }, - }); - } - return entries.sort( - (a, b) => a.ms - b.ms || a.order - b.order || a.id.localeCompare(b.id), - ); - }, [comments, commentItems, interactions, timelineEvents, linkedRuns, liveRuns, planDocument]); - - // Boolean gate (stable across the host's per-render brief objects) so the - // heavy assembly memo doesn't recompute on every parent render. - const hasBrief = Boolean(issueBrief); - const items = useMemo(() => { - // Settled turns for every terminal run whose transcript we have. Messages - // and thinking are excluded entirely (PAP-361): the final reply already - // landed as the run's comment bubble, interstitial updates are ephemeral - // (live-line only), and thinking stays in the run log / classic - // transcript — "Worked · N tools" expands to exactly the tool rows. - const settledTurns: { turn: TaskChatTurnItem; anchorCommentId: string | null; startMs: number }[] = []; - // Raw summary inputs per turn id, so back-to-back same-agent runs can - // coalesce into one "Worked" row in the final pass (PAP-362). - const turnMergeMetaById = new Map(); - for (const source of runs) { - if (!isTerminalRunStatus(source.status)) continue; - if (liveRun && source.id === liveRun.id) continue; - const entries = transcriptByRun.get(source.id) ?? []; - if (entries.length === 0) continue; - const meta = linkedRunMetaById.get(source.id); - const started = meta?.startedAt ? new Date(meta.startedAt).getTime() : NaN; - const finished = meta?.finishedAt ? new Date(meta.finishedAt).getTime() : NaN; - const durationMs = - Number.isFinite(started) && Number.isFinite(finished) - ? Math.max(0, finished - started) - : undefined; - const parsed = transcriptToTaskChatItems(entries, { - runId: source.id, - agentName: meta?.agentName, - running: false, - }); - const children = settledRunChildren(parsed); - if (children.length === 0) continue; - const failed = source.status !== "succeeded"; - const startSlotRaw = meta?.startedAt ?? meta?.createdAt; - turnMergeMetaById.set(`${source.id}:turn`, { - agentKey: meta?.agentId ?? "", - agentName: meta?.agentName, - parts: [{ entries, durationMs, failed }], - }); - settledTurns.push({ - turn: { - id: `${source.id}:turn`, - kind: "turn", - settled: true, - animateFold: liveSeenRef.current.has(source.id), - items: children, - summary: buildTurnSummary(entries, { durationMs, failed }), - }, - anchorCommentId: lastCommentIdByRun.get(source.id) ?? null, - // Chronological slot for a turn with no reply comment to anchor to - // (PAP-367): the run's start time. A run with no linked meta yet - // (just-settled, list not refetched) sits at the thread tail — - // where it last rendered as the live turn — until meta arrives. - startMs: startSlotRaw ? toMs(startSlotRaw) : Number.POSITIVE_INFINITY, - }); - } - settledTurns.sort((a, b) => (a.startMs < b.startMs ? -1 : a.startMs > b.startMs ? 1 : 0)); - - const turnsByAnchor = new Map(); - const unanchored: { turn: TaskChatTurnItem; startMs: number }[] = []; - for (const { turn, anchorCommentId, startMs } of settledTurns) { - if (anchorCommentId) { - const list = turnsByAnchor.get(anchorCommentId) ?? []; - list.push(turn); - turnsByAnchor.set(anchorCommentId, list); - } else { - unanchored.push({ turn, startMs }); - } - } - - // Anchored turns follow their run's reply comment; comment-less turns - // (stopped runs, or a reply not yet fetched) interleave chronologically at - // their run's start time instead of piling up under the newest message - // (PAP-367). - const out = assembleThreadItems(orderedEntries, turnsByAnchor, unanchored); - - if (liveRun) { - const entries = transcriptByRun.get(liveRun.id) ?? []; - const parsed = transcriptToTaskChatItems(entries, { - runId: liveRun.id, - agentName: liveRun.agentName, - running: true, - }); - // Parent-row model (PAP-354): the run's status line IS the live turn — - // one expandable row owning the activity. Only tool/usage rows nest - // inside it; a streaming interstitial update renders in the reserved row - // above the status line (liveStatus.selfTalk, PAP-361/round 9) and - // vanishes when it completes. The parent row sits last; on settle its - // summary attaches to the reply bubble's timestamp line. - const children = parsed.filter(isNestableLiveChild); - const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null; - const queued = liveRun.status === "queued"; - const status = queued - ? { label: "Queued", detail: "Waiting to start", toolName: undefined, selfTalk: undefined } - : deriveRunStatusLabel(entries); - out.push({ - id: `${liveRun.id}:turn`, - kind: "turn", - settled: false, - items: children, - summary: buildTurnSummary(entries), - liveStatus: { - id: `${liveRun.id}:status`, - kind: "status", - status: "running", - label: status.label, - detail: status.detail, - toolName: status.toolName, - selfTalk: status.selfTalk, - startedAtMs: startedAt ?? undefined, - }, - }); - } - // PAP-362: two runs replying back-to-back (same agent, nothing but the - // agent's own bubbles between) fold into ONE "Worked" row below the last - // reply; a user message, interaction, or the live turn keeps them apart. - // Round 9: a settled turn directly following its own agent's reply bubble - // then attaches to that bubble — the "Worked · …" summary renders on the - // bubble's always-visible timestamp line instead of as a standalone row. - // PAP-375: the description-as-first-bubble placeholder prepends LAST, after - // every assembly/merge pass, so nothing can ever sort above it. - return prependIssueBrief( - attachSettledTurns(coalesceSettledTurns(out, turnMergeMetaById), turnMergeMetaById), - hasBrief, - ); - }, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]); - - // Feedback votes keyed by the comment they target (targetType - // "issue_comment"), mirroring IssueChatThread — the redesign attaches the - // 👍/👎 state to each agent bubble by its comment id (PAP-413). - const feedbackVoteByTargetId = useMemo(() => { - const map = new Map(); - for (const feedbackVote of feedbackVotes ?? []) { - if (feedbackVote.targetType !== "issue_comment") continue; - map.set(feedbackVote.targetId, feedbackVote.vote); - } - return map; - }, [feedbackVotes]); - - // copy · 👍 · 👎 cluster for an agent bubble's footer line (PAP-413). Human - // and system bubbles get nothing; copy is always available, and the feedback - // buttons render only when the host wired a vote handler. - const renderMessageActions = useCallback( - (item: TaskChatMessageItem) => { - if (item.author !== "agent" || item.optimistic) return null; - return ( - onVote(item.id, vote, options), - } - : null - } - /> - ); - }, - [onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl], - ); - - const renderInteraction = useCallback( - (item: TaskChatInteractionItem) => ( - - ), - [ - agentMap, - currentUserId, - userLabelMap, - onAcceptInteraction, - onRejectInteraction, - onSubmitInteractionAnswers, - onCancelInteraction, - onSubmitInteractionVerdicts, - imageUploadHandler, - externalReferences, - ], - ); - - // Mobile (PAP-360): the app shell scrolls the DOCUMENT (Layout's main is - // overflow-visible with auto height), so the desktop bounded h-dvh chain - // collapses the absolute-inset transcript viewport to 0px. Render the thread - // in document flow instead (the same scroll={false} path the previews use) - // and track auto-follow against window scroll. Desktop stays byte-identical. - const { isMobile } = useSidebar(); - useWindowAutoFollow(isMobile ? taskChatContentKey(items) : 0, isMobile); + if (!liveRun) return commentItems; + const entries = transcriptByRun.get(liveRun.id) ?? []; + const turn = transcriptToTaskChatItems(entries, { + runId: liveRun.id, + agentName: liveRun.agentName, + running: true, + }); + const startedAt = liveRun.startedAt ? new Date(liveRun.startedAt).getTime() : null; + const elapsedMs = startedAt != null ? Math.max(0, Date.now() - startedAt) : undefined; + const queued = liveRun.status === "queued"; + const status = queued ? { label: "Queued", detail: "Waiting to start" } : deriveRunStatusLabel(entries); + const statusItem: TaskChatItem = { + id: `${liveRun.id}:status`, + kind: "status", + status: "running", + label: status.label, + detail: status.detail, + elapsedMs, + }; + return [...commentItems, ...turn, statusItem]; + // `tick` is intentionally a dependency so elapsed advances each second. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [commentItems, liveRun, transcriptByRun, tick]); return ( -
-
+
+
{items.length === 0 ? ( -
- {threadHeader ? ( -
- {threadHeader} -
- ) : null} -
{emptyMessage}
-
+
{emptyMessage}
) : ( - : undefined} - renderMessageActions={renderMessageActions} - scroll={!isMobile} - /> + )}
{showComposer ? ( -
+
{composerAccessory} {footer}
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 48112bd8f39..7ec2298b658 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -1,9 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react"; -import { createPortal } from "react-dom"; -import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { issueStatusText } from "@/lib/status-colors"; -import { copyTextToClipboard } from "@/lib/clipboard"; import { Link } from "@/lib/router"; import { deriveOriginatingActor, type Issue, type IssueLabel } from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -13,7 +10,6 @@ import { authApi } from "../../api/auth"; import { executionWorkspacesApi } from "../../api/execution-workspaces"; import { instanceSettingsApi } from "../../api/instanceSettings"; import { issuesApi } from "../../api/issues"; -import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; import { projectsApi } from "../../api/projects"; import { useCompany } from "../../context/CompanyContext"; import { queryKeys } from "../../lib/queryKeys"; @@ -44,7 +40,6 @@ import { useRetryNowMutation } from "../../hooks/useRetryNowMutation"; import { RetryErrorBand } from "../IssueScheduledRetryCard"; import { StatusIcon } from "../StatusIcon"; import { PriorityIcon } from "../PriorityIcon"; -import { SHOW_TASK_PRIORITY_UI } from "../../lib/ui-flags"; import { Identity } from "../Identity"; import { IssueReferencePill } from "../IssueReferencePill"; import { formatDate, formatDateTime, cn, projectUrl } from "../../lib/utils"; @@ -100,7 +95,7 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: Compone useEffect(() => () => clearTimeout(timerRef.current), []); const handleCopy = useCallback(async () => { try { - await copyTextToClipboard(value); + await navigator.clipboard.writeText(value); setCopied(true); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => setCopied(false), 1500); @@ -175,41 +170,6 @@ export function IssueProperties({ // OFF renders today's stacked sections verbatim (no Tabs wrapper). This pane // is always task-scoped, so the flag alone is a sufficient gate. const taskChatRedesignEnabled = experimentalSettings?.enableTaskChatRedesign === true; - // When hosted by the redesigned PropertiesPanel, the tab strip portals into - // the pane's header bar (left of the window controls). The slot only exists - // once the panel has committed, hence the effect; inline hosts (mobile sheet) - // keep the tab strip in place. - const [paneHeaderSlot, setPaneHeaderSlot] = useState(null); - useEffect(() => { - if (!taskChatRedesignEnabled || inline) { - setPaneHeaderSlot(null); - return; - } - setPaneHeaderSlot(document.getElementById(PROPERTIES_PANE_HEADER_SLOT_ID)); - }, [taskChatRedesignEnabled, inline]); - // Plan earns a tab as soon as an issue is in planning mode, even before the - // plan document arrives. This keeps an expected plan surface visible and - // lets its diagnostic empty state explain what is missing. - // Same query keys as the tab bodies, so these share their cached fetches. - const { data: paneTabPlanDocument } = useIssuePlanDocument( - taskChatRedesignEnabled ? issue.id : null, - ); - const { data: paneTabAcceptedPlans } = useQuery({ - queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id), - queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id), - enabled: taskChatRedesignEnabled, - }); - const { data: paneTabAttachments } = useQuery({ - queryKey: queryKeys.issues.attachments(issue.id), - queryFn: () => issuesApi.listAttachments(issue.id), - enabled: taskChatRedesignEnabled, - }); - const hasPlanTab = - Boolean(paneTabPlanDocument) - || (paneTabAcceptedPlans?.length ?? 0) > 0 - || issue.workMode === "planning"; - const hasArtifactsTab = (paneTabAttachments?.length ?? 0) > 0; - const [paneTab, setPaneTab] = useState("properties"); const [assigneeOpen, setAssigneeOpen] = useState(false); const [assigneeSearch, setAssigneeSearch] = useState(""); /** When a run is live, a selection is staged here until the operator confirms @@ -2027,16 +1987,13 @@ export function IssueProperties({ /> - {/* PAP-411: priority UI is hidden behind SHOW_TASK_PRIORITY_UI. Revive by flipping the flag. */} - {SHOW_TASK_PRIORITY_UI && ( - - onUpdate({ priority })} - showLabel - /> - - )} + + onUpdate({ priority })} + showLabel + /> + - {paneHeaderSlot - ? createPortal(Properties, paneHeaderSlot) - : null} - {propertiesBody} - - ); - } - - // Flag ON: wrap the same body in a Properties | Plan | Artifacts tab shell - // (v5 decision: singular "Plan", Docs merged into Artifacts). The Properties - // tab is unchanged. Panel hosts portal the strip into the pane header bar; - // portals keep React context, so the Tabs root still drives it. - // Fall back to Properties if the selected tab's content went away (or the - // selection was made on another issue). - const activePaneTab = - (paneTab === "plans" && !hasPlanTab) || (paneTab === "artifacts" && !hasArtifactsTab) - ? "properties" - : paneTab; - // In the pane header the strip stretches to the bar's full height and the - // active underline drops to bottom-0, so it hugs the header's border line. - const paneTabTriggerClass = paneHeaderSlot - ? "h-full group-data-[orientation=horizontal]/tabs:after:bottom-0" - : undefined; - const tabStrip = ( - - - Properties - - {hasPlanTab ? ( - - Plan - - ) : null} - {hasArtifactsTab ? ( - - Artifacts - - ) : null} - - ); + // Flag ON: wrap the same body in a Properties | Plans | Artifacts tab shell. + // Plans/Artifacts get their real content in a later step; the shell itself is + // the deliverable here, and the Properties tab is unchanged. return ( - - {paneHeaderSlot - ? createPortal( - // Portals keep React context but break the DOM tree the Tailwind - // group-selectors need: the active-tab underline is styled via - // `group-data-[orientation=horizontal]/tabs:*`, so restore that - // ancestor here (display: contents keeps it out of layout). -
- {tabStrip} -
, - paneHeaderSlot, - ) - : tabStrip} + + + Properties + Plans + Artifacts + {propertiesBody} - {hasPlanTab ? ( - - - - ) : null} - {hasArtifactsTab ? ( - - - - ) : null} + + + + + + ); } diff --git a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx index db42607c6a8..d157d2f942b 100644 --- a/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx +++ b/ui/src/components/issue-properties/IssuePropertiesPlansTab.tsx @@ -1,126 +1,40 @@ -import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import type { Issue, IssueThreadInteraction } from "@paperclipai/shared"; +import type { Issue } from "@paperclipai/shared"; import { issuesApi } from "@/api/issues"; import { queryKeys } from "@/lib/queryKeys"; import { IssuePlanDecompositionsSection } from "@/components/IssuePlanDecompositionsSection"; -import { MarkdownBody } from "@/components/MarkdownBody"; -import { DocumentAnnotationsCountChip, IssueDocumentAnnotations } from "@/components/IssueDocumentAnnotations"; -import { useIssuePlanDocument } from "@/hooks/useIssuePlanDocument"; -import { useLocation } from "@/lib/router"; -import { IssuePlanConfirmationActionBar } from "./IssuePlanConfirmationActionBar"; interface IssuePropertiesPlansTabProps { issue: Issue; - /** True when hosted outside the properties panel (mobile sheet) — the plan - * confirmation bar then renders in place instead of the pane footer slot. */ - inline?: boolean; -} - -function hasPendingPlanConfirmation(interactions: IssueThreadInteraction[] | undefined): boolean { - return (interactions ?? []).some( - (interaction) => - interaction.kind === "request_confirmation" - && interaction.status === "pending" - && interaction.payload.target?.type === "issue_document" - && interaction.payload.target.key === "plan", - ); } /** * Plans tab of the redesigned properties pane (flag: enableTaskChatRedesign). * - * Owns the plan surface with the flag ON: the `plan` document itself (formerly - * pinned above the tabs via IssueDocumentsSection, which the chat shell gates - * off) rendered above the accepted-plan decomposition history. Structured live - * PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in - * the /dev/task-chat-lab harness). + * Migrates the accepted-plan decomposition history (formerly pinned above the + * tabs via IssuePlanDecompositionsSection) into a dedicated tab. Structured live + * PlanEntry/todo streaming is a flagged protocol dependency (demonstrated in the + * /dev/task-chat-lab harness); until it lands, the tab shows accepted-plan + * revisions or an empty state. */ -export function IssuePropertiesPlansTab({ issue, inline }: IssuePropertiesPlansTabProps) { - const { data: planDocument, isLoading: planDocumentLoading } = useIssuePlanDocument(issue.id); - const location = useLocation(); - const [annotationPanelOpen, setAnnotationPanelOpen] = useState(false); +export function IssuePropertiesPlansTab({ issue }: IssuePropertiesPlansTabProps) { const { data } = useQuery({ queryKey: queryKeys.issues.acceptedPlanDecompositions(issue.id), queryFn: () => issuesApi.listAcceptedPlanDecompositions(issue.id), }); - const { data: interactions } = useQuery({ - queryKey: queryKeys.issues.interactions(issue.id), - queryFn: () => issuesApi.listInteractions(issue.id), - }); const hasPlans = (data?.length ?? 0) > 0; - const pendingPlanConfirmation = hasPendingPlanConfirmation(interactions); - if (!planDocument && !hasPlans) { + if (!hasPlans) { return ( - <> - {/* This is deliberately outside the plan-document gate: an interaction - can arrive before its plan document query resolves or persists. */} - -
- {planDocumentLoading ? ( - "Loading plan…" - ) : issue.workMode === "planning" ? ( -
-

This task is in plan mode but no plan document has been written yet.

- {pendingPlanConfirmation ? ( -

- A plan confirmation is pending, but the plan document it should confirm is missing. -

- ) : null} -
- ) : ( - "No plan yet. The plan document, accepted plans, and their revisions will appear here." - )} -
- +
+ No plan yet. Accepted plans and their revisions will appear here. +
); } return ( -
- {/* Pending plan confirmation: its CTAs pin to the pane's footer slot so - they stay visible while the plan scrolls. */} - - {planDocument ? ( -
-
- {`Revision ${planDocument.latestRevisionNumber ?? 1} · updated ${new Date(planDocument.updatedAt).toLocaleString([], { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - })}`} - setAnnotationPanelOpen((open) => !open)} - /> -
- - {planDocument.body} - -
- ) : null} - {hasPlans ? ( - - ) : null} +
+
); } diff --git a/ui/src/components/task-chat/TaskChatBubble.tsx b/ui/src/components/task-chat/TaskChatBubble.tsx index 528fbc985c9..454de31084b 100644 --- a/ui/src/components/task-chat/TaskChatBubble.tsx +++ b/ui/src/components/task-chat/TaskChatBubble.tsx @@ -1,62 +1,16 @@ -import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; -import { MarkdownBody } from "@/components/MarkdownBody"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { AgentIcon } from "@/components/AgentIconPicker"; -import { CommentAttributionChip } from "@/components/CommentAttributionChip"; -import { - Attachment, - AttachmentContent, - AttachmentDescription, - AttachmentGroup, - AttachmentMedia, - AttachmentTitle, - AttachmentTrigger, -} from "@/components/ui/attachment"; -import { extractAttachmentRefs, fileKindForName } from "./task-chat-attachments"; import type { TaskChatMessageItem } from "./task-chat-model"; interface TaskChatBubbleProps { item: TaskChatMessageItem; - /** - * The settled run turn rendered on this bubble's footer line (round 9): - * replaces the plain timestamp with "2:34 PM · ✓ Worked · 38s · 3 tools" - * (the timestamp leads the summary), expandable to the nested tool history. - * Supplied by TaskChatThreadView when `item.attachedTurn` is set. - */ - attachedTurn?: ReactNode; - /** - * copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413). - * Rendered here only for a runless reply (leading the bare timestamp); when - * an attached turn is present it owns these via its `leading` slot instead, - * so this bubble skips them. Human/system bubbles pass nothing. - */ - actions?: ReactNode; -} - -function initialsForName(name: string) { - const parts = name.trim().split(/\s+/); - if (parts.length >= 2) { - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); - } - return name.slice(0, 2).toUpperCase(); } /** * Author-typed message row — the primary legibility signal. Human messages sit * right in a solid accent bubble; agent messages sit left in a neutral card - * bubble with an avatar author header (the agent's assigned icon + name · mode - * chip); system notices are centered and recede. + * bubble with an author header; system notices are centered and recede. */ -export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubbleProps) { - if (item.interstitial) { - // Interstitial updates are ephemeral (PAP-361): while streaming the text - // lives on the live parent row's line (TaskChatStatusItem.selfTalk), and - // once finished it renders nowhere — the run log / classic transcript - // remain the archive. Never rendered as a bubble. - return null; - } - +export function TaskChatBubble({ item }: TaskChatBubbleProps) { if (item.author === "system") { return (
@@ -66,116 +20,31 @@ export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubblePr } const isHuman = item.author === "human"; - // Non-image file references ("[name](/api/attachments/…/content)") render as - // attachment chips under the bubble; link-only lines leave the body text. - const { refs: attachmentRefs, text: bodyText } = extractAttachmentRefs(item.text); return (
{!isHuman && item.authorName ? ( - - - {item.agentIcon ? ( - - - - ) : ( - {initialsForName(item.authorName)} - )} - - {item.authorName} - {item.onBehalfOfUserName ? ( - - ) : null} - {item.modeLabel ? ( - - {item.modeLabel} - - ) : null} - - ) : null} - {bodyText.length > 0 ? ( -
- - {bodyText} - -
- ) : null} - {attachmentRefs.length > 0 ? ( - - {attachmentRefs.map((ref) => { - const kind = fileKindForName(ref.name); - const KindIcon = kind.icon; - return ( - - - - - - {ref.name} - {kind.label} - - } - /> - - ); - })} - + {item.authorName} ) : null} +
+ {item.text} + {item.streaming ? ( + + ) : null} +
{item.optimistic ? ( {item.optimistic === "queued" ? "Queued" : "Sending…"} - ) : attachedTurn ? ( - // The settled turn takes over the footer line: timestamp + "✓ Worked" - // summary, always visible; expanding stretches beneath the bubble. The - // copy/👍/👎 actions (PAP-413) ride the turn's summary row via its - // `leading` slot — not this wrapper — so they stay anchored to the - // summary line when the tool history expands beneath it. -
- {attachedTurn} -
- ) : actions ? ( - // Agent reply without run activity: the actions still lead the footer, - // with the always-visible timestamp trailing (PAP-413). -
- {actions} - {item.timestamp ? ( - - {item.timestamp} - - ) : null} -
) : item.timestamp ? ( - // Timestamps are always visible (round 9) — no longer hover-revealed. - - {item.timestamp} - + {item.timestamp} ) : null}
); diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx index 5c7cca635b9..4b0bddb639b 100644 --- a/ui/src/components/task-chat/TaskChatComposer.tsx +++ b/ui/src/components/task-chat/TaskChatComposer.tsx @@ -1,135 +1,27 @@ -import { - useRef, - useState, - type ChangeEvent, - type ClipboardEvent as ReactClipboardEvent, - type CSSProperties, -} from "react"; +import { useState } from "react"; import { cn } from "@/lib/utils"; -import { ArrowUp, Check, ChevronDown, Loader2, Plus, X } from "lucide-react"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - Attachment, - AttachmentAction, - AttachmentActions, - AttachmentContent, - AttachmentDescription, - AttachmentGroup, - AttachmentMedia, - AttachmentTitle, -} from "@/components/ui/attachment"; -import { fileKindForName, formatFileSize } from "./task-chat-attachments"; -import { MarkdownEditor, type MarkdownEditorRef } from "@/components/MarkdownEditor"; -import { nextWorkMode, workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta"; -import type { InlineEntityOption } from "@/components/InlineEntitySelector"; -import type { MentionOption } from "@/components/MarkdownEditor"; -import type { IssueAttachment, IssueWorkMode } from "@paperclipai/shared"; - -/** Structurally identical to IssueChatThread's module-private CommentReassignment. */ -interface CommentReassignment { - assigneeAgentId: string | null; - assigneeUserId: string | null; -} +import { Check, ChevronDown, Send } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { workModeMetaFor, workModeMetaList } from "@/lib/work-mode-meta"; +import type { IssueWorkMode } from "@paperclipai/shared"; interface TaskChatComposerProps { - onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise | void; + onAdd: (body: string) => Promise | void; workMode: IssueWorkMode; onWorkModeChange?: (mode: IssueWorkMode) => Promise | void; disabled?: boolean; disabledReason?: string | null; placeholder?: string; - /** Preferred upload path: attaches the file to the task (mirrors legacy). */ - onAttachImage?: (file: File) => Promise; - /** Fallback upload path: returns a URL for inline image markdown. */ - onImageUpload?: (file: File) => Promise; - /** Mentionable entities for the editor's @-autocomplete. */ - mentions?: MentionOption[]; - enableReassign?: boolean; - reassignOptions?: InlineEntityOption[]; - currentAssigneeValue?: string; - issueStatus?: string; - /** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */ - mobile?: boolean; -} - -/** Per-mode hue token (see ui/src/index.css `--tc-mode-*`). */ -const MODE_HUE: Partial> = { - standard: "var(--tc-mode-agent)", - planning: "var(--tc-mode-plan)", - ask: "var(--tc-mode-ask)", -}; - -function modeHue(mode: IssueWorkMode): string { - return MODE_HUE[mode] ?? "var(--tc-mode-agent)"; -} - -const MODE_DESCRIPTION: Partial> = { - standard: "Make changes and run work", - planning: "Draft a plan before acting", - ask: "Answer questions only, no changes", -}; - -/** v7 per-mode placeholder copy; `{agent}` is the pending assignee's name. */ -function modePlaceholder(mode: IssueWorkMode, agentName: string): string { - switch (mode) { - case "planning": - return `Plan with ${agentName} — shapes the plan doc, no code changes…`; - case "ask": - return `Ask ${agentName} a question — read-only, nothing runs…`; - default: - return `Message ${agentName} — describe what you want done…`; - } -} - -type ComposerAttachment = { - id: string; - name: string; - size?: number; - status: "uploading" | "attached" | "error"; - error?: string; - /** Set once uploaded; the submit path appends `[name](contentPath)` lines. */ - contentPath?: string; -}; - -/** Local duplicate of IssueChatThread's module-private helper (same rule). */ -function shouldImplicitlyReopenComment(issueStatus: string | undefined, assigneeValue: string) { - const resumesToTodo = issueStatus === "done" || issueStatus === "cancelled" || issueStatus === "blocked"; - return resumesToTodo && assigneeValue.startsWith("agent:"); -} - -function parseAssigneeValue(value: string): CommentReassignment | undefined { - if (value.startsWith("agent:")) { - const id = value.slice("agent:".length); - return id ? { assigneeAgentId: id, assigneeUserId: null } : undefined; - } - if (value.startsWith("user:")) { - const id = value.slice("user:".length); - return id ? { assigneeAgentId: null, assigneeUserId: id } : undefined; - } - return undefined; -} - -function escapeMarkdownLabel(name: string): string { - return name.replace(/[[\]]/g, "\\$&"); } /** - * Composer for the redesigned thread (v7 spec): the shared MarkdownEditor - * (rich lists, @-mentions, /-commands, inline pasted images) over a 32px - * comp-bar of [attach] [mode chip] … [assignee] [send]. The mode chip is a - * status-chip rectangle carrying the pending mode's hue; the composer chrome - * itself stays neutral. Shift+Tab cycles modes (captured before Lexical); - * Cmd/Ctrl+Enter posts via the editor's native onSubmit; plain Enter stays a - * newline / next list item. Pasted or dropped images upload through - * `onAttachImage` (or the `onImageUpload` fallback) and land inline at the - * caret via the editor's image plugin; non-image files render as shadcn - * base/attachment chips (kind icon · name · size, remove ×, uploading/error - * states) between the editor and the comp-bar. + * Lightweight composer for the redesigned thread. Covers the baseline + * essentials: optimistic send (delegates to the existing onAdd mutation, which + * already echoes locally) and the three modes — Agent / Plan / Ask — reusing + * the app's work-mode metadata. The selected mode is applied on submit, mirroring + * the existing composer's semantics. Advanced affordances (attachments, reassign, + * mentions, drafts) are deferred; they're demonstrated on the legacy composer and + * layered on post-baseline. */ export function TaskChatComposer({ onAdd, @@ -137,176 +29,27 @@ export function TaskChatComposer({ onWorkModeChange, disabled = false, disabledReason, - placeholder, - onAttachImage, - onImageUpload, - mentions, - enableReassign = false, - reassignOptions, - currentAssigneeValue = "", - issueStatus, - mobile = false, + placeholder = "Reply", }: TaskChatComposerProps) { const [body, setBody] = useState(""); const [submitting, setSubmitting] = useState(false); const [pendingMode, setPendingMode] = useState(workMode); - const [pendingAssignee, setPendingAssignee] = useState(null); - const [attachments, setAttachments] = useState([]); - const fileInputRef = useRef(null); - const editorRef = useRef(null); - const bodyRef = useRef(body); - bodyRef.current = body; - + const [menuOpen, setMenuOpen] = useState(false); const modeMeta = workModeMetaFor(pendingMode); - const canAcceptFiles = Boolean(onAttachImage || onImageUpload); - const showAssignee = Boolean(enableReassign && reassignOptions && reassignOptions.length > 0); - const assigneeValue = pendingAssignee ?? currentAssigneeValue; - const assigneeLabel = - reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned"; - const assigneeName = assigneeLabel === "Unassigned" ? "the agent" : assigneeLabel; - const effectivePlaceholder = placeholder ?? modePlaceholder(pendingMode, assigneeName); - - /** Upload an image and return its URL for inline `![](src)` markdown. */ - async function uploadInlineImage(file: File): Promise { - if (onAttachImage) { - const attachment = await onAttachImage(file); - if (attachment?.contentPath) return attachment.contentPath; - throw new Error("Upload did not return a file URL"); - } - if (onImageUpload) return onImageUpload(file); - throw new Error("This file type cannot be attached here"); - } - - /** Non-image files: attach to the task and track in the chip row. */ - async function attachNonImageFile(file: File) { - const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`; - setAttachments((prev) => [...prev, { id, name: file.name, size: file.size, status: "uploading" }]); - try { - if (!onAttachImage) { - setAttachments((prev) => - prev.map((item) => - item.id === id - ? { ...item, status: "error", error: "This file type cannot be attached here" } - : item, - ), - ); - return; - } - const attachment = await onAttachImage(file); - const name = attachment?.originalFilename ?? file.name; - setAttachments((prev) => - prev.map((item) => - item.id === id - ? { ...item, name, status: "attached", contentPath: attachment?.contentPath } - : item, - ), - ); - } catch (err) { - setAttachments((prev) => - prev.map((item) => - item.id === id - ? { ...item, status: "error", error: err instanceof Error ? err.message : "Upload failed" } - : item, - ), - ); - } - } - - /** Images picked from the + button go inline at the caret, like paste/drop. */ - async function attachPickedFile(file: File) { - if (!file.type.startsWith("image/")) { - await attachNonImageFile(file); - return; - } - const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2)}`; - try { - const url = await uploadInlineImage(file); - editorRef.current?.insertMarkdown(`![${escapeMarkdownLabel(file.name)}](${url})`); - } catch (err) { - setAttachments((prev) => [ - ...prev, - { - id, - name: file.name, - size: file.size, - status: "error", - error: err instanceof Error ? err.message : "Upload failed", - }, - ]); - } - } - - function handleFileInputChange(evt: ChangeEvent) { - const files = evt.target.files; - if (files && files.length > 0) { - void (async () => { - for (const file of Array.from(files)) await attachPickedFile(file); - })(); - } - evt.target.value = ""; - } - - /** - * Pasted image files fall through to the editor's image plugin (inline at - * the caret); non-image files are attached to the task here. Only swallow - * the paste when it carries no images the plugin should handle. - */ - function handlePasteCapture(evt: ReactClipboardEvent) { - if (!canAcceptFiles) return; - const files = Array.from(evt.clipboardData?.files ?? []); - if (files.length === 0) return; - const nonImages = files.filter((file) => !file.type.startsWith("image/")); - if (nonImages.length === 0) return; - if (nonImages.length === files.length) { - evt.preventDefault(); - evt.stopPropagation(); - } - void (async () => { - for (const file of nonImages) await attachNonImageFile(file); - })(); - } - - // Uploaded file references ride along as trailing `[name](contentPath)` - // lines — the bubble renderer folds those link-only lines back into chips. - const attachedRefs = attachments.filter( - (item) => item.status === "attached" && item.contentPath, - ); - // Sending mid-upload would silently drop the pending file from the comment; - // sending past a failed chip would discard the file the user selected and - // clear its error state, so both hold submission until resolved or removed. - const uploadPending = attachments.some((item) => item.status === "uploading"); - const uploadFailed = attachments.some((item) => item.status === "error"); + const ModeIcon = modeMeta.icon; async function submit() { - const trimmed = bodyRef.current.trim(); - if ( - (!trimmed && attachedRefs.length === 0) || - uploadPending || - uploadFailed || - submitting || - disabled - ) - return; - - const refLines = attachedRefs - .map((item) => `[${escapeMarkdownLabel(item.name)}](${item.contentPath})`) - .join("\n"); - const fullBody = [trimmed, refLines].filter(Boolean).join("\n\n"); - const hasReassignment = showAssignee && assigneeValue !== currentAssigneeValue; - const reassignment = hasReassignment ? parseAssigneeValue(assigneeValue) : undefined; - const reopen = shouldImplicitlyReopenComment(issueStatus, assigneeValue) ? true : undefined; - + const trimmed = body.trim(); + if (!trimmed || submitting || disabled) return; setSubmitting(true); setBody(""); try { if (pendingMode !== workMode && onWorkModeChange) { await onWorkModeChange(pendingMode); } - await onAdd(fullBody, reopen, reassignment); - setAttachments([]); - setPendingAssignee(null); + await onAdd(trimmed); } catch { - setBody(trimmed); // restore on failure (chips stay for retry) + setBody(trimmed); // restore on failure } finally { setSubmitting(false); } @@ -315,207 +58,75 @@ export function TaskChatComposer({ return (
{ - // Shift+Tab cycles the pending mode; captured on the wrapper so it - // wins over Lexical's list-outdent binding inside the editor. - if (disabled) return; - if (e.key === "Tab" && e.shiftKey) { - e.preventDefault(); - e.stopPropagation(); - setPendingMode((mode) => nextWorkMode(mode)); - } - }} - onPasteCapture={handlePasteCapture} > -
- void submit()} - imageUploadHandler={canAcceptFiles ? uploadInlineImage : undefined} - onDropFile={canAcceptFiles ? attachNonImageFile : undefined} - bordered={false} - className={cn(disabled && "opacity-60")} - contentClassName={ - mobile - ? "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-base scrollbar-auto-hide" - : "max-h-(--sz-28dvh) min-h-(--sz-48px) overflow-y-auto px-1 py-1 text-sm scrollbar-auto-hide" +