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/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@corbits/command-palette": "workspace:*",
"@corbits/context-menu": "workspace:*",
"@corbits/inbox": "workspace:*",
"@corbits/inference-settings": "workspace:*",
"@corbits/insights": "workspace:*",
"@corbits/plugins-ui": "workspace:*",
"@corbits/preferences": "workspace:*",
Expand Down
26 changes: 25 additions & 1 deletion apps/web/src/pages/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { libraryArtifactPath } from "@corbits/artifact-ui";
import { describeApiError } from "@corbits/api-query";
import { listPrincipals } from "@corbits/settings-ui";
import { ChatWorkspace, fetchWorkbenchBlob, type Part } from "@corbits/chat-ui";
import { useQueryClient } from "@tanstack/react-query";
import {
getResolvedCatalog,
hasUsableModel as computeHasUsableModel,
} from "@corbits/inference-settings";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useMemo } from "react";

import { fetchArtifactDetail } from "../api";
Expand Down Expand Up @@ -142,6 +146,24 @@ export function ChatPage({
navigate("/plugins");
}, [providerHealthBanner, requestPluginsConnect, navigate]);

// CL-6568: whether this tenant can actually run inference — never
// whether a `model_provider` row merely exists, since seeding mints
// that row with no credential attached. The same resolved-catalog
// read `resolveModelSources` acts on at launch, so a model only
// carries an offering once a real credential backs it.
const resolvedCatalogQuery = useQuery({
queryKey: ["chat-page", "resolved-catalog", tenantId],
queryFn: () => getResolvedCatalog(tenantId ?? ""),
enabled: tenantId !== null,
});
const hasUsableModel =
resolvedCatalogQuery.data !== undefined
? computeHasUsableModel(resolvedCatalogQuery.data)
: undefined;
const handleConnectModel = useCallback(() => {
navigate("/settings/connections");
}, [navigate]);

// A file part with an `artifactId` links back to a real Library row
// (CL-6000) — this always resolves through the Library artifacts read
// surface for that id, the same one `LibraryRoute` reads, never raw blob
Expand Down Expand Up @@ -239,6 +261,8 @@ export function ChatPage({
onOpenArtifact={openArtifact}
onOpenArtifactInLibrary={openArtifactInLibrary}
onFixConnection={handleFixConnection}
{...(hasUsableModel !== undefined ? { hasUsableModel } : {})}
onConnectModel={handleConnectModel}
{...(approvalActions !== undefined ? { approvalActions } : {})}
{...(blockResponses !== undefined ? { blockResponses } : {})}
{...(connectGithubActions !== undefined ? { connectGithubActions } : {})}
Expand Down
17 changes: 4 additions & 13 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions packages/chat-ui/src/chat-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { useStreamingReply, typingAgentNames } from "./streaming-reply";
import { useTurnActivity, TurnActivityStrip } from "./turn-activity";
import type { StreamingReplyState } from "./streaming-reply";
import { AgentBadge, WorkbenchTimeline, messageDomId } from "./timeline";
import { NoUsableModelBanner } from "./no-usable-model-banner";
import type {
CurrentUser,
PinActions,
Expand Down Expand Up @@ -407,6 +408,8 @@ function ChatWorkspaceInner({
onWorkbenchNotFound,
onBackToWorkbenchList,
onSignIn,
hasUsableModel,
onConnectModel,
}: {
readonly tenantId: string;
readonly workbenchId?: string | null;
Expand Down Expand Up @@ -493,6 +496,18 @@ function ChatWorkspaceInner({
* retry that can only ever hit the same 401. Omitted, that state falls
* back to no action at all (never "Try again" for a session that's gone). */
readonly onSignIn?: () => void;
/** Whether this tenant can actually run inference right now — the
* host's read of `hasUsableModel` (`@corbits/inference-settings`)
* against its resolved catalog, never mere `model_provider` row
* presence (CL-6568). `undefined` while that read is still in flight:
* the banner stays hidden rather than flashing "no model" before the
* real answer lands. */
readonly hasUsableModel?: boolean;
/** The pre-send banner's "Connect a model" action — the host's own
* navigation into Settings → AI providers. Undefined still renders the
* banner, just with an inert button, matching every other optional
* action this file wires. */
readonly onConnectModel?: () => void;
}) {
const queryClient = useQueryClient();
const refreshWorkbenchLists = useCallback(() => {
Expand Down Expand Up @@ -522,6 +537,18 @@ function ChatWorkspaceInner({

const composerRef = useRef<ComposerHandle>(null);

/** Retry on a failed-turn strip: the request text was already
* recovered (`findRetryText`) rather than resent silently — a person
* may have since fixed what broke, or may not want it re-sent
* verbatim, so this hands it back into the composer ready to send
* rather than re-sending on their behalf. */
const handleRetryFailedTurn = useCallback(
(_item: TimelineMessageItem, retryText?: string) => {
if (retryText !== undefined) composerRef.current?.insertText(retryText);
},
[],
);

const feed = useWorkbenchFeed({
tenantId,
activeWorkbenchId,
Expand Down Expand Up @@ -1274,6 +1301,7 @@ function ChatWorkspaceInner({
: {})}
reactionActions={reactionActions}
pinActions={pinActions}
onRetryFailedTurn={handleRetryFailedTurn}
pendingActions={{
onRetry: retryPendingSend,
onDiscard: discardPendingSend,
Expand Down Expand Up @@ -1302,6 +1330,11 @@ function ChatWorkspaceInner({
/>
<TurnActivityStrip activity={turnActivity} />
<div className="chat-composer-stack">
{hasUsableModel === false && hasAgentParticipant ? (
<NoUsableModelBanner
onConnectModel={() => onConnectModel?.()}
/>
) : null}
<Composer
ref={composerRef}
agents={mentionCandidatesFromParticipants(
Expand Down Expand Up @@ -1377,6 +1410,8 @@ export function ChatWorkspace({
onWorkbenchNotFound,
onBackToWorkbenchList,
onSignIn,
hasUsableModel,
onConnectModel,
}: {
readonly tenant: TenantResolution;
/** Controlled active workbench (e.g. from the app's URL); null = pick the first. */
Expand Down Expand Up @@ -1456,6 +1491,10 @@ export function ChatWorkspace({
readonly onBackToWorkbenchList?: () => void;
/** See `ChatWorkspaceInner`'s prop of the same name. */
readonly onSignIn?: () => void;
/** See `ChatWorkspaceInner`'s prop of the same name. */
readonly hasUsableModel?: boolean;
/** See `ChatWorkspaceInner`'s prop of the same name. */
readonly onConnectModel?: () => void;
}) {
switch (tenant.kind) {
case "ready":
Expand Down Expand Up @@ -1508,6 +1547,8 @@ export function ChatWorkspace({
? { onBackToWorkbenchList }
: {})}
{...(onSignIn !== undefined ? { onSignIn } : {})}
{...(hasUsableModel !== undefined ? { hasUsableModel } : {})}
{...(onConnectModel !== undefined ? { onConnectModel } : {})}
/>
);
case "empty":
Expand Down
3 changes: 2 additions & 1 deletion packages/chat-ui/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export { ChatWorkspace } from "./chat-workspace";
export type { TenantResolution, PresenceMember } from "./chat-workspace";

export { WorkbenchTimeline, messageDomId } from "./timeline";
export { WorkbenchTimeline, messageDomId, findRetryText } from "./timeline";
export { NoUsableModelBanner } from "./no-usable-model-banner";
export type {
CurrentUser,
ReactionActions,
Expand Down
33 changes: 33 additions & 0 deletions packages/chat-ui/src/no-usable-model-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// CL-6568: the pre-send half of the fix. A tenant whose one seeded
// `model_provider` row carries no credential (the shape seeding always
// leaves behind) can't run inference — the composer stays live (a person
// may still want to leave a note), but this banner says so before they
// invest a long message in a reply that was never coming, and leads
// straight into the connect flow that already works
// (`@corbits/settings-ui`'s `ConnectionsSection`).
import { Button } from "@corbits/react-ui";
import { WarningCircle } from "@corbits/icons";
import { CHAT_STRINGS } from "./strings";

export function NoUsableModelBanner({
onConnectModel,
}: {
readonly onConnectModel: () => void;
}) {
return (
<div className="chat-no-model-banner" role="status">
<WarningCircle aria-hidden="true" />
<span className="chat-no-model-banner-text">
{CHAT_STRINGS.noUsableModelBannerText}
</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={onConnectModel}
>
{CHAT_STRINGS.noUsableModelBannerAction}
</Button>
</div>
);
}
3 changes: 3 additions & 0 deletions packages/chat-ui/src/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,9 @@ export const CHAT_STRINGS = {
replyTimedOutNotice: "No reply arrived — the agent may be unavailable.",
turnFailedTitle: (sender: string) => `${sender} didn't reply`,
turnFailedSub: "No reply arrived — the agent may be unavailable.",
noUsableModelBannerText:
"No model is connected yet, so a reply here won't come through.",
noUsableModelBannerAction: "Connect a model",
rowMenuLabel: "Conversation actions",
rowMenuRename: "Rename",
rowMenuPin: "Pin",
Expand Down
26 changes: 26 additions & 0 deletions packages/chat-ui/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -1787,6 +1787,32 @@
box-sizing: border-box;
}

/* CL-6568's pre-send banner — a tenant with no usable model sees this
above the composer, before typing a single character, rather than
discovering it only after a reply never arrives. Never disables the
composer beneath it. */
.chat-no-model-banner {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 0.5rem;
padding: 0.4rem 0.6rem;
border-radius: var(--ui-radius-md, 0.375rem);
background: color-mix(in srgb, var(--destructive) 8%, transparent);
color: color-mix(in srgb, var(--destructive) 78%, var(--muted-foreground));
font-size: 0.8125rem;
}

.chat-no-model-banner svg {
flex-shrink: 0;
width: 1rem;
height: 1rem;
}

.chat-no-model-banner-text {
flex: 1;
}

.chat-composer {
position: relative;
flex-shrink: 0;
Expand Down
Loading
Loading