mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
Fix conference room chat scroll, identity, feed links (PAP-13345)
- Give the assistant chat body its own scroll container so loading or
scrolling a chat never moves the page or the sidebar.
- Render the conference room with issue-chat density: the board user's
messages are right-aligned blue bubbles, and the company user directory
now resolves real names/avatars instead of "You".
- Report heartbeat scheduling suppression from the selected-agent-chat
comment route and surface it as an inline notice, so a send on a
worktree instance no longer looks like it silently did nothing.
- Deep-link Agent Feed events on board_chat issues to /board-chat/{ref}
(they are hidden from the issues list, so the old link was a broken
/issues/<uuid>), and stop showing the board user's own comment events.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c2d4f5ca5
commit
048e79f094
9 files changed
+167
-10
No files matched your search
@@ -45,6 +45,10 @@ const mockAccessDecide = vi.hoisted(() => vi.fn(async (input: { action?: string
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
const mockResolveHeartbeatSchedulingSuppression = vi.hoisted(() =>
|
||||
vi.fn((): { suppressed: boolean; reason: string | null } => ({ suppressed: false, reason: null })),
|
||||
);
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
companyService: () => ({}),
|
||||
accessService: () => ({
|
||||
@@ -98,6 +102,7 @@ vi.mock("../services/index.js", () => ({
|
||||
routineService: () => ({
|
||||
syncRunStatusForIssue: vi.fn(async () => undefined),
|
||||
}),
|
||||
resolveHeartbeatSchedulingSuppression: mockResolveHeartbeatSchedulingSuppression,
|
||||
workProductService: () => ({}),
|
||||
}));
|
||||
|
||||
@@ -253,6 +258,25 @@ describe("selected-agent issue chat backend", () => {
|
||||
ASSIGNEE_AGENT_ID,
|
||||
expect.anything(),
|
||||
);
|
||||
expect(res.body.wake).toEqual({ suppressed: false, reason: null });
|
||||
});
|
||||
|
||||
it("reports wake suppression so the chat UI can explain a silent no-reply", async () => {
|
||||
mockResolveHeartbeatSchedulingSuppression.mockReturnValueOnce({
|
||||
suppressed: true,
|
||||
reason: "worktree_instance",
|
||||
});
|
||||
const db = createDbStub(
|
||||
[makeAgent({ id: CEO_AGENT_ID, name: "CEO", role: "ceo" })],
|
||||
[],
|
||||
);
|
||||
|
||||
const res = await request(await createApp(db))
|
||||
.post(`/api/issues/${ISSUE_ID}/selected-agent-chat/comments`)
|
||||
.send({ body: "hello?" });
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(201);
|
||||
expect(res.body.wake).toEqual({ suppressed: true, reason: "worktree_instance" });
|
||||
});
|
||||
|
||||
it("titles new board chat issues from the first selected-agent message and stores the target agent", async () => {
|
||||
|
||||
@@ -98,6 +98,7 @@ import {
|
||||
executionWorkspaceService,
|
||||
goalService,
|
||||
heartbeatService,
|
||||
resolveHeartbeatSchedulingSuppression,
|
||||
issueApprovalService,
|
||||
issueRecoveryActionService,
|
||||
issueThreadInteractionService,
|
||||
@@ -8920,6 +8921,10 @@ export function issueRoutes(
|
||||
agentId: targetAgent.id,
|
||||
}, "failed to wake selected-agent chat target on issue comment"));
|
||||
|
||||
// Tell the client whether the wake can actually run here: on worktree
|
||||
// instances scheduling is suppressed, and a silent no-reply chat reads
|
||||
// as "sending did nothing" (PAP-13345).
|
||||
const wakeSuppression = resolveHeartbeatSchedulingSuppression();
|
||||
res.status(201).json({
|
||||
comment,
|
||||
targetAgent: {
|
||||
@@ -8928,6 +8933,10 @@ export function issueRoutes(
|
||||
role: targetAgent.role,
|
||||
status: targetAgent.status,
|
||||
},
|
||||
wake: {
|
||||
suppressed: wakeSuppression.suppressed,
|
||||
reason: wakeSuppression.reason,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -41,6 +41,11 @@ export type SelectedAgentChatTarget = {
|
||||
export type SelectedAgentChatCommentResponse = {
|
||||
comment: IssueComment;
|
||||
targetAgent: SelectedAgentChatTarget;
|
||||
/** Whether the target-agent wake could be scheduled for this send. */
|
||||
wake?: {
|
||||
suppressed: boolean;
|
||||
reason: "worktree_instance" | "database_restore_in_progress" | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type ResolveRecoveryActionResponse = {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import type { ActivityEvent, Agent } from "@paperclipai/shared";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { authApi } from "../api/auth";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
@@ -245,11 +246,13 @@ function CollapsedFeedGroup({
|
||||
agentMap,
|
||||
entityNameMap,
|
||||
entityTitleMap,
|
||||
boardChatIssueRefs,
|
||||
}: {
|
||||
group: CollapsedGroup;
|
||||
agentMap: Map<string, Agent>;
|
||||
entityNameMap: Map<string, string>;
|
||||
entityTitleMap: Map<string, string>;
|
||||
boardChatIssueRefs?: ReadonlyMap<string, string>;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const actor = group.latestEvent.actorType === "agent"
|
||||
@@ -301,6 +304,7 @@ function CollapsedFeedGroup({
|
||||
agentMap={agentMap}
|
||||
entityNameMap={entityNameMap}
|
||||
entityTitleMap={entityTitleMap}
|
||||
boardChatIssueRefs={boardChatIssueRefs}
|
||||
isMuted
|
||||
/>
|
||||
))}
|
||||
@@ -360,6 +364,27 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
// Conference Room conversations are board_chat issues hidden from the
|
||||
// default list above; fetch them separately so their events resolve to an
|
||||
// identifier and deep-link to the room instead of a broken issue URL.
|
||||
const { data: boardChatIssues } = useQuery({
|
||||
queryKey: [...queryKeys.issues.list(selectedCompanyId ?? ""), "board-chat-refs"],
|
||||
queryFn: () =>
|
||||
issuesApi.list(selectedCompanyId!, {
|
||||
originKind: "board_chat",
|
||||
limit: 100,
|
||||
sortField: "updated",
|
||||
sortDir: "desc",
|
||||
}),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: queryKeys.auth.session,
|
||||
queryFn: () => authApi.getSession(),
|
||||
});
|
||||
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||
|
||||
const agentMap = useMemo(() => {
|
||||
const map = new Map<string, Agent>();
|
||||
for (const a of agents ?? []) map.set(a.id, a);
|
||||
@@ -370,14 +395,22 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
|
||||
const map = new Map<string, string>();
|
||||
for (const a of agents ?? []) map.set(`agent:${a.id}`, a.name);
|
||||
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.identifier ?? i.id);
|
||||
for (const i of boardChatIssues ?? []) map.set(`issue:${i.id}`, i.identifier ?? i.id);
|
||||
return map;
|
||||
}, [agents, issues]);
|
||||
}, [agents, issues, boardChatIssues]);
|
||||
|
||||
const entityTitleMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const i of issues ?? []) map.set(`issue:${i.id}`, i.title);
|
||||
for (const i of boardChatIssues ?? []) map.set(`issue:${i.id}`, i.title);
|
||||
return map;
|
||||
}, [issues]);
|
||||
}, [issues, boardChatIssues]);
|
||||
|
||||
const boardChatIssueRefs = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const i of boardChatIssues ?? []) map.set(i.id, i.identifier || i.id);
|
||||
return map;
|
||||
}, [boardChatIssues]);
|
||||
|
||||
const entityStatusMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
@@ -389,6 +422,16 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
|
||||
const processedItems = useMemo(() => {
|
||||
const events = (activity ?? [])
|
||||
.filter((evt) => {
|
||||
// The board user's own chat messages are not agent activity — they
|
||||
// already see them in the thread they just typed into (PAP-13345).
|
||||
if (
|
||||
(evt.action === "issue.commented" || evt.action === "issue.comment_added")
|
||||
&& evt.actorType === "user"
|
||||
&& currentUserId
|
||||
&& evt.actorId === currentUserId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const tier = getEventTier(evt);
|
||||
if (!showAllActivity && tier === 3) return false;
|
||||
return matchesFilter(evt, filter);
|
||||
@@ -396,7 +439,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
|
||||
return collapseSequential(events);
|
||||
}, [activity, filter, showAllActivity]);
|
||||
}, [activity, filter, showAllActivity, currentUserId]);
|
||||
|
||||
const visibleItems = processedItems.slice(0, visibleCount);
|
||||
const hasMore = processedItems.length > visibleCount;
|
||||
@@ -491,6 +534,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
|
||||
agentMap={agentMap}
|
||||
entityNameMap={entityNameMap}
|
||||
entityTitleMap={entityTitleMap}
|
||||
boardChatIssueRefs={boardChatIssueRefs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -513,6 +557,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
|
||||
entityNameMap={entityNameMap}
|
||||
entityTitleMap={entityTitleMap}
|
||||
entityStatusMap={entityStatusMap}
|
||||
boardChatIssueRefs={boardChatIssueRefs}
|
||||
isActive={isActiveHeartbeat}
|
||||
/>
|
||||
</div>
|
||||
@@ -531,6 +576,7 @@ export function ActivityFeed({ className }: ActivityFeedProps) {
|
||||
entityNameMap={entityNameMap}
|
||||
entityTitleMap={entityTitleMap}
|
||||
entityStatusMap={entityStatusMap}
|
||||
boardChatIssueRefs={boardChatIssueRefs}
|
||||
isActive={isActiveHeartbeat}
|
||||
isMuted
|
||||
/>
|
||||
|
||||
@@ -27,9 +27,15 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { MentionOption } from "./MarkdownEditor";
|
||||
import { accessApi } from "../api/access";
|
||||
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
|
||||
import { heartbeatsApi } from "../api/heartbeats";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import {
|
||||
buildCompanyUserLabelMap,
|
||||
buildCompanyUserProfileMap,
|
||||
type CompanyUserProfile,
|
||||
} from "../lib/company-members";
|
||||
import type { IssueChatComment, IssueChatTranscriptEntry } from "../lib/issue-chat-messages";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
@@ -227,6 +233,8 @@ export interface AssistantChatViewProps {
|
||||
companyId?: string | null;
|
||||
projectId?: string | null;
|
||||
currentUserId?: string | null;
|
||||
userLabelMap?: ReadonlyMap<string, string> | null;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile> | null;
|
||||
backgroundWorkChildren?: IssueRelationIssueSummary[];
|
||||
suppressIssueStatusNotices?: boolean;
|
||||
companyName?: string | null;
|
||||
@@ -240,6 +248,8 @@ export interface AssistantChatViewProps {
|
||||
loading?: boolean;
|
||||
/** Surface a delivery/transport failure inline (CR8). */
|
||||
errorText?: string | null;
|
||||
/** Non-error inline notice (e.g. delivered but the agent can't run here). */
|
||||
noticeText?: string | null;
|
||||
onRetry?: () => void;
|
||||
onSend: (body: string) => Promise<void>;
|
||||
onStopRun?: (runId: string) => Promise<void>;
|
||||
@@ -285,6 +295,8 @@ export function AssistantChatView({
|
||||
companyId,
|
||||
projectId,
|
||||
currentUserId,
|
||||
userLabelMap,
|
||||
userProfileMap,
|
||||
backgroundWorkChildren = [],
|
||||
suppressIssueStatusNotices,
|
||||
companyName = null,
|
||||
@@ -296,6 +308,7 @@ export function AssistantChatView({
|
||||
onAttachImage,
|
||||
loading = false,
|
||||
errorText,
|
||||
noticeText,
|
||||
onRetry,
|
||||
onSend,
|
||||
onStopRun,
|
||||
@@ -439,6 +452,17 @@ export function AssistantChatView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{noticeText ? (
|
||||
<div
|
||||
role="status"
|
||||
data-testid="assistant-chat-notice"
|
||||
className="mx-4 mt-3 flex items-start gap-2 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2 text-sm text-amber-950 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
|
||||
<p className="min-w-0 flex-1 break-words">{noticeText}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && comments.length === 0 ? (
|
||||
<div
|
||||
data-testid="assistant-chat-skeleton"
|
||||
@@ -452,7 +476,9 @@ export function AssistantChatView({
|
||||
) : (
|
||||
<div
|
||||
data-testid="selected-agent-chat-body"
|
||||
className="min-w-0 px-4 pb-4 pt-3"
|
||||
// Own scroll container: chat scrolling (including the initial
|
||||
// scroll-to-latest) must never move the page or the sidebar.
|
||||
className="min-h-0 min-w-0 flex-1 overflow-y-auto px-4 pb-4 pt-3"
|
||||
>
|
||||
<IssueChatThread
|
||||
preset="assistant"
|
||||
@@ -469,6 +495,8 @@ export function AssistantChatView({
|
||||
projectId={projectId}
|
||||
agentMap={agentMap}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
userProfileMap={userProfileMap}
|
||||
backgroundWorkChildren={backgroundWorkChildren}
|
||||
suppressIssueStatusNotices={suppressIssueStatusNotices}
|
||||
mentions={mentions}
|
||||
@@ -558,8 +586,23 @@ export function AssistantChat({
|
||||
}, [agents, preferredTargetAgentId, targetAgentId]);
|
||||
|
||||
const [errorText, setErrorText] = useState<string | null>(null);
|
||||
const [noticeText, setNoticeText] = useState<string | null>(null);
|
||||
const [pendingComments, setPendingComments] = useState<PendingAssistantChatComment[]>([]);
|
||||
|
||||
// Resolve human author names/avatars (issue-chat parity: "Dotta", not "You").
|
||||
const { data: companyMembers } = useQuery({
|
||||
queryKey: queryKeys.access.companyUserDirectory(companyId),
|
||||
queryFn: () => accessApi.listUserDirectory(companyId),
|
||||
});
|
||||
const userLabelMap = useMemo(
|
||||
() => buildCompanyUserLabelMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
const userProfileMap = useMemo(
|
||||
() => buildCompanyUserProfileMap(companyMembers?.users),
|
||||
[companyMembers?.users],
|
||||
);
|
||||
|
||||
const liveRunsQuery = useQuery({
|
||||
queryKey: targetAgentId
|
||||
? queryKeys.issues.selectedAgentChatLiveRuns(issueId, targetAgentId)
|
||||
@@ -655,6 +698,7 @@ export function AssistantChat({
|
||||
const handleSend = useCallback(
|
||||
async (body: string) => {
|
||||
setErrorText(null);
|
||||
setNoticeText(null);
|
||||
const optimistic = createOptimisticComment({
|
||||
issueId,
|
||||
companyId,
|
||||
@@ -676,6 +720,14 @@ export function AssistantChat({
|
||||
: pending,
|
||||
),
|
||||
);
|
||||
if (response.wake?.suppressed) {
|
||||
const agentName = response.targetAgent?.name ?? "The agent";
|
||||
setNoticeText(
|
||||
response.wake.reason === "worktree_instance"
|
||||
? `Message saved, but agent runs are disabled on this worktree instance — ${agentName} won't reply here.`
|
||||
: `Message saved, but agent runs are paused on this instance right now — ${agentName} can't reply yet.`,
|
||||
);
|
||||
}
|
||||
await onMessageSent?.();
|
||||
invalidateRuns();
|
||||
} catch (err) {
|
||||
@@ -784,6 +836,8 @@ export function AssistantChat({
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
currentUserId={currentUserId}
|
||||
userLabelMap={userLabelMap}
|
||||
userProfileMap={userProfileMap}
|
||||
backgroundWorkChildren={backgroundWorkChildren}
|
||||
suppressIssueStatusNotices
|
||||
companyName={companyName}
|
||||
@@ -795,6 +849,7 @@ export function AssistantChat({
|
||||
onAttachImage={handleAttachFile}
|
||||
loading={commentsQuery.isLoading}
|
||||
errorText={errorText}
|
||||
noticeText={noticeText}
|
||||
onRetry={errorText ? () => setErrorText(null) : undefined}
|
||||
onSend={handleSend}
|
||||
onStopRun={handleStopRun}
|
||||
|
||||
@@ -287,6 +287,7 @@ function resolveContent(
|
||||
agentMap: Map<string, Agent>,
|
||||
entityNameMap: Map<string, string>,
|
||||
entityTitleMap: Map<string, string> | undefined,
|
||||
boardChatIssueRefs: ReadonlyMap<string, string> | undefined,
|
||||
): CardContent {
|
||||
const details = event.details as Record<string, unknown> | null;
|
||||
const actor = event.actorType === "agent" ? agentMap.get(event.actorId) ?? null : null;
|
||||
@@ -319,9 +320,18 @@ function resolveContent(
|
||||
const approvalAgentName = approvalAgentId ? agentMap.get(approvalAgentId)?.name ?? null : null;
|
||||
const approvalType = details?.type as string | undefined;
|
||||
|
||||
// Conference Room conversations are backed by board_chat issues that are
|
||||
// hidden from the regular issues surface — deep-link them to the room, not
|
||||
// to /issues/<uuid> (PAP-13345).
|
||||
const boardChatRef = event.entityType === "issue"
|
||||
? boardChatIssueRefs?.get(event.entityId) ?? null
|
||||
: null;
|
||||
|
||||
const link = isHeartbeatEvent && heartbeatAgentId
|
||||
? `/agents/${heartbeatAgentId}/runs/${event.entityId}`
|
||||
: event.entityType === "issue"
|
||||
: boardChatRef
|
||||
? `/board-chat/${boardChatRef}`
|
||||
: event.entityType === "issue"
|
||||
? isDocEvent && docKey
|
||||
? `/issues/${issueSlug}#document-${encodeURIComponent(docKey)}`
|
||||
: `/issues/${issueSlug}`
|
||||
@@ -401,6 +411,9 @@ interface FeedCardProps {
|
||||
agentMap: Map<string, Agent>;
|
||||
entityNameMap: Map<string, string>;
|
||||
entityTitleMap?: Map<string, string>;
|
||||
/** Conference Room issue id → conversation ref; events on these issues
|
||||
* deep-link to /board-chat/{ref} instead of the hidden issue page. */
|
||||
boardChatIssueRefs?: ReadonlyMap<string, string>;
|
||||
/** Retained for call-site compatibility; no longer read — the collapsed
|
||||
* card always shows the event-derived status. Lifecycle aggregation (a
|
||||
* later pass) will pass the live status through its own wrapper. */
|
||||
@@ -420,13 +433,14 @@ export function FeedCard({
|
||||
agentMap,
|
||||
entityNameMap,
|
||||
entityTitleMap,
|
||||
boardChatIssueRefs,
|
||||
isActive = false,
|
||||
isMuted = false,
|
||||
isPinned = false,
|
||||
className,
|
||||
}: FeedCardProps) {
|
||||
const details = event.details as Record<string, unknown> | null;
|
||||
const content = resolveContent(event, agentMap, entityNameMap, entityTitleMap);
|
||||
const content = resolveContent(event, agentMap, entityNameMap, entityTitleMap, boardChatIssueRefs);
|
||||
const verb = formatVerb(event.action, details, isPinned ? "pinned" : "chronological");
|
||||
const iconSpec = getIconSpec(event, details, isActive);
|
||||
|
||||
|
||||
@@ -497,7 +497,7 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders assistant preset turns as compact rows and starts live reasoning collapsed", async () => {
|
||||
it("renders assistant preset turns with issue-chat density and starts live reasoning collapsed", async () => {
|
||||
const root = createRoot(container);
|
||||
const transcriptsByRunId = new Map<string, readonly IssueChatTranscriptEntry[]>([
|
||||
[
|
||||
@@ -561,7 +561,8 @@ describe("IssueChatThread", () => {
|
||||
});
|
||||
|
||||
const viewport = container.querySelector('[data-testid="thread-viewport"]') as HTMLDivElement | null;
|
||||
expect(viewport?.className).toContain("space-y-3");
|
||||
// Comfortable (issue-chat) density: full spacing, not the old compact rows.
|
||||
expect(viewport?.className).toContain("space-y-4");
|
||||
const workingButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(element) => element.textContent?.includes("Working"),
|
||||
);
|
||||
|
||||
@@ -33,7 +33,8 @@ describe("resolveIssueChatThreadPreset", () => {
|
||||
it("maps the assistant preset to the lightweight chat defaults", () => {
|
||||
expect(resolveIssueChatThreadPreset("assistant")).toMatchObject({
|
||||
preset: "assistant",
|
||||
density: "compact",
|
||||
// Comfortable = issue-chat message rendering (own messages right/blue).
|
||||
density: "comfortable",
|
||||
workingIndicator: "collapsed",
|
||||
variant: "full",
|
||||
showComposer: true,
|
||||
|
||||
@@ -65,7 +65,9 @@ const ISSUE_CHAT_THREAD_PRESETS: Record<IssueChatThreadPresetName, IssueChatThre
|
||||
composerSingleLine: false,
|
||||
},
|
||||
assistant: {
|
||||
density: "compact",
|
||||
// Comfortable density matches the issue-chat message rendering: the
|
||||
// board user's own messages are right-aligned blue bubbles (PAP-13345).
|
||||
density: "comfortable",
|
||||
workingIndicator: "collapsed",
|
||||
variant: "full",
|
||||
showComposer: true,
|
||||
|
||||
Reference in new issue
Block a user