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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"@sentry/electron": "7.16.0",
"@streamdown/mermaid": "1.0.2",
"@superset/auth": "workspace:*",
"@superset/chat": "workspace:*",
"@superset/chat-legacy": "workspace:*",
"@superset/db": "workspace:*",
"@superset/host-service": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ export const PERSISTED_KEY_REGISTRY: ReadonlyArray<
"src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/state/paneScrollStateCache/paneScrollStateCache.ts",
["v2-pane-scroll-state-v1"],
],
[
"src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/Composer/Composer.tsx",
["chat-v3-draft:*"],
],
["src/renderer/stores/changes/store.ts", ["changes-store"]],
["src/renderer/stores/prompt-history.ts", ["prompt-history"]],
["src/renderer/stores/tabs/store.ts", ["tabs-storage"]],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { UserContent } from "@superset/chat/protocol";
import { useCallback, useState } from "react";
import type { HarnessId } from "./components/NewSessionView";
import { NewSessionView } from "./components/NewSessionView";
import { SessionPicker } from "./components/SessionPicker";
import { SessionView } from "./components/SessionView";
import { useSessionClient } from "./hooks/useSessionClient";

export function ChatV3Pane({
onSessionIdChange,
sessionId,
workspaceId,
}: {
workspaceId: string;
sessionId: string | null;
onSessionIdChange: (sessionId: string | null) => void;
}) {
const { client, wiring } = useSessionClient(sessionId);
const [harness, setHarness] = useState<HarnessId>("claude-code");
const [pendingFirstPrompt, setPendingFirstPrompt] = useState<
UserContent[] | null
>(null);

const createSession = useCallback(
async (content: UserContent[] | null) => {
const created = await wiring.transport.createSession({
commandId: crypto.randomUUID(),
workspaceId,
harness,
});
setPendingFirstPrompt(content);
onSessionIdChange(created.sessionId);
Comment on lines +24 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Serialize session creation and handle failures.

Each submission creates a new commandId, but this callback has no pending guard. Rapid submissions can create multiple remote sessions. A rejected createSession call is also unhandled because the caller discards its promise. Track creation state, prevent duplicate submission, and catch the failure before updating pane state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/ChatV3Pane.tsx
around lines 24 - 32, Update the createSession callback to track whether session
creation is pending, return early while a creation is in progress, and serialize
rapid submissions. Handle rejected wiring.transport.createSession calls within
the callback, and only update pending prompt and session ID state after
successful creation; clear the pending state on both success and failure.

},
[wiring.transport, workspaceId, harness, onSessionIdChange],
);

const picker = (
<SessionPicker
activeSessionId={sessionId}
onNewSession={() => onSessionIdChange(null)}
onSelect={onSessionIdChange}
Comment on lines +39 to +41

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Bind pendingFirstPrompt to its created session.

pendingFirstPrompt has no session ID. If the user selects another session before the created client becomes ready, SessionView sends that prompt to the newly selected session. Store { sessionId, content } and pass the content only when it matches the active session. Clear it when the user starts or selects another session.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/ChatV3Pane.tsx
around lines 39 - 41, Update the ChatV3Pane session/prompt state so
pendingFirstPrompt stores both the created sessionId and prompt content. In
SessionView, pass the pending content only when its stored sessionId matches the
active sessionId; clear the pending prompt when starting or selecting another
session, while preserving normal prompt handling.

transport={wiring.transport}
workspaceId={workspaceId}
/>
);

if (!client || !sessionId) {
return (
<NewSessionView
harness={harness}
headerLeft={picker}
onHarnessChange={setHarness}
onSend={(content) => void createSession(content)}
workspaceId={workspaceId}
/>
);
}

return (
<SessionView
client={client}
headerLeft={picker}
key={sessionId}
onFirstPromptSent={() => setPendingFirstPrompt(null)}
pendingFirstPrompt={pendingFirstPrompt}
sessionId={sessionId}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { OutboxEntry } from "@superset/chat/core";
import type { UserContent } from "@superset/chat/protocol";
import { Button } from "@superset/ui/button";
import { Textarea } from "@superset/ui/textarea";
import { ArrowUp, Square } from "lucide-react";
import { useEffect, useRef, useState } from "react";

const DRAFT_DEBOUNCE_MS = 300;

export type ComposerProps = {
draftKey: string;
outbox: OutboxEntry[];
onSend: (content: UserContent[]) => OutboxEntry | null;
placeholder?: string;
disabled?: boolean;
onCancelTurn?: (() => void) | null;
};

export function Composer({
disabled,
draftKey,
onCancelTurn,
onSend,
outbox,
placeholder,
}: ComposerProps) {
const [value, setValue] = useState(
() => window.localStorage.getItem(draftKey) ?? "",
);
const valueRef = useRef(value);
valueRef.current = value;
const pendingRef = useRef<{ clientId: string; sentText: string } | null>(
null,
);
Comment on lines +27 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Bound the per-session draft keys or move them to SQLite.

draftKey is chat-v3-draft:${sessionId} for session views, so each session creates its own localStorage key. This file removes a key only when the user clears or sends that draft in that mounted Composer. A user who types text and abandons the session leaves the key behind permanently. No cap, TTL, or entity reconciliation deletes stranded session drafts, and the registry entry declares the family without a bound or deletion owner.

Add one of the following:

  • an LRU or fixed cap over chat-v3-draft:* keys,
  • a boot sweep that deletes draft keys for sessions that no longer exist,
  • or createElectronSQLitePersistence for session-scoped drafts.

Also document the bound and deletion owner for the key family.

Based on learnings from the coding guidelines: "Use localStorage only for small singleton UI state; entity-scoped unbounded data or payloads larger than a few KB belongs in SQLite via createElectronSQLitePersistence" and "For every renderer localStorage key, document its bound, deletion owner or path, and behavior when the feature is removed; use caps/LRU, TTL, entity reconciliation, or a fixed-size singleton as appropriate."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/Composer/Composer.tsx
around lines 27 - 34, Replace the unbounded session-scoped localStorage
persistence around the Composer draft state and draftKey with
createElectronSQLitePersistence, or add a bounded cleanup mechanism for
chat-v3-draft:* keys that removes abandoned session drafts. Document the key
family’s bound, deletion owner/path, and feature-removal behavior near the
relevant registry or persistence declaration.

Source: Coding guidelines


useEffect(() => {
const timer = setTimeout(() => {
if (value === "") window.localStorage.removeItem(draftKey);
else window.localStorage.setItem(draftKey, value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Abandoned chat drafts can grow renderer localStorage without a size bound or cleanup path: each session/workspace gets a separate key and unsent drafts survive indefinitely. Add a bounded/expiring reconciliation strategy or move entity-scoped draft persistence to the supported SQLite layer.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/Composer/Composer.tsx, line 39:

<comment>Abandoned chat drafts can grow renderer localStorage without a size bound or cleanup path: each session/workspace gets a separate key and unsent drafts survive indefinitely. Add a bounded/expiring reconciliation strategy or move entity-scoped draft persistence to the supported SQLite layer.</comment>

<file context>
@@ -0,0 +1,109 @@
+	useEffect(() => {
+		const timer = setTimeout(() => {
+			if (value === "") window.localStorage.removeItem(draftKey);
+			else window.localStorage.setItem(draftKey, value);
+		}, DRAFT_DEBOUNCE_MS);
+		return () => clearTimeout(timer);
</file context>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The desktop persisted-key registry check will fail for this new localStorage writer because the Composer file is not registered. Add the chat-v3-draft:* key family to the registry with its bounds and deletion policy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/Composer/Composer.tsx, line 39:

<comment>The desktop persisted-key registry check will fail for this new `localStorage` writer because the Composer file is not registered. Add the `chat-v3-draft:*` key family to the registry with its bounds and deletion policy.</comment>

<file context>
@@ -0,0 +1,109 @@
+	useEffect(() => {
+		const timer = setTimeout(() => {
+			if (value === "") window.localStorage.removeItem(draftKey);
+			else window.localStorage.setItem(draftKey, value);
+		}, DRAFT_DEBOUNCE_MS);
+		return () => clearTimeout(timer);
</file context>

}, DRAFT_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [value, draftKey]);

useEffect(() => {
return () => {
const latest = valueRef.current;
if (latest === "") window.localStorage.removeItem(draftKey);
else window.localStorage.setItem(draftKey, latest);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Switching sessions before a sent message is echoed can resurrect that message as a draft: the unmount cleanup saves the pending sent text, but the pending outbox/echo state is destroyed with the old session client. Make cleanup/send state durable across the session switch or remove/mark the draft when dispatching so an accepted prompt cannot be duplicated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/Composer/Composer.tsx, line 48:

<comment>Switching sessions before a sent message is echoed can resurrect that message as a draft: the unmount cleanup saves the pending sent text, but the pending outbox/echo state is destroyed with the old session client. Make cleanup/send state durable across the session switch or remove/mark the draft when dispatching so an accepted prompt cannot be duplicated.</comment>

<file context>
@@ -0,0 +1,109 @@
+		return () => {
+			const latest = valueRef.current;
+			if (latest === "") window.localStorage.removeItem(draftKey);
+			else window.localStorage.setItem(draftKey, latest);
+		};
+	}, [draftKey]);
</file context>

};
}, [draftKey]);

useEffect(() => {
const pending = pendingRef.current;
if (!pending) return;
if (outbox.some((entry) => entry.clientId === pending.clientId)) return;
pendingRef.current = null;
if (valueRef.current === pending.sentText) {
setValue("");
window.localStorage.removeItem(draftKey);
}
}, [outbox, draftKey]);
Comment on lines +52 to +61

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect OutboxEntryState members and discard/confirm transitions.
fd -t f 'outbox.ts' packages/chat/src | xargs rg -n -C3 'OutboxEntryState|state\s*[:=]\s*"|discard|confirm'

Repository: superset-sh/superset

Length of output: 1823


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/\$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/Composer/Composer.tsx"

echo "== file exists =="
test -f "$FILE" && echo yes || echo no

echo "== composer outline =="
ast-grep outline "$FILE" --view expanded | sed -n '1,220p'

echo "== composer relevant lines =="
cat -n "$FILE" | sed -n '1,140p'

echo "== references to clear/draft/pending text and outbox discard/confirm =="
rg -n -C3 'discards?|discardPrompt|onDiscard|confirm|pendingRef|sentText|setValue|localStorage.removeItem|outbox' apps/desktop/src/renderer packages -g '*.ts' -g '*.tsx' | sed -n '1,240p'

Repository: superset-sh/superset

Length of output: 27039


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate ChatV3Pane files =="
fd -t f 'ChatV3Pane|Transcript|Outbox' apps/desktop/src/renderer packages -i | sed -n '1,200p'

echo "== ChatV3Pane references to discard/confirm/outbox =="
rg -n -C4 'outbox|confirm|discard|onConfirm|onDiscard|onDiscardPrompt' apps/desktop/src/renderer -g '*.ts' -g '*.tsx' | sed -n '1,260p'

echo "== focused app files =="
for name in "ChatV3Pane.tsx" "Transcript.tsx" "ChatV3Pane.test.tsx"; do
  echo "-- $name --"
  fd -t f "$name" apps/desktop/src/renderer -i | while read -r f; do
    echo "FILE: $f"
    rg -n -C4 'outbox|confirm|discard|onConfirm|onDiscard|onDiscardPrompt' "$f" || true
  done
done

echo "== chat package transcript-related references =="
rg -n -C4 'outbox|confirm|discard|pendingRef|sentText|onDiscardPrompt|onConfirm' packages apps -g '*.ts' -g '*.tsx' -g '*.test-data.ts' || true

Repository: superset-sh/superset

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/\$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/Composer/Composer.tsx"
PANEDIR="apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workcase/\$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane"
APPFILES="apps/desktop/src/renderer"

echo "== exact Composer file =="
wc -l "$FILE"
sed -n '1,130p' "$FILE"

echo "== exact ChatV3Pane file candidates =="
fd -i 'ChatV3Pane\.tsx$' "$APPFILES" | while read -r p; do
  echo "---- $p ----"
  rg -n -C5 'outbox|confirm|discard|onConfirm|onDiscard|onDiscardPrompt' "$p" || true
done

echo "== exact Transcript file candidates =="
fd -i 'Transcript\.tsx$' "$APPFILES" | while read -r p; do
  echo "---- $p ----"
  rg -n -C5 'outbox|confirm|discard|onConfirm|onDiscard|onDiscardPrompt' "$p" || true
done

echo "== focused app renderer search exact terms =="
rg -n -w -C3 '\'outbox\\'|\'confirm\\'|\'discard\\'|onConfirm|onDiscard|onDiscardPrompt|outbox' "$APPFILES" -g '*.ts' -g '*.tsx' | sed -n '1,260p'

echo "== focused package chat-outbox exact search =="
find packages -path '*chat*' -type f \( -name '*.ts' -o -name '*.tsx' \) -print0 | xargs -0 rg -n -w -C3 'OutboxEntryState|OutboxEntry|confirm\\(|discard\\(|outbox' | sed -n '1,220p' | rg -n -C5 'packages/chat|outbox|confirm|discard|OutboxEntry' || true

echo "== behavioral probe for current effect logic ==
python3 - <<'PY'
def current_clears(pending, outbox, value, sent_text):
    if not pending:
        return False
    if any(entry == pending for entry in outbox):
        return False
    if value == sent_text:
        return True
    return False

for state, present, edited, failed in [
    ("inflight", True, False, False),
    ("failed", False, False, False),
    ("failed", False, True, False),
    ("queued", False, False, True),
]:
    outbox = [pending := {"clientId": "x", "state": state}] if present else []
    print(state, "present" if present else "absent", "edited" if edited else "same", "changed" if (outbox and pending["state"]!=state) else "same state", current_clears({"clientId": "x", "sentText": ""}, outbox, "" if not edited else "edited", ""))
PY

echo "== exact diff for current effect around lines 52-61 =="
cat -n "$FILE" | sed -n '48,64p'

Repository: superset-sh/superset

Length of output: 5847


Clear the composer draft only on confirmed sends.

Composer tracks the sent clientId and clears both the textarea and draft localStorage when that entry leaves outbox. outbox can lose an entry for confirmation or discard, and Transcript exposes discard for failed outbox entries. If the user discards a failed prompt without editing the text, this path deletes the user’s message payload. Track the entry through confirmation, or pass a clear-only-on-success signal, and avoid clearing on discard.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/Composer/Composer.tsx
around lines 52 - 61, Update the pending-send handling in Composer’s useEffect
so the textarea and draft localStorage are cleared only after the tracked
clientId is confirmed as successfully sent, not merely removed from outbox.
Distinguish confirmation from discard using the existing entry status or a
clear-only-on-success signal, while preserving the valueRef check and pendingRef
cleanup.


const send = () => {
const text = valueRef.current;
if (text.trim() === "" || disabled) return;
const entry = onSend([{ type: "text", text }]);
if (entry === null) {
setValue("");
window.localStorage.removeItem(draftKey);
return;
}
pendingRef.current = { clientId: entry.clientId, sentText: text };
};

return (
<div className="flex items-end gap-2 border-t border-border p-3">
<Textarea
className="max-h-40 min-h-10 flex-1 resize-none"
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
send();
}
}}
placeholder={placeholder ?? "Message the agent"}
value={value}
/>
{onCancelTurn ? (
<Button onClick={onCancelTurn} size="icon" variant="outline">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The send and cancel controls are unlabeled icon-only buttons, so assistive-technology users cannot identify their actions. Add explicit labels such as Send message and Cancel turn.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/Composer/Composer.tsx, line 95:

<comment>The send and cancel controls are unlabeled icon-only buttons, so assistive-technology users cannot identify their actions. Add explicit labels such as `Send message` and `Cancel turn`.</comment>

<file context>
@@ -0,0 +1,109 @@
+				value={value}
+			/>
+			{onCancelTurn ? (
+				<Button onClick={onCancelTurn} size="icon" variant="outline">
+					<Square className="size-4" />
+				</Button>
</file context>

<Square className="size-4" />
</Button>
) : (
<Button
disabled={disabled || value.trim() === ""}
onClick={send}
size="icon"
>
<ArrowUp className="size-4" />
</Button>
)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { Composer } from "./Composer";
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { cn } from "@superset/ui/utils";
import { memo, useMemo } from "react";
import { Streamdown } from "streamdown";
import { planMarkdown } from "./utils/planMarkdown";

const MarkdownBlock = memo(function MarkdownBlock({
block,
}: {
block: string;
}) {
return (
<Streamdown
className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
linkSafety={{ enabled: false }}
mode="streaming"
>
{block}
</Streamdown>
);
});
Comment on lines +6 to +20

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move MarkdownBlock into its own component file.

MarkdownBlock is a separate component. Place it in MarkdownBlock/MarkdownBlock.tsx and add an index.ts barrel export.

As per coding guidelines, “Use one component per file.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/MarkdownView/MarkdownView.tsx
around lines 6 - 20, Move the memoized MarkdownBlock component into a new
MarkdownBlock/MarkdownBlock.tsx file, preserving its props, Streamdown
configuration, and rendering behavior. Add an index.ts barrel export for
MarkdownBlock, then update MarkdownView to import and use the extracted
component instead of defining it locally.

Source: Coding guidelines


export function MarkdownView({
className,
text,
}: {
text: string;
className?: string;
}) {
const plan = useMemo(() => planMarkdown(text), [text]);
return (
<div className={cn("flex min-w-0 flex-col gap-1 text-sm", className)}>
{plan.stable.map((entry) => (
<MarkdownBlock block={entry.block} key={entry.key} />
))}
{plan.tail !== null &&
(plan.tailFenceOpen ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded-md bg-muted p-2 font-mono text-xs">
{plan.tail}
</pre>
) : (
<MarkdownBlock block={plan.tail} />
))}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MarkdownView } from "./MarkdownView";
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { MarkdownBlockPlan, MarkdownPlan } from "./planMarkdown";
export { planMarkdown } from "./planMarkdown";
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test";
import { planMarkdown } from "./planMarkdown";

describe("planMarkdown", () => {
test("stable blocks keep byte-identical keys and content as text streams in", () => {
const first = planMarkdown("# Title\n\nParagraph one.\n\nParagraph tw");
const second = planMarkdown(
"# Title\n\nParagraph one.\n\nParagraph two.\n\nParagraph three",
);

expect(first.stable.length).toBeGreaterThan(0);
for (const [index, block] of first.stable.entries()) {
expect(second.stable[index]).toEqual(block);
}
expect(second.tail).toBe("Paragraph three");
});

test("an open fence marks the tail as plain and closing it releases the block", () => {
const open = planMarkdown("Intro\n\n```ts\nconst a = 1;\n");
expect(open.tailFenceOpen).toBe(true);
expect(open.tail).toContain("const a = 1;");

const closed = planMarkdown("Intro\n\n```ts\nconst a = 1;\n```\n\nAfter");
expect(closed.tailFenceOpen).toBe(false);
expect(closed.tail).toBe("After");
expect(
closed.stable.some((entry) => entry.block.includes("const a = 1;")),
).toBe(true);
});

test("empty text renders nothing", () => {
expect(planMarkdown("")).toEqual({
stable: [],
tail: null,
tailFenceOpen: false,
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { fenceState, splitForStreaming } from "@superset/chat/core";

export type MarkdownBlockPlan = {
key: string;
block: string;
};

export type MarkdownPlan = {
stable: MarkdownBlockPlan[];
tail: string | null;
tailFenceOpen: boolean;
};

export function planMarkdown(text: string): MarkdownPlan {
const { stable, tail } = splitForStreaming(text);
let offset = 0;
const keyed = stable.map((block) => {
const key = `${offset}`;
offset += block.length;
return { key, block };
});
if (tail === "") return { stable: keyed, tail: null, tailFenceOpen: false };
return { stable: keyed, tail, tailFenceOpen: fenceState(text).open };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { UserContent } from "@superset/chat/protocol";
import { Button } from "@superset/ui/button";
import type { ReactNode } from "react";
import { Composer } from "../Composer";

export const HARNESSES = ["claude-code", "codex"] as const;
export type HarnessId = (typeof HARNESSES)[number];

export function NewSessionView({
harness,
headerLeft,
onHarnessChange,
onSend,
workspaceId,
}: {
workspaceId: string;
harness: HarnessId;
onHarnessChange: (harness: HarnessId) => void;
onSend: (content: UserContent[]) => void;
headerLeft?: ReactNode;
}) {
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
{headerLeft}
<div className="ml-auto flex items-center gap-1">
{HARNESSES.map((candidate) => (
<Button
key={candidate}
onClick={() => onHarnessChange(candidate)}
size="sm"
variant={candidate === harness ? "secondary" : "ghost"}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Screen-reader users cannot tell which harness will be used for the next session because selection is conveyed only through the visual button variant. Add aria-pressed={candidate === harness} to each harness button.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/NewSessionView/NewSessionView.tsx, line 32:

<comment>Screen-reader users cannot tell which harness will be used for the next session because selection is conveyed only through the visual button variant. Add `aria-pressed={candidate === harness}` to each harness button.</comment>

<file context>
@@ -0,0 +1,56 @@
+							key={candidate}
+							onClick={() => onHarnessChange(candidate)}
+							size="sm"
+							variant={candidate === harness ? "secondary" : "ghost"}
+						>
+							{candidate}
</file context>

>
{candidate}
</Button>
))}
</div>
</div>
<div className="flex flex-1 flex-col items-center justify-center gap-1 text-center">
<span className="text-sm font-medium">New chat</span>
<span className="text-xs text-muted-foreground">
Send a message to start a {harness} session
</span>
</div>
<Composer
draftKey={`chat-v3-draft:new:${workspaceId}`}
onSend={(content) => {
onSend(content);
return null;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A failed new-session request clears the user's draft before the session exists. The send path should retain the draft until session creation and first-prompt delivery are acknowledged, rather than adapting this asynchronous operation to Composer's immediate-success null result.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/NewSessionView/NewSessionView.tsx, line 49:

<comment>A failed new-session request clears the user's draft before the session exists. The send path should retain the draft until session creation and first-prompt delivery are acknowledged, rather than adapting this asynchronous operation to Composer's immediate-success `null` result.</comment>

<file context>
@@ -0,0 +1,56 @@
+				draftKey={`chat-v3-draft:new:${workspaceId}`}
+				onSend={(content) => {
+					onSend(content);
+					return null;
+				}}
+				outbox={[]}
</file context>

}}
Comment on lines +47 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 First prompt clears before creation

When session creation rejects because the host service is starting or unavailable, this callback has already returned null, causing Composer to erase the persisted first prompt even though no session or recoverable outbox entry was created.

Knowledge Base Used: Desktop App (Electron main + renderer)

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/NewSessionView/NewSessionView.tsx
Line: 47-50

Comment:
**First prompt clears before creation**

When session creation rejects because the host service is starting or unavailable, this callback has already returned `null`, causing `Composer` to erase the persisted first prompt even though no session or recoverable outbox entry was created.

**Knowledge Base Used:** [Desktop App (Electron main + renderer)](https://app.greptile.com/personal-org-6810/-/custom-context/knowledge-base/superset-sh/superset/-/docs/desktop-app.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

outbox={[]}
placeholder={`Start a ${harness} session`}
/>
Comment on lines +45 to +53

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Do not clear the draft before session creation succeeds.

onSend returns null unconditionally, so Composer clears the textarea and deletes the draft key immediately. ChatV3Pane implements this callback as (content) => void createSession(content), and createSession awaits wiring.transport.createSession without a catch. If session creation fails, three things happen:

  • The typed prompt is already erased and the draft key is already removed.
  • The rejection surfaces only as an unhandled promise rejection.
  • The pane stays on NewSessionView, so the user sees an empty composer and no error.

Change onSend to report success, and keep the draft until creation resolves. Report the failure to the user. Follow the desktop rule that rendered error messages carry select-text cursor-text.

🐛 Proposed direction
-	onSend: (content: UserContent[]) => void;
+	onSend: (content: UserContent[]) => Promise<boolean>;
 			<Composer
 				draftKey={`chat-v3-draft:new:${workspaceId}`}
-				onSend={(content) => {
-					onSend(content);
-					return null;
-				}}
+				onSend={onSend}
 				outbox={[]}
 				placeholder={`Start a ${harness} session`}
 			/>

Composer.send then needs to await the result and clear only when it is true.

Based on learnings from the coding guidelines: "Rendered error messages must include select-text cursor-text classes so users can copy them; Sonner toasts are exempt."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/`$workspaceId/hooks/usePaneRegistry/components/ChatV3Pane/components/NewSessionView/NewSessionView.tsx
around lines 45 - 53, Update the NewSessionView Composer onSend flow and its
ChatV3Pane createSession callback to return a success boolean, resolving true
only after session creation succeeds and false on failure. Have Composer.send
await this result and clear the textarea and draft key only when it is true; on
failure, preserve the draft and render a user-visible error with select-text
cursor-text classes, avoiding an unhandled rejection.

Source: Coding guidelines

</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { HarnessId } from "./NewSessionView";
export { HARNESSES, NewSessionView } from "./NewSessionView";
Loading
Loading