Skip to content
KONIGI Patterns

AI Assistants / Help within apps / Assistant sidebar

‹ 2 of 5 ›

Assistant sidebar

People need to use the assistant while keeping their work in view.

Updated September 12, 2026

Problem

People need to read the assistant’s answer while looking at the work it describes. A dialog covers that work, and a separate page makes them switch back and forth.

Solution

Place the assistant in a panel beside the workspace. Let it use the open document or selected content, and show what it can see. Give people a way to change that selection.

Let people resize and close the panel, and remember their choice. Provide a keyboard shortcut to open and close it. On small screens, let people switch between the conversation and their work without losing their place.

When an answer belongs in the document, provide an insert action. Make the destination clear before changing the document.

Use when

The assistant needs access to the current work, and people need more than one exchange to complete a task.

Don’t use when

The interaction is a single act on a single object. A rewrite, a summary, or a rename is better served by an inline control, and opening a panel for it costs more screen than the task is worth.

Trade-offs

The panel reduces the space available for work. Keeping a conversation open across documents is convenient, but its context can become unclear. Show when the document or selection changes so people know what the next answer will use.

Checklist

  • Does the panel know what the viewer is looking at, and does it say so?
  • Can the viewer change what it can see?
  • Is it resizable, and is the size remembered?
  • Is open and close the same keystroke?
  • Does the conversation survive navigating to another document, and should it?
  • Is there a one-click path from an answer into the document?
  • What does this become at a phone width?
  • Does the panel push the content or overlay it?
  • Where does the history of these conversations live?
  • Does it reopen uninvited after being dismissed?

Compare

Claude keeps the conversation in the main column and pushes generated documents into the side panel, inverting the usual arrangement so the artifact takes the space rather than the chat. Notion binds its panel to the current page and pulls extra context through @-mentions. Scope gets typed rather than inferred. Microsoft Copilot in an Office application scopes to the open file and the tenant, where the panel’s hardest job is explaining which of those two it is answering from. Figma puts the assistant against the canvas selection instead of a document, so the panel’s context is a set of layers and the answers are structural rather than textual.

Scoped context shows what the assistant can access. Inline assist handles a quick change to selected content. AI entry point helps people find the assistant.

Artifact panel gives generated content its own workspace. Conversation history keeps earlier conversations available.

Assistant sidebar anatomy A panel down one edge with the document still visible beside it, a line naming what the assistant can currently see, an insert control routing an answer back into the document, and a marker showing the width taken from the work. Worth the width only if it can see the work the document, still readable seeing: this page change insert every pixel comes off the document 1 2 3 4 1 NAME THE SCOPE Otherwise the viewer answers the question by experiment, and usually wrongly. 2 A PATH BACK IN An answer produced next to a document usually belongs inside it. 3 THE THING IT REFERS TO Without binding to what is on screen this is a worse chatbot in a narrower column. 4 WIDTH IS THE COST On a small screen the panel and the work are in direct competition. Binding to the open document makes answers useful and makes them impossible to reproduce later.
Wireframe — the pattern's anatomy, not any one product's version of it

Implementation

A panel down one edge, worth its width only if it can see the work beside it. It names what it currently sees, routes an answer back into the document, and every pixel it takes comes off the thing being written.

shadcn
npx shadcn@latest add resizable button input
npm
lucide-react
Tokens
--background--foreground--card--card-foreground--muted--muted-foreground--border--input--ring--chart-1

Pricing page draft

Our pricing is designed to grow with you. Whether you are a solo founder or a global enterprise, we have a plan that fits your needs and your budget, with transparent pricing and no hidden fees.

Starter: three seats, unlimited projects, community support. Free.

Team: £12 a seat a month, shared workspaces, priority support, SSO.

Enterprise: custom seat pricing, audit log, dedicated support, invoicing.

AssistantSidebar.tsxThe panel with its scope line, an insert button on any answer that offers one, and a composer; plus the layout that splits, remembers the split, and toggles on one keystroke.

import { useEffect, useState, type ReactNode } from "react";
import { CornerDownLeft, PanelRightOpen } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";

/** One turn in the panel. `insert` is the text an answer offers to put into
 *  the document; an answer without one is a remark, and gets no button. */
export type Turn = { role: "user" | "assistant"; text: string; insert?: string };

type Props = {
  /** What the assistant can currently see, in the viewer's words. Required:
   *  a panel that will not say gets the question answered by experiment. */
  scope: string;
  onChangeScope: () => void;
  turns: Turn[];
  onSend: (text: string) => void;
  /** The path from an answer back into the document. A button, never a
   *  copy and paste. */
  onInsert: (text: string) => void;
};

export function AssistantSidebar({ scope, onChangeScope, turns, onSend, onInsert }: Props) {
  const [draft, setDraft] = useState("");
  const send = () => {
    if (!draft.trim()) return;
    onSend(draft.trim());
    setDraft("");
  };

  return (
    <aside className="flex h-full flex-col bg-card" aria-label="Assistant">
      <div className="m-3 flex items-center gap-2 rounded-md border border-chart-1 bg-chart-1/10 px-2.5 py-1 text-xs">
        <span className="text-chart-1">seeing: {scope}</span>
        <button type="button" className="ml-auto text-muted-foreground hover:text-foreground" onClick={onChangeScope}>change</button>
      </div>

      <div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3">
        {turns.map((t, i) =>
          t.role === "user" ? (
            <div key={i} className="flex justify-end">
              <p className="rounded-xl bg-muted px-3 py-1.5 text-sm text-foreground">{t.text}</p>
            </div>
          ) : (
            <div key={i}>
              <p className="text-sm leading-relaxed text-card-foreground">{t.text}</p>
              {t.insert && (
                <Button variant="outline" size="sm" className="mt-2 h-7 text-xs" onClick={() => onInsert(t.insert!)}>
                  <CornerDownLeft className="size-3.5" /> insert
                </Button>
              )}
            </div>
          ),
        )}
      </div>

      <form onSubmit={(e) => { e.preventDefault(); send(); }} className="p-3">
        <Input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder={`Ask about ${scope}`} aria-label="Ask the assistant" />
      </form>
    </aside>
  );
}

/** The work on the left, the panel down the right edge, one keystroke to open
 *  and the same one to close. The split is the viewer's and is remembered
 *  under `id`. Closed, the panel collapses to a rail rather than vanishing. */
export function AssistantLayout({ id, document, sidebar, open, onToggle, defaultWidth = 38 }: {
  id: string;
  document: ReactNode;
  sidebar: ReactNode;
  open: boolean;
  onToggle: () => void;
  /** Percent of the group. Prose wants roughly 360 to 420px to read. */
  defaultWidth?: number;
}) {
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === "j") { e.preventDefault(); onToggle(); }
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [onToggle]);

  return (
    <ResizablePanelGroup direction="horizontal" autoSaveId={id}>
      <ResizablePanel id="document" order={1} defaultSize={100 - defaultWidth} minSize={40}>{document}</ResizablePanel>
      {open ? (
        <>
          <ResizableHandle />
          <ResizablePanel id="assistant" order={2} defaultSize={defaultWidth} minSize={30} maxSize={55}>{sidebar}</ResizablePanel>
        </>
      ) : (
        <button type="button" onClick={onToggle} aria-label="Open assistant (⌘J)" className="flex w-9 items-start justify-center border-l pt-3 text-muted-foreground hover:text-foreground">
          <PanelRightOpen className="size-4" />
        </button>
      )}
    </ResizablePanelGroup>
  );
}

demo.tsxHow it is called: the pricing draft beside a panel that sees it. Insert rewrites the intro; ⌘J collapses the panel to a rail.

import { useState } from "react";
import { AssistantLayout, AssistantSidebar, type Turn } from "./AssistantSidebar";

const INTRO =
  "Our pricing is designed to grow with you. Whether you are a solo founder or a global enterprise, we have a plan that fits your needs and your budget, with transparent pricing and no hidden fees.";
const PLANS = [
  "Starter: three seats, unlimited projects, community support. Free.",
  "Team: £12 a seat a month, shared workspaces, priority support, SSO.",
  "Enterprise: custom seat pricing, audit log, dedicated support, invoicing.",
];

const TURNS: Turn[] = [
  { role: "user", text: "Is the intro pulling its weight?" },
  {
    role: "assistant",
    text: "The intro runs three sentences before a plan is named, and none of them says a price. One line does the job: seats, not tiers, and the first three are free.",
    insert: "Seats, not tiers. The first three are free.",
  },
];

/** The pricing page draft on the left, the panel on the right seeing it.
 *  Insert replaces the intro the answer is about. ⌘J closes the panel to a
 *  rail and opens it again; the split you drag is remembered. */
export default function Demo() {
  const [intro, setIntro] = useState(INTRO);
  const [open, setOpen] = useState(true);
  const [turns, setTurns] = useState(TURNS);

  return (
    <div className="h-[360px] overflow-hidden rounded-lg border bg-card">
      <AssistantLayout
        id="demo:assistant-sidebar"
        open={open}
        onToggle={() => setOpen((o) => !o)}
        document={
          <article className="h-full overflow-y-auto p-4 text-sm leading-relaxed text-card-foreground">
            <h2 className="text-base font-semibold">Pricing page draft</h2>
            <p className={`mt-2 ${intro === INTRO ? "rounded bg-chart-1/10 px-1" : ""}`}>{intro}</p>
            {PLANS.map((p) => <p key={p} className="mt-2">{p}</p>)}
          </article>
        }
        sidebar={
          <AssistantSidebar
            scope="this page"
            onChangeScope={() => {}}
            turns={turns}
            onInsert={(text) => setIntro(text)}
            onSend={(text) => setTurns([...turns, { role: "user", text }])}
          />
        }
      />
    </div>
  );
}
What it renders. Identical markup in both panes, with only the token values changing.

Examples

No screenshots yet for this pattern. Browse Products to see other examples.