diff --git a/.gitignore b/.gitignore index 056f92d750..15d5d0f676 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,6 @@ worktrees/ # Graphify knowledge graph output graphify-out/ + +# Impeccable +.impeccable/ diff --git a/app/css/components/assistant-chat.css b/app/css/components/assistant-chat.css index 40b70c07b2..0aae3795a4 100644 --- a/app/css/components/assistant-chat.css +++ b/app/css/components/assistant-chat.css @@ -1,6 +1,3 @@ -/* The editor's AI assistant chat panel. - Ported from Jiki's ChatPanel/Conversation/ChatMessages/ChatInput CSS modules, - remapped onto Exercism's design tokens. */ .c-assistant-chat { @apply flex flex-col; height: 100%; @@ -74,14 +71,13 @@ object-fit: cover; } - /* The assistant's avatar is Exercism's face on a light purple disc */ & .message.assistant .avatar { - background: theme(colors.lightPurple); + background: var(--backgroundColorF); box-shadow: 0 0 0 1px var(--borderColor6); & .c-icon { width: 22px; height: 22px; - filter: var(--purple-filter); + filter: var(--purple-light-white-dark-filter); } } @@ -97,8 +93,8 @@ } & .message.assistant .content { - background: theme(colors.lightPurple); - border: 1px solid theme(colors.lavender); + background: var(--backgroundColorF); + border: 1px solid var(--borderColor6); } /* --- Message content typography ---------------------------------- */ @@ -121,12 +117,12 @@ } & .content a { @apply font-medium underline; - color: theme(colors.purple); + color: var(--colorPurpleToBrightPurple); } & .content code { @apply font-mono text-13 px-6 py-2 rounded-3; background: var(--backgroundColorD); - color: theme(colors.purple); + color: var(--colorPurpleToBrightPurple); } & .content pre { @apply p-12 rounded-8; @@ -142,6 +138,14 @@ } /* --- Thinking indicator ------------------------------------------ */ + /* Dots are shorter than a line of text, so top alignment misplaces the avatar */ + & .message.thinking-row { + @apply items-center; + & .avatar { + margin-top: 0; + } + } + & .thinking { @apply flex items-center gap-8 text-15; color: var(--textColor6); @@ -149,7 +153,7 @@ width: 6px; height: 6px; border-radius: 50%; - background: theme(colors.purple); + background: var(--colorPurpleToBrightPurple); animation: assistant-chat-pulse 1.4s ease-in-out infinite; } & span:nth-child(2) { @@ -163,25 +167,25 @@ /* --- Notice bars (error / usage) --------------------------------- */ & .chat-status { @apply flex items-center justify-between gap-12 px-32 py-8; - background: theme(colors.superLightOrange); + background: var(--backgroundColorCAlert); border-top: 1px solid theme(colors.orange); & .message-text { @apply text-14; - color: var(--textColor2); + color: var(--textColorCAlert); } & button { @apply text-14 font-medium underline; - color: theme(colors.purple); + color: var(--colorPurpleToBrightPurple); } } & .chat-usage { @apply px-32 py-8; - background: theme(colors.champagne); + background: var(--backgroundColorCAlert); border-top: 1px solid theme(colors.yellowPrompt); & .usage-text { @apply text-14; - color: var(--textColor2); + color: var(--textColorCAlert); } &.at-cap .usage-text { @apply font-medium; @@ -222,7 +226,7 @@ width: 36px; height: 36px; border-radius: 50%; - border: 1px solid theme(colors.gray); + border: 1px solid var(--borderColor6); } } @@ -234,13 +238,13 @@ & .chat-input-field textarea { @apply block w-100 p-12 rounded-8 text-15 leading-150; background: var(--backgroundColorA); - border: 2px solid theme(colors.lavender); + border: 2px solid var(--borderColor6); resize: none; height: 96px; transition: border-color 0.2s; &:focus { outline: none; - border-color: theme(colors.purple); + border-color: var(--colorPurpleToBrightPurple); } &::placeholder { color: var(--textColor6); @@ -280,12 +284,24 @@ } /* --- Start state (no conversation yet) ---------------------------- */ + /* Resizable pane: size off the container, not the viewport */ & .chat-start { @apply flex items-center justify-center rounded-12; - @apply p-40; + container-type: inline-size; + container-name: chat-start; flex: 1; - margin: 24px 32px 32px; - background: theme(colors.lightPurple); + /* Shrink to the scrolling tab panel rather than overflowing it */ + min-height: 0; + /* `safe` keeps the top reachable when the panel is shorter than the card */ + align-items: safe center; + margin: 16px; + padding: 32px 16px; + background: var(--backgroundColorF); + + /* Spacing scale: bind an item to its label, group rows, break between groups */ + --space-bind: 4px; + --space-group: 16px; + --space-break: 32px; } & .chat-start-content { @@ -296,7 +312,8 @@ } & .chat-start-avatar { - @apply flex items-center justify-center mx-auto mb-16; + @apply flex items-center justify-center mx-auto; + margin-bottom: var(--space-group); width: 64px; height: 64px; border-radius: 50%; @@ -314,17 +331,29 @@ } & .chat-start-content h3 { - @apply text-h4 mb-8; + @apply text-h4; + margin-bottom: var(--space-bind); + text-wrap: balance; color: var(--textColor1); } & .chat-start-description { - @apply text-p-base mx-auto mb-24 leading-170; + @apply text-p-base mx-auto; + /* Tighter than body leading so a wrapped sentence reads as one phrase */ + line-height: 1.45; + margin-bottom: var(--space-break); max-width: 460px; + text-wrap: pretty; color: var(--textColor6); + /* Reserve the second line so the typewriter doesn't shift the input */ + @container chat-start (max-width: 340px) { + min-height: calc(2 * 1.45em); + } & .rotating-text { @apply font-medium; - color: theme(colors.purple); + /* Keeps the phrase and its caret together on one line */ + display: inline-block; + color: var(--colorPurpleToBrightPurple); } & .cursor { display: inline-block; @@ -332,26 +361,29 @@ height: 1em; margin-inline-start: 2px; vertical-align: text-bottom; - background: theme(colors.purple); + background: var(--colorPurpleToBrightPurple); animation: assistant-chat-caret 1s steps(2, start) infinite; } } & .chat-start-input { - @apply relative mb-12; + @apply relative; + margin-bottom: var(--space-group); & textarea { - @apply block w-100 rounded-12 text-16 leading-150; - padding: 14px 16px 56px 16px; + @apply block w-100 rounded-12 leading-150; + font-size: 16px; + /* Bottom padding clears the overlaid send button (16 + 40 + 8) */ + padding: 16px 16px 64px; background: var(--backgroundColorA); - border: 2px solid theme(colors.lavender); + border: 2px solid var(--borderColor6); resize: none; overflow: hidden; - min-height: 100px; + min-height: 128px; transition: border-color 0.2s, height 0.1s ease; animation: assistant-chat-glow 5s ease-in-out infinite; &:focus { outline: none; - border-color: theme(colors.purple); + border-color: var(--colorPurpleToBrightPurple); } &::placeholder { color: var(--textColor6); @@ -361,19 +393,32 @@ & .chat-start-send { @apply absolute inline-flex items-center justify-center gap-6; - @apply rounded-8 text-16 font-medium; - bottom: 19px; - inset-inline-end: 14px; + @apply rounded-8 font-medium; + font-size: 16px; + bottom: 16px; + inset-inline-end: 16px; padding: 8px 16px; - background: theme(colors.purpleHover); - color: theme(colors.purple); + /* A wrapped label would outgrow the padding the textarea reserves for it */ + white-space: nowrap; + max-width: calc(100% - 32px); + overflow: hidden; + text-overflow: ellipsis; + background: var(--backgroundColorBtnSecondary); + color: var(--colorPurpleToBrightPurple); opacity: 0.7; cursor: default; transition: all 0.3s ease; & .c-icon { width: 16px; height: 16px; - filter: var(--purple-filter); + flex-shrink: 0; + /* Matches the label's --colorPurpleToBrightPurple in both themes */ + filter: var(--purple-to-bright-purple-filter); + } + @container chat-start (max-width: 200px) { + & .c-icon { + display: none; + } } &.--active { background: linear-gradient( @@ -390,12 +435,18 @@ } } + /* Tick is set inline, not as a flex row, so it flows with the copy when narrow */ & .chat-start-included { - @apply flex items-center justify-center gap-6 text-15; + font-size: 14px; + line-height: 1.4; + text-wrap: balance; color: var(--textColor6); & .c-icon { - width: 18px; - height: 18px; + display: inline-block; + width: 16px; + height: 16px; + margin-inline-end: 8px; + vertical-align: -3px; filter: var(--textColor6-filter); } } diff --git a/app/css/pages/editor.css b/app/css/pages/editor.css index d1ff7ec2c5..165aa6bd70 100644 --- a/app/css/pages/editor.css +++ b/app/css/pages/editor.css @@ -49,6 +49,15 @@ @apply uses-suspense; } +/* `body` reserves a scrollbar gutter globally (see defaults.css) so pages + don't jump horizontally as the scrollbar comes and goes. The editor is + `position: fixed` at 100% height and never scrolls the body, so on this + page that rule can only ever draw an empty, unusable track down the right + edge. Reclaim the gutter here; every other page keeps the default. */ +body:has(.c-react-wrapper-editor) { + overflow-y: hidden; +} + #page-editor { @apply fixed flex flex-col; width: 100%; @@ -339,8 +348,13 @@ } } + /* `auto`, not `scroll`: several of these panels (Results, + Feedback, the assistant's empty state) have nothing to scroll, + and `scroll` paints an empty track down their right edge + regardless. The panels that do overflow — Instructions, Get + help — still get their scrollbar. */ & > .--tab-panel { - overflow-y: scroll; + overflow-y: auto; height: 100%; } @@ -377,6 +391,45 @@ } } + /* The panel is a resizable pane, so its width is independent + of the viewport's — these size off the container, not the + screen. */ + section.request-mentoring { + container-type: inline-size; + container-name: request-mentoring; + + /* The illustration is a fixed 110px and doesn't shrink, so + beside the prose it leaves a column a couple of words wide. + Stack it above the copy instead: it keeps its full size and + the text gets the whole width. */ + & .--intro { + @container request-mentoring (max-width: 460px) { + @apply flex-col items-start; + + /* Lead with the illustration, then the copy. Left + aligned like the buttons and prose below it, so the + stacked layout keeps one edge throughout. */ + & > .c-icon { + @apply order-first; + @apply ml-0 mt-0 mb-16; + } + } + } + + /* Side by side the two don't fit: the "100% free" badge is + squeezed until its label breaks over three lines. Stack + them and each keeps its natural width. */ + & .--cta { + @container request-mentoring (max-width: 420px) { + @apply flex-col items-start; + + & > * + * { + @apply ml-0 mt-8; + } + } + } + } + section.feedback-pane { @apply pb-16 px-24; diff --git a/app/javascript/components/Editor.tsx b/app/javascript/components/Editor.tsx index 7568b8067c..2054f5be3f 100644 --- a/app/javascript/components/Editor.tsx +++ b/app/javascript/components/Editor.tsx @@ -437,6 +437,12 @@ export default ({ { @@ -104,13 +114,22 @@ export const SplitPane = ({ const { isBelowLgWidth = false } = useContext(ScreenSizeContext) || {} + // A width persisted from a wider window (or from before a min-width was + // introduced) can exceed what's available now. The right pane's own + // min-width stops it collapsing, so cap the left pane to match rather than + // letting the two disagree and overflow the container. + const cappedLeftWidth = + typeof leftWidth === 'number' + ? `min(${leftWidth}px, 100% - ${rightMinWidth}px)` + : leftWidth + return (
{!isBelowLgWidth && (
{left}
diff --git a/app/javascript/components/editor/AssistantChat/AssistantChatPanel.tsx b/app/javascript/components/editor/AssistantChat/AssistantChatPanel.tsx index e62305710a..747844d5db 100644 --- a/app/javascript/components/editor/AssistantChat/AssistantChatPanel.tsx +++ b/app/javascript/components/editor/AssistantChat/AssistantChatPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useContext, useEffect, useRef, useState } from 'react' import { Tab, GraphicalIcon, Avatar } from '@/components/common' import { TabsContext } from '@/components/Editor' import { highlightAll } from '@/utils/highlight' @@ -22,7 +22,7 @@ export function AssistantChatPanel(props: { getFiles: () => File[] }): JSX.Element { return ( - + ) @@ -91,10 +91,12 @@ function Conversation({ const usageStatus = deriveUsageStatus(chat.usage) const scrollRef = useRef(null) const configured = Boolean(config.chatUrl) + const { current: currentTab } = useContext(TabsContext) useEffect(() => { + if (currentTab !== 'assistant') return scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) - }, [chat.messages.length, chat.currentResponse]) + }, [chat.messages.length, chat.currentResponse, currentTab]) const send = () => { const message = draft.trim() @@ -171,6 +173,8 @@ function Conversation({ placeholder={ usageStatus?.atCap ? "You've reached your message limit" + : chat.isDisabled + ? 'Waiting for the assistant to reply…' : chat.messages.length > 0 ? 'Respond to the assistant…' : 'Ask about your code, the tests, or the exercise…' @@ -235,7 +239,7 @@ function MessageList({ ))} {status === 'thinking' ? ( -
+
diff --git a/app/javascript/components/editor/AssistantChat/AssistantChatStartState.tsx b/app/javascript/components/editor/AssistantChat/AssistantChatStartState.tsx index 1d54ea8547..789bcb2cd4 100644 --- a/app/javascript/components/editor/AssistantChat/AssistantChatStartState.tsx +++ b/app/javascript/components/editor/AssistantChat/AssistantChatStartState.tsx @@ -61,12 +61,14 @@ export function AssistantChatStartState({ textareaRef.current?.focus() }, []) - // Grow the textarea with its contents + // Grow the textarea with its contents. The minimum lives in CSS (it varies + // with the panel's width), so scrollHeight is used as-is and min-height + // does the flooring. useEffect(() => { const el = textareaRef.current if (!el) return el.style.height = 'auto' - el.style.height = `${Math.max(100, el.scrollHeight)}px` + el.style.height = `${el.scrollHeight}px` }, [message]) const send = () => { @@ -117,9 +119,11 @@ export function AssistantChatStartState({

- {insider - ? 'Unlimited conversations, included with Insiders' - : 'Get assistant help on this exercise - 100% free.'} + + {insider + ? 'Unlimited conversations, included with Insiders' + : 'Get assistant help on this exercise - 100% free.'} +

diff --git a/app/javascript/components/editor/AssistantChat/chatApi.ts b/app/javascript/components/editor/AssistantChat/chatApi.ts index 47c15f0454..89d0186baf 100644 --- a/app/javascript/components/editor/AssistantChat/chatApi.ts +++ b/app/javascript/components/editor/AssistantChat/chatApi.ts @@ -51,6 +51,38 @@ export class ChatRateLimitedError extends Error { } } +/** + * Turns a failure with no message of its own into something a student can act + * on. The typed errors (rate limit, usage cap, captcha) already carry their own + * text and are handled by the caller - this only covers the cases that would + * otherwise surface as raw transport text like "HTTP 500: Internal Server Error". + */ +export function describeChatError(error: unknown): string { + if (error instanceof ChatApiError) { + const type = + error.data && typeof error.data === 'object' + ? (error.data as Record).error + : undefined + + if (type === 'model_overloaded' || error.status === 503) { + return 'The assistant is overloaded. Please try again shortly.' + } + if (error.status === 504) { + return 'The assistant timed out. Please try again.' + } + if (error.status && error.status >= 500) { + return 'The assistant is unavailable. Please try again shortly.' + } + } + + // Network failures surface as a TypeError from fetch, with no status. + if (error instanceof TypeError) { + return 'Connection lost. Please check your network.' + } + + return 'Something went wrong. Please try again.' +} + export async function sendChatMessage( chatUrl: string, payload: ChatRequestPayload, diff --git a/app/javascript/components/editor/AssistantChat/useChat.ts b/app/javascript/components/editor/AssistantChat/useChat.ts index f8a22e80ba..9a1bd6fdda 100644 --- a/app/javascript/components/editor/AssistantChat/useChat.ts +++ b/app/javascript/components/editor/AssistantChat/useChat.ts @@ -5,6 +5,7 @@ import { useChatState } from './useChatState' import { useTurnstile } from './useTurnstile' import { sendChatMessage, + describeChatError, ChatTokenExpiredError, ChatUsageLimitError, ChatRateLimitedError, @@ -183,9 +184,7 @@ export function useChat( return } - const errorMessage = - error instanceof Error ? error.message : 'Something went wrong' - chatState.setError(errorMessage) + chatState.setError(describeChatError(error)) chatState.setStatus('error') } }, diff --git a/app/javascript/components/editor/FeedbackPanel/FeedbackPanelRequestMentoring.tsx b/app/javascript/components/editor/FeedbackPanel/FeedbackPanelRequestMentoring.tsx index ccc4c36459..1b5742fa23 100644 --- a/app/javascript/components/editor/FeedbackPanel/FeedbackPanelRequestMentoring.tsx +++ b/app/javascript/components/editor/FeedbackPanel/FeedbackPanelRequestMentoring.tsx @@ -17,9 +17,9 @@ export function RequestMentoring({ const { t } = useAppTranslation('components/editor/FeedbackPanel') return ( -
+
-
+

{t('feedbackPanelRequestMentoring.takeSolutionToNextLevel')} @@ -39,7 +39,7 @@ export function RequestMentoring({ className="ml-48 mt-20" />

-
+
{t('feedbackPanelRequestMentoring.submitForCodeReview')} @@ -60,7 +60,7 @@ export function RequestMentoring({

}} />