Skip to content
KONIGI

AI Assistants / In-product assistance / Assistant sidebar

2 of 5

Assistant sidebar

The conversation has to sit next to the work without covering the work up.

Updated September 12, 2026

Problem

An assistant inside a product needs somewhere to live. A modal covers the work and makes the assistant the task. A separate page abandons the context entirely. Neither lets the viewer read an answer and look at the thing it’s about at the same time.

Solution

A persistent panel down one edge, with the work still visible beside it. The arrangement is unremarkable; what earns it’s the thing it makes possible, which is that the assistant can see what the viewer is looking at. HAX guideline 4 asks for contextually relevant information, and a sidebar that reads the open document, the selected rows, or the current issue is the only version of an in-product assistant that beats opening a general chatbot in another tab. Without that binding, a panel is a worse chatbot in a narrower column.

Because the context is the point, it has to be visible. A small line naming what the assistant can currently see, and ideally letting the viewer change it, answers the question that otherwise gets answered by experiment. It also prevents the more damaging misunderstanding, where a viewer assumes the panel can see the whole workspace and asks a question that silently gets answered from the one open file.

Width is a real constraint rather than a styling choice. Prose needs somewhere around 360 to 420 pixels to read comfortably, and every one of those pixels comes off the document. On a 13-inch laptop the panel and the work are in direct competition, which argues for making it resizable, remembering the size, and collapsing it to a rail rather than a full close.

Dismissal deserves the same care as invocation, which HAX guideline 8 is explicit about. A panel that takes three clicks to get rid of, or that reopens on every navigation, becomes something people fight. One keystroke to open, the same keystroke to close, state remembered per document.

Output routing is the last piece. An answer produced next to a document frequently belongs inside it, and the path from panel to content should be a button rather than a copy and paste. That path is also where the scope contract gets tested, since inserting into the document is the moment the assistant stops advising and starts editing.

Use when

The assistant’s value depends on what the viewer is currently working on, and conversations run more than one turn.

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

Every pixel of panel is a pixel of work, and the people with the least screen are usually the ones with the most need. Persisting the panel across navigation keeps the thread alive and means the assistant follows people into places it knows nothing about. Binding to the current document makes it useful and makes its answers hard to reproduce later, since the same question in a different file gives a different answer for invisible reasons. And on a phone the whole arrangement collapses, so the panel becomes a sheet and the side-by-side reading the pattern exists for is gone.

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 is the mechanism that makes the panel worth more than a browser tab. Inline assist is the lighter alternative for single acts. AI entry point is how people find the panel in the first place. Artifact panel is what happens when the output needs the space instead. Conversation history is where these threads go once the viewer closes the document.

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 captures reference this pattern yet. Captures arrive product by product; see Products for what's in the gallery so far.